Declarative Pydantic-native secrets manager
Project description
vaultly
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": "***"}
Install
pip install vaultly # core (env / mock backends)
pip install 'vaultly[aws]' # + AWS Systems Manager Parameter Store
pip install 'vaultly[vault]' # + HashiCorp Vault (KV v2)
Backends
| 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.
Path interpolation
{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.
Casts
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.
Validation modes
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 cache
TTL
ttl= on Secret(...):
None(default) — cache forever.0— never cache; every access hits the backend.> 0— seconds.
Versioning
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).
Description
Free-text description that surfaces in error messages — useful in big models:
db_password: str = Secret(
"/db/{stage}/password",
description="postgres prod credentials",
)
Retries
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.
stale_on_error
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.
Type checking
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.
Errors
VaultlyError
├── ConfigError
│ └── MissingContextVariableError
├── SecretNotFoundError # not retried
├── AuthError # not retried
└── TransientError # retried by RetryingBackend
Each backend maps SDK exceptions into this hierarchy.
Security model
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)
Concurrency
- 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.
Construction paths
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.
Stability
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).
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file vaultly-1.0.0.tar.gz.
File metadata
- Download URL: vaultly-1.0.0.tar.gz
- Upload date:
- Size: 175.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9e513d8a293966c90c3c74b882a9bb9530b5939d220e33cb2de51b84b45577bc
|
|
| MD5 |
423778ccafe3d08252d2d787eb0679af
|
|
| BLAKE2b-256 |
fd4bb317075b1a42c9b0a51885418e223271daf6c1d0d4390be05cb0d40887da
|
File details
Details for the file vaultly-1.0.0-py3-none-any.whl.
File metadata
- Download URL: vaultly-1.0.0-py3-none-any.whl
- Upload date:
- Size: 30.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
229239e93c0c86c2d59d78581ecdbae40b669ba9c7065003cd5dcdcb69d52c89
|
|
| MD5 |
9d587bb5515559f13c29cd1fc7ffb04f
|
|
| BLAKE2b-256 |
947550d1e32e2570a9a99406b5422ef2424b30ef401441cac958dd43b03a92e5
|