Skip to content

Providers

A provider is any class implementing the Provider protocol (raaz/providers/protocol.py):

class Provider(Protocol):
    name: str

    def __init__(self, config: Optional[ProviderConfig] = None) -> None: ...
    def pull(self, app: str, env: str) -> None: ...
    def push(self, app: str, env: str) -> None: ...
    def list(self, app: str, env: str, prefix: str = None) -> List[str]: ...
    def fetch_secrets(self, app: str, env: str) -> Dict[str, str]: ...

    @staticmethod
    def get_provider() -> Callable[[], Provider]: ...

fetch_secrets returns {filename: content} for every secret under app/env without writing anything to disk - it's the one method both pull and raaz run are built on (pull writes each entry to filename; run parses each entry as dotenv content and injects the result into a subprocess's environment, see CLI Reference). Every built-in provider implements fetch_secrets as the actual fetch logic and pull as a thin wrapper around it - a new provider should do the same, unless your content is binary (see below, where that thin wrapper stops being safe to reuse).

get_provider() is a static method returning a zero-arg factory - that factory is what Raaz actually calls to construct your provider, so it can defer construction (and any credential loading it does) until the provider is actually needed.

Providers are discovered solely via the raaz.providers entry-point group - built-in providers (aws, gcp, azure, oci, vault, 1password, bitwarden, local) declare themselves there in this repo's pyproject.toml, and a third-party package plugs into the exact same mechanism. Discovery is lazy and per-name: raaz only imports the entry point for the provider you actually use, so having a third-party provider installed alongside others never forces an import of packages/SDKs you haven't asked for.

How plugins declare themselves

[project]
name = "raaz-blob"
version = "0.1.0"
dependencies = []

[project.entry-points."raaz.providers"]
blob = "raaz_blob.provider:BlobProvider"

Example: a binary-file provider

Every built-in provider stores .env-style text - file.read_text() on push, file.write_text(content) on pull, throughout. That's fine for .env files, but nothing in the Provider protocol actually requires text: a third-party provider is free to push/pull anything - a TLS keystore, a GPG keyring, a license file - and those are usually genuine binary content, not UTF-8. BlobProvider below stores exactly that, backed by a plain local directory (so this example needs no SDK, no account, and actually runs as shown):

import base64
from pathlib import Path
from typing import Callable, Dict, List, Optional

from raaz.env_files import find_env_files

# Stand-in for a real backend - swap this for an actual SDK/API call in a real provider.
BLOB_STORE = Path.home() / ".example-blob-store"


class BlobProvider:
    name = "blob"

    def __init__(self, config=None):
        self.config = config
        # Matches every file, not just .env-shaped names - env_file_pattern is a plain
        # instance attribute the Provider protocol never requires (see
        # ENV_FILENAME_PATTERN in raaz/env_files.py); each provider picks its own.
        self.env_file_pattern = r".*"
        self.env_dir = Path.cwd()

    def _store_dir(self, app: str, env: str) -> Path:
        return BLOB_STORE / app / env

    def fetch_secrets(self, app: str, env: str) -> Dict[str, str]:
        # fetch_secrets is typed Dict[str, str], but a keystore/certificate/license file
        # isn't valid UTF-8 text - base64 is what lets binary content pass through a
        # str-typed contract without corruption. It's decoded back to raw bytes only at
        # the one point that actually writes to disk: pull(), below.
        result = {}
        for path in self._store_dir(app, env).glob("*"):
            if path.is_file():
                result[path.name] = base64.b64encode(path.read_bytes()).decode("ascii")
        return result

    def pull(self, app: str, env: str) -> None:
        # NOT the thin "fetch_secrets() + write_text() loop" every built-in provider
        # uses (see raaz/providers/vault/provider.py for that shape) - write_text() would
        # re-encode this base64 string as UTF-8 text on disk, corrupting the original
        # binary file the moment it's written. Override pull() directly and decode +
        # write_bytes() instead.
        self.env_dir.mkdir(parents=True, exist_ok=True)
        for filename, b64_content in self.fetch_secrets(app, env).items():
            (self.env_dir / filename).write_bytes(base64.b64decode(b64_content))

    def push(self, app: str, env: str) -> None:
        store_dir = self._store_dir(app, env)
        store_dir.mkdir(parents=True, exist_ok=True)
        for file in find_env_files(self.env_dir, self.env_file_pattern):
            (store_dir / file.name).write_bytes(file.read_bytes())

    def list(self, app: str, env: str, prefix: Optional[str] = None) -> List[str]:
        return [
            path.name for path in self._store_dir(app, env).glob("*")
            if path.is_file() and (not prefix or path.name.startswith(prefix))
        ]

    @staticmethod
    def get_provider() -> Callable[[], "BlobProvider"]:
        return lambda: BlobProvider()

