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/listbase64-encode. There's no way to put raw, possibly-invalid-UTF-8 bytes into astrreturn 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-.envtext, like a.pemcertificate or a JSON config file), you don't need any of this - justread_text()/return the string directly, the wayaws/gcp/azure/oci/vault/1password/bitwardenall do.pullis overridden directly, not built asfetch_secrets()+ a write loop. The shared shortcut every built-in provider uses ((self.env_dir / filename).write_text(content)) assumescontentis exactly what belongs on disk. For a binary provider,contentis base64 text - writing it verbatim viawrite_text()would save the encoded string as the file's content, not the original bytes.pull()has to decode first and usewrite_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.