Declarative, Pydantic-native secrets manager for Python 3.12+.
Mix regular Pydantic fields with secret fields in one model. Secrets are
fetched lazily on first access, cached with per-field TTL, masked in repr
and model_dump, and never carry a different type than the one you
declared:
from vaultly import Secret, SecretModel
from vaultly.backends.aws_ssm import AWSSSMBackend
class AppConfig(SecretModel):
stage: str = "dev"
debug: bool = False
db_password: str = Secret("/db/{stage}/password", ttl=300)
api_key: str = Secret("/services/openai/key")
max_conns: int = Secret("/db/{stage}/max_conns")
config = AppConfig(stage="prod", backend=AWSSSMBackend(region_name="eu-west-1"))
config.db_password # -> str, fetched on first access, cached for 300s
config.max_conns # -> int, cast from "42"
config.model_dump() # -> {..., "db_password": "***", "api_key": "***"}pip install vaultly # core (env / mock backends)
pip install 'vaultly[aws]' # + AWS Systems Manager Parameter Store
pip install 'vaultly[vault]' # + HashiCorp Vault (KV v2)| Backend | Import | Notes |
|---|---|---|
EnvBackend |
from vaultly import EnvBackend |
env vars; prefix optional |
MockBackend |
from vaultly.testing.mock import ... |
for tests; tracks call list |
AWSSSMBackend |
from vaultly.backends.aws_ssm import … |
batched via GetParameters |
VaultBackend |
from vaultly.backends.vault import … |
KV v2; path:key selects a field |
RetryingBackend |
from vaultly import RetryingBackend |
wraps any backend, retries TransientError only |
Backends implement a tiny Backend ABC with get(path) -> str and
get_batch(paths) -> dict. Bring your own by subclassing.
{var} placeholders in a secret path are filled from non-secret fields of
the root model:
db_password: str = Secret("/db/{stage}/password")Nested SecretModel fields share the root's context, backend, and cache —
they never resolve {var} against their own fields.
Path validation runs at construction time. A typo ({stge} instead of
{stage}) fails immediately with MissingContextVariableError instead of
six hours later in production.
The annotated field type drives the cast:
| Annotation | Cast |
|---|---|
str |
passthrough |
int / float |
direct |
bool |
true/1/yes/on ↔ false/0/no/off (case-insensitive) |
dict / list |
json.loads |
Custom: Secret("/x", transform=...) replaces the default rule entirely.
Set on a subclass via class kwargs:
class AppConfig(SecretModel, validate="fetch", stale_on_error=True):
..."paths"(default) — verify every{var}resolves at construction."fetch"— additionallyprefetch()everything viabackend.get_batchat construction. Fails fast at startup if any secret is missing."none"— skip both. Errors surface on first access.
Manual control:
config.prefetch() # fetch the whole tree now (uses get_batch)
config.refresh("api_key") # invalidate one and re-fetch
config.refresh_all() # invalidate the whole cachettl= on Secret(...):
None(default) — cache forever.0— never cache; every access hits the backend.> 0— seconds.
Pin a secret to a specific version:
class AppConfig(SecretModel):
db_password: str = Secret("/db/password", version=2)Versioned and unversioned reads of the same path are cached separately. SSM
forwards the version as Name=path:N; Vault as version=N to KV v2; other
backends ignore it. prefetch() falls back to serial get for versioned
secrets (the batch APIs don't support per-path versions).
Free-text description that surfaces in error messages — useful in big models:
db_password: str = Secret(
"/db/{stage}/password",
description="postgres prod credentials",
)from vaultly import RetryingBackend
from vaultly.backends.aws_ssm import AWSSSMBackend
backend = RetryingBackend(
AWSSSMBackend(region_name="eu-west-1"),
max_attempts=3,
base_delay=0.5,
max_delay=4.0,
)Only TransientError is retried (timeouts, throttling, 5xx). Auth and
not-found errors are not. Transport-level retries (DNS, TCP resets) stay
inside each SDK's own retry config — RetryingBackend is strictly the
semantic layer on top.
Opt in per model. When the backend raises TransientError and the cache
holds an expired value, return that with a warning log instead of failing:
class AppConfig(SecretModel, stale_on_error=True):
db_password: str = Secret("/db/password", ttl=60)Default is off — for some deployments, returning a stale credential during an outage is worse than a hard failure.
pyright works out of the box. mypy users should enable the Pydantic
plugin:
# pyproject.toml
[tool.mypy]
plugins = ["pydantic.mypy"]A type checker sees db_password: str as plain str, both at the field
declaration and at the access site.
VaultlyError
├── ConfigError
│ └── MissingContextVariableError
├── SecretNotFoundError # not retried
├── AuthError # not retried
└── TransientError # retried by RetryingBackend
Each backend maps SDK exceptions into this hierarchy.
What vaultly does mask:
repr(model),str(model)— secret fields render as"***".model.model_dump()andmodel.model_dump_json()— same.
What vaultly does not mask:
-
A secret field accessed directly (
model.db_password) is a plainstr.print(model.db_password), log lines, exception messages, and template expansions can leak it. We chosestroverpydantic.SecretStrso that downstream code (DB drivers, HTTP clients) Just Works — but the responsibility to not log it is yours. -
vars(model)andmodel.__dict__bypass__getattribute__and expose the internalMISSINGsentinel for unfetched fields (and fetched values are stored in the cache, not in__dict__, so you'll always see the sentinel there). Usemodel.model_dump()for introspection — it goes through the masking serializer. -
pickle.dumps(model),copy.copy(model),copy.deepcopy(model), andmodel.model_copy()all raiseNotImplementedError. Each would either share or duplicate the in-memory cleartext cache and break nested-root linkage. Construct a fresh instance instead. -
Process memory — secret strings are not zeroed when evicted; this is Python's general posture and would require C-extensions to fix.
-
Logger output —
vaultlyuses thevaultlylogger and emits paths (not values) at WARNING level for stale-on-error and retries. Resolved paths may contain context like{stage}/ tenant id; configure your logger if you treat those as PII:import logging logging.getLogger("vaultly").addFilter(my_pii_scrubber)
- Cache reads and writes are protected by a per-cache
threading.Lock. - Cold-cache fetches are serialized per resolved key — N threads asking for the same uncached secret produce exactly one backend call.
- Hot-path reads (cache hit) take only the cache lock and return without touching the per-key fetch lock.
- Async is not yet supported (planned for a future release). Today,
fetches inside an event loop will block; wrap calls in
asyncio.to_threadif needed.
vaultly's path validation, _root wiring, and optional prefetch run via
a Pydantic model_validator(mode='after'), which fires for both:
AppConfig(stage="prod", backend=...)(calls__init__then validators)AppConfig.model_validate({...})and.model_validate_json(...)(skip__init__, go straight to validators)
AppConfig.model_construct(...) skips all validation by Pydantic design.
You'll get an instance back, but path checks and prefetch don't run; the
first attribute access surfaces any errors lazily.
vaultly follows Semantic Versioning. Breaking
changes to the public API ship in a major version bump. The
Backend.get(path, *, version=None) signature is the most likely
candidate to evolve in a future major release (a SecretQuery-shaped
argument is on the table).