Handling binary content

Two things make this example different from every built-in provider, and both come from the same root cause - fetch_secrets's contract is Dict[str, str], which assumes text:

  • fetch_secrets/list base64-encode. There's no way to put raw, possibly-invalid-UTF-8 bytes into a str return value without either encoding them first or breaking the protocol's type - base64 is the standard way to do that losslessly. If your secrets genuinely are text (even non-.env text, like a .pem certificate or a JSON config file), you don't need any of this - just read_text()/return the string directly, the way aws/gcp/azure/oci/vault/1password/bitwarden all do.
  • pull is overridden directly, not built as fetch_secrets() + a write loop. The shared shortcut every built-in provider uses ((self.env_dir / filename).write_text(content)) assumes content is exactly what belongs on disk. For a binary provider, content is base64 text - writing it verbatim via write_text() would save the encoded string as the file's content, not the original bytes. pull() has to decode first and use write_bytes() instead.

push has the mirror-image concern but it's simpler: read with read_bytes() instead of read_text(), and there's nothing to decode on the way in since the backend here (a local directory) stores raw bytes directly - a real SDK-backed provider whose API only accepts strings (most REST APIs do) would base64-encode in push too, symmetric with fetch_secrets.

Running the plugin

pip install raaz raaz-blob
raaz list providers
# => 1password, aws, azure, bitwarden, blob, gcp, local, oci, vault

Optional: credential fields for raaz doctor / raaz provider configure

If your provider authenticates via a provider.env file (like aws/azure/oci/gcp/vault/1password/ bitwarden - not every provider does; local and the BlobProvider example above need no credentials at all), you can opt into the same interactive raaz provider configure <name> prompt-and-write flow and raaz doctor's missing-field hints that the built-in file-backed providers get - neither is required, and skipping this section just means users of your provider are told to edit provider.env by hand instead. Two independent, optional pieces:

1. Declare config_fields on your *Config class (not on the Provider itself - see AWSConfig/ AzureConfig/OCIConfig/BitwardenConfig for real examples):

from raaz.providers.field_specs import ProviderField

class BlobConfig:
    config_fields = [
        ProviderField("api_token", "BLOB_API_TOKEN", secret=True),
        ProviderField("region", "BLOB_REGION", secret=False),
    ]

    def __init__(self):
        ...  # load from provider.env, same as AWSConfig/AzureConfig/OCIConfig do

    def get_config(self) -> dict:
        ...  # this stays whatever shape your own SDK client actually needs -
             # config_fields is unrelated schema, not a replacement for it

Each ProviderField is (attr, env_name, secret) - attr is the instance attribute this maps to on your *Config object, env_name is the provider.env key / env var name, and secret=True masks it (hidden input, no default echoed back) when raaz provider configure prompts for it.

2. Register that class under a second, separate entry-point group, raaz.provider_configs - not raaz.providers:

[project.entry-points."raaz.provider_configs"]
blob = "raaz_blob.config:BlobConfig"

This is intentionally a different group from where BlobProvider itself is registered, so that reading config_fields (via raaz.providers.get_config_class(name)) never has to import your provider's main module if that module imports your SDK unconditionally (the way the built-in providers guard their own SDK imports) - raaz doctor/raaz provider configure can read your credential schema even when your provider can't yet be constructed at all (no credentials configured, or - like AWS on a machine with no region set anywhere - constructing the real client fails outright). Keep your *Config module's own imports SDK-free if you want this same benefit.

Neither piece is part of the Provider/ProviderConfig protocol itself (raaz/providers/protocol.py) - both are read via getattr(..., None), so omitting either degrades gracefully rather than breaking anything.

Naming secrets

Raaz doesn't dictate a secret-naming scheme to third-party providers, but the built-in cloud/vault providers (aws/gcp/azure/oci/vault/1password/bitwarden) all convert between a local filename and a secret name via raaz/providers/naming.py's build_secret_name/parse_filename - a secret name shaped <app><sep><env><sep><filename>. sep and fold_dots are chosen per provider to match what that backend allows in a secret name - see the PROVIDER_SEPARATORS/per-provider constants in raaz/providers/naming.py for the exact values. Every provider routes both push and pull through the same pair of calls so the two stay in sync - don't hand-roll secret-name string slicing in a new provider, reuse these helpers. (The BlobProvider example above skips this entirely - it uses real nested directories, <app>/<env>/<filename>, since a local filesystem backend has no need to fold that structure into one flat string the way a cloud API's secret name does.)

Note this scheme is filename-only (no path information), matching how push/pull themselves only operate on top-level files in the configured .env dir today (no nested subdirectories) - see CLI Reference.