Python Bindings
dynamic-config-py pairs this engine with the schema you already write:
Rust resolves, your schema validates, Python reads a cache.
pip install dynamic-config-py # the import is `dynamic_config`
pip install dynamic-config-py[pydantic] # + Pydantic models
pip install dynamic-config-py[pydantic-settings] # + BaseSettings classes
pip install dynamic-config-py[msgspec] # + msgspec Structs
pip install dynamic-config-py[all] # the Pydantic pair
The base install has no dependencies: the engine is compiled into the
wheel, and a dataclasses.dataclass is a schema here. Pydantic and
msgspec are extras because each is a choice — see
What a schema may be for what
each kind validates, including Values, which is
no schema at all:
a configuration read by dotted path, for the keys a program learns at run
time.
from dataclasses import dataclass
from dynamic_config import DynamicConfig
@dataclass
class Database:
host: str = "localhost"
port: int = 5432
db = (
DynamicConfig(Database, key="db")
.file("config.toml")
.env("APP_")
.init_and_current() # a Database instance — cached, not re-validated
)
Everything the Rust side does with sources happens here unchanged: files
merge in call order, the environment beats them, .env sits just below
the real environment, profiles overlay sibling files, discovery sits
below listed files, and the runtime layers bracket the rest. The
precedence chapter is the contract for both
languages.
Where validation happens, and why it matters
reload trigger (watcher / reload() / init())
→ Rust: load, merge, strict checks no GIL
→ Rust: resolved tree → dict GIL, microseconds
→ Python: Model.model_validate(dict) GIL, once per reload
→ ok: swap the cached model, wake readers, run hooks
→ err: nothing installs — the previous model keeps serving
Validation is the engine's own validate hook, which the loader calls
before it installs anything. That placement is the whole design:
- A reader never pays.
current()returns the cached instance — no boundary crossing, no validation, no lock a writer holds. - A reload Pydantic rejects changes nothing. The previous model keeps
serving, the generation does not move, and the last-known-good cache is
not written — exactly what a Rust
validaterefusal does. - Validation runs once per resolve, not once per read and not twice per reload.
The lifecycle
config.init() # load, validate, install
candidate = config.load() # validate only — installs nothing
config.reload() # again, on demand
watch = config.watch(debounce=0.25) # and on every file change
watch.stop() # or use it as a context manager
config.current() # the model; raises before the first load
config.try_current() # or None
config.replace(Database(host="x")) # install one you built
Every call that touches the sources has an async twin —
init_async, load_async, reload_async, changed_async — and
API Reference is the full list, with each pair on
one row.
current() has none, deliberately: the model is cached on the
configuration object, so reading it is an attribute lookup that needs no
await on a loop and blocks nothing on a thread.
watch(poll_interval=...) chooses polling over the platform's
notification backend — what network and overlay filesystems need, where
the native watch registers successfully and then silently never fires.
One watcher per configuration object; two configurations of the same
model watch side by side.
Reacting to a reload
@config.on_change("pool_size") # only when that path moved
def resize(old, new):
pool.resize(new.pool_size)
async for db in config.changes(): # any event loop, no callback
pool.resize(db.pool_size)
Hooks run on whichever thread performed the reload, so keep them short: compare, then signal the subsystem that owns the resource — the reload lifecycle chapter is the same argument in Rust. A hook that raises is reported through Python's unraisable channel and the remaining hooks still run.
Callbacks is the whole surface: what old and
new mean, why a read inside a hook already sees the new model, the
filter above, the scoped with config.on_reload(...) form, and how to
hand work to the thread that owns the resource.
changes() is an async iterator whose wait happens on a worker thread
with the GIL released, so it drives on asyncio, uvloop, trio's asyncio
compatibility layer — anything. There is a blocking changed(timeout=…)
for threads and an awaitable changed_async(timeout=…) for a single
shot. Async & asyncio is the whole story: which calls
block, which thread each piece runs on, and how cancellation behaves.
Testing
The most common shape this library is used in is a test that wants one value pinned:
with config.overrides(pool_size=1, host="localhost"):
assert under_test(config) == "localhost, one connection"
Reloaded on entry, and on exit the previous override layer is restored
and reloaded — restored rather than emptied, so a nested with composes
and a pin made before the block still stands after it. The restore runs
on an exception too, which is the point: the long hand is
set_override, reload, clear_overrides, reload, and it is the last
two that get forgotten, after which one test's pin is the next test's
mystery. Dotted paths are spelled with __ — pool__max_size=1 — the
same nesting rule the environment layer uses.
The other half of an isolated configuration test is the filesystem and
the environment, and the package ships those as a pytest plugin. It
loads through a pytest11 entry point, so installing the package is the
whole setup:
def test_the_service_reads_its_file(dynamic_config_workspace):
(dynamic_config_workspace / "app.toml").write_text('[db]\nport = 5432\n')
config = DynamicConfig(Database, key="db").file("app.toml")
assert config.init_and_current().port == 5432
dynamic_config_workspace is a temporary directory that is also the
working directory, so a relative file("app.toml") finds this test's
copy; dynamic_config_env("APP_") unsets the variables a developer's
shell would otherwise contribute. Neither is autouse, and the module
imports pytest and the standard library only — it is loaded in every
pytest run of every environment this package is installed in, so a
dependency there would be a dependency for all of them.
The reference has both fixtures, and the
conftest.py that makes the environment one automatic.
Secrets are derived, not re-declared
class Database(BaseModel):
host: str
password: SecretStr # this is the declaration
At construction the binding walks model_fields for SecretStr and
SecretBytes — through Optional, unions, containers, nested models,
Pydantic dataclasses and RootModel, as dotted paths — and seeds the
same secret list the generated Rust builder() seeds. A field is listed
under every name a file could carry it under, its aliases included,
because a secret spelled the other way is still a secret
(Aliases). Everything
downstream follows from it: the redacted
last-known-good cache drops those
fields, explain prints them as ***, and Pydantic's ValidationError
— which by default echoes the offending input — is scrubbed to
locations, messages and error types before it crosses the boundary.
Nobody keeps a second list in step with the first, because there is no second list.
What a read costs
The claim is that reading configuration is an attribute lookup. Measured
with python benchmarks/read_path.py, which prints the machine it ran on
above the numbers — because a nanosecond figure without one is not a
measurement:
cpu Intel(R) Core(TM) i7-14700F
cores 28
memory 126 GiB
os Linux 7.1.4
python CPython 3.14.6 (release)
rounds 200000 per measurement
| ns per read | against a global | |
|---|---|---|
config.current() | 29 | 1.1× |
| a module global | 27 | — |
Model.model_validate(dict) | 990 | 34× |
current() is within a tenth of a bare global because it is a Python
attribute: the engine publishes each new model into the configuration
object as it installs, so a read never crosses back into Rust. The third
row is the number that matters — it is what every read would cost if the
model were validated per read instead of once per reload.
The ratios are what travels between machines; the nanoseconds belong to that block above them. A slower laptop moves all three numbers and leaves the two ratios where they are, which is why the argument is made with ratios.
Diagnostics
config.source_of("port") # Origin(kind='env', detail='APP_DB_PORT')
config.is_set("pool.size") # False
print(config.explain("port")) # every layer's answer, as a table
report = config.check() # would it load? any unknown keys?
config.snapshot().to_dict() # the resolved section, as data
The rules the diagnostics chapter states hold
here too: paths, never values — except explain, which is the one
diagnostic whose job is values, and which redacts the secret ones. Every
repr() in the binding shows shape rather than content, so an object
landing in a log line cannot leak a configuration.
changed_paths is the audit half of a reload — what moved, without
what it moved to:
from dynamic_config import changed_paths
config.on_reload(
lambda old, new: log.info("configuration changed: %s",
", ".join(str(c) for c in changed_paths(old, new)))
)
It compares the real values — including secrets, because comparing the mask Pydantic renders would make two different passwords look identical and miss the one change most worth noticing — and reports only paths and whether each was added, removed or changed.
Errors
One hierarchy, mirroring ErrorKind:
from dynamic_config import DynamicConfigError, InvalidError, MissingError
try:
config.init()
except InvalidError as error:
for report in error.errors: # Pydantic's own report, scrubbed
print(report["loc"], report["msg"], report["type"])
except DynamicConfigError as error:
print(error.kind, error.path, error.origin)
DynamicConfigError catches everything; each instance carries kind,
path, origin_kind and origin so a program can branch without
parsing English.
The decorator
from dynamic_config import dynamic_config
@dynamic_config(key="db", files=["config.toml"], env="APP_")
class Database(BaseModel):
host: str
port: int = 5432
Database.config.init()
Database.current()
Sugar over the same object: the decorator builds a DynamicConfig,
stores it as Model.config and attaches current/try_current/
reload/source_of/explain classmethods. It does not load at
import time — reading files while a module is being imported is a
surprise nobody asked for; pass init=True when a script wants exactly
that. Decorating one class twice is an error, mirroring the crate's
one-configuration-per-type rule.
In Python a runtime-configured decorator is idiomatic where Rust's argument-free attribute was not. The engine-level rule holds in both: declaration is separate from the configurable builder underneath.
Inherit Configured if you type-check
The decorator attaches its six members at runtime, and no type checker
can see that: Python has no way to spell "this class, plus these
members", so Database.current() is an attr-defined error under
mypy --strict and nothing at all to an editor's completion.
from dynamic_config import Configured, dynamic_config
@dynamic_config(key="db", files=["config.toml"])
class Database(Configured, BaseModel):
host: str = "localhost"
Database.current().host # `str`, and it completes
Configured declares the members where a checker sees them; the
decorator fills them in. It adds no fields, so model_fields is
unchanged, and the runtime behaviour is identical either way.
The decorator on its own still works and is not deprecated — but it
cannot be made visible to a checker, and tests/typing/usage.py in the
repository is where that promise is kept: mypy --strict runs over a
file written the way a caller writes one, because types that regress for
a user are invisible to a test suite.
Typing
DynamicConfig is Generic[M], so current() comes back as your
model rather than as Any:
config = DynamicConfig(Database, key="db")
reveal_type(config.current()) # Database
Stubs ship with the package and mypy --strict runs over the facade in
CI, so this stays true.
With a web framework
@app.get("/health")
async def health() -> dict[str, str]:
db = config.current() # once per request; reuse the value
return {"host": db.host}
Read current() once per request and use that value, exactly as the Rust
guidance says: a reload landing mid-request would otherwise show one
request two configurations. Web Frameworks has
the FastAPI, Flask and Django patterns in full, including what not to do
in each (copying into app.config, freezing into Django's settings), and
the pre-forking-server caveats.
Data types
Whatever Pydantic validates, this loads: enums, datetime, UUID,
Decimal, paths, addresses, containers, nested models, unions,
SecretStr. Data Types covers the whole range and the
three conversions that have to be exact — an integer staying an integer,
a bool not becoming 1, and a large u64 keeping its digits.
A msgspec.Struct is the fifth kind of declaration, and the fastest:
msgspec builds the instance in C, declares a secret through
Meta(extra={"secret": True}), and leaves unknown keys to the struct's
own forbid_unknown_fields. A msgspec
Struct
has the three answers that are msgspec's rather than this library's.
And whatever a model may be, it may be the schema: inheritance,
mixins, model_config, validators, computed fields, RootModel,
Pydantic dataclasses, generics, discriminated unions — and
pydantic_settings.BaseSettings, whose own sourcing declaration
DynamicConfig.from_settings(...) translates into engine sources so an
existing settings class keeps the variable names its deployment already
sets. What a schema may be and
pydantic-settings.
What is not exposed, and why
| Not exposed | Why |
|---|---|
| The store crates themselves | their clients — gRPC, the AWS SDK, three HTTP stacks — would ride into every wheel |
| Encrypted files | decryption needs a Decryptor, which is a Rust trait; decrypt with the CLI and point this at the result |
save, JSON Schema | Pydantic already does both, better |
The door those crates go through is exposed: RemoteSource is
implementable in Python, so a store with no Rust client is a class with
fetch() and describe() — see Remote Stores in
Python. The first row is on the roadmap as an
opt-in wheel; the last two are not.
Limitations has the full list with the reasoning
— including the constraints that are not omissions at all: sources fixed
after the first load, one watcher per configuration, and why
ValidationError is rebuilt rather than re-raised.
Examples
Eighteen runnable scripts ship with the package —
examples/,
from the twenty-line quick start to multi-tenant configuration, the
diagnostics tour, several configurations on one event loop (as values
and as decorated classes), every callback shape, an existing
pydantic-settings class, a remote store written in Python, and the
three framework integrations. None needs a server or a setup step,
and all of them run in CI, because an example nobody runs is
documentation that has already started rotting — and the framework ones
are driven again by the integration suite, which asserts what they
answer rather than only that they exit zero.
How it is built
Implementation Details is the inside view: where validation is hooked and why that placement is the whole design, how a validated model is published exactly once, why the read path never crosses back into Rust, what the GIL and thread rules are, and what the two changes the Rust crate needed were.
Interpreter shutdown
A watcher thread that outlives finalization would call into a Python that
is no longer there — the classic embedding crash. The binding registers
an atexit handler that stops every watcher and drops every cached model
while the interpreter is still whole, and the test suite exercises exactly
that: a process exiting mid-reload-storm with a detached watcher running.
API Reference
Everything the package exports, in one place. Where a call has an async
twin it sits in the same row, because the pair is the point: the
synchronous one is right from a thread, a script or a test, and the
_async one hands the blocking half to an executor so the event loop is
never the thing waiting.
from dynamic_config import DynamicConfig, dynamic_config, set_executor, changed_paths
DynamicConfig(model, key, *, executor=None, secrets=())
Generic[M], so every method that hands a model back hands back your
model rather than Any.
| Parameter | Default | Meaning |
|---|---|---|
model | required | the schema class: a dataclasses.dataclass, a Pydantic model, a Pydantic dataclass, a msgspec.Struct — see What a schema may be — or Values, which is no schema at all |
key | required | the section this configuration reads ([db] in a TOML file). It also names the environment prefix, the cache entry and every diagnostic; "" is a configuration with nothing to call itself, which goes with whole_document() |
executor | None | which pool runs the blocking half of the async calls; None follows set_executor |
secrets | () | dotted paths whose values must never reach a diagnostic. A declared model already says which of its fields are secret and these are added to that; for a Values configuration they are the only such statement, and the cache(path, mode) modes that redact are refused without them |
DynamicConfig.from_settings(model, key, *, executor=None)
A configuration whose sources come from a pydantic_settings.BaseSettings
class's own SettingsConfigDict — its files, its .env, its variable
names — so an existing settings class keeps working and gains layering,
provenance and hot reload. Refuses what has no engine equivalent
(secrets_dir, cli_parse_args, an overridden
settings_customise_sources) instead of dropping it. Chain more sources
onto the result as usual. See
pydantic-settings.
Sources
Each returns the configuration, so they chain. All of them raise once anything has loaded — sources are how a configuration is identified.
| Method | Effect |
|---|---|
file(path) | Adds a file. Merged in call order, later wins; a missing one is skipped |
discover(name, paths) | Looks for {name}.{ext} in each directory, below listed files |
env(prefix) | The environment layer: prefix plus the section key (APP_DB_*) |
nest(separator) | The separator that means nesting inside a variable name; __ unless said |
allow_empty_env() | Treats FOO= as set-to-empty rather than unset |
strict_env() | Refuses ambiguous spellings — off, no, nil — naming the variable |
whole_document() | Reads each document as this model's values, with no section header. See Document Shape |
env_file(path) | A .env read as the environment layer, just below the real one |
profile_env(variable) | The variable naming the active profile, for sibling files |
cache(path, mode="redacted") | A last-known-good cache; redacted, full or fingerprint |
A remote store, written in Python
The eight store crates stay in Rust; the door does not. Any object with
fetch() and describe() is a remote store here — see
RemoteSource.
The same four methods take a compiled store from the opt-in second
wheel (pip install dynamic-config-py[remote]): Etcd(...) and
Vault(...) from dynamic_config.remote are RemoteSource
implementations like any other, and their API is
on its own page because they are a separate
distribution.
| Synchronous | Async twin | Does |
|---|---|---|
remote(source) | — | Installs the store. Chains; fetches nothing. Allowed after the first load too, unlike the source methods |
refresh_remote() | refresh_remote_async() | Reads the store and keeps the document, for the next load |
clear_remote() | — | Drops the fetched document; the source stays installed |
remote_description | — | What the installed store's describe() said, or None |
Fetching is explicit, exactly as it is in Rust: a load merges the document that was last fetched and touches no network. The remote layer sits above the files and below the environment.
class OurService(RemoteSource):
def fetch(self):
return httpx.get(URL, timeout=5).text, Format.JSON
def describe(self):
return "our service"
config = DynamicConfig(Database, key="db").remote(OurService())
config.refresh_remote()
config.init()
Three things a caller has to know, each of which has a test:
- The timeout is the
fetch()implementation's. Nothing on the Rust side can interrupt Python that has decided not to return, so the deadline belongs to the client the method calls. - A raising
fetch()is reported, not fatal. It arrives asRemoteError— orAuthError, if that is what was raised — with the original attached as__cause__. Its message is deliberately not repeated: a store's exception routinely carries the URL it called. The previous document and the previous model both keep serving. - A
fetch()may read its own configuration —current(),snapshot(),explain()— because no lock is held across it. The one thing it may not do is callrefresh_remote(), which is refused by name rather than left to recurse.
describe() is asked once, when the source is installed, because the
engine reads it on the load path and a load must not re-enter Python.
Lifecycle
| Synchronous | Async twin | Does |
|---|---|---|
init() | init_async() | Loads, validates, installs |
init_and_current() | init_and_current_async() | Both of the above, for the code that wants the values rather than the object |
load() | load_async() | Loads and validates, installs nothing; returns the candidate |
reload() | reload_async() | Loads, validates, installs again, rewrites the cache |
current() | — | The installed model. One attribute lookup; raises NotInitialisedError before the first load |
try_current() | — | The same, or None |
replace(model) | — | Installs a model you built, firing the hooks. status() and snapshot() still describe the last real load |
changed(timeout=None) | changed_async(timeout=None) | Blocks until the next install; None on timeout |
| — | changes() | An async iterator over every install from here on |
watch(debounce=0.25, poll_interval=None) | watch_async(…) | Starts a watcher; returns a Watch |
on_reload(hook) | — | Runs hook(old, new) after every install; returns a HookGuard. Usable as a decorator |
on_change(*paths) | — | The decorator form of the same, firing only when one of paths moved. See Callbacks |
current() and try_current() have no async twin because there is
nothing to await: the model is cached on the object, so the read is an
attribute lookup on the loop and on a thread alike.
watch has a twin for a narrower reason than the others: the watcher is
a thread either way, so what watch_async moves off the loop is only
starting it — resolving directories, registering each with the
notification backend, spawning the carrier thread. That is syscalls
rather than I/O, and it measures a fraction of a millisecond natively;
but it grows with the number of directories, and poll_interval takes a
baseline scan of everything it watches first, which is single-digit
milliseconds over a large directory and worse over the network
filesystems that are the reason to poll. A startup handler runs once and
would survive either call; the async one is the same work with the wait
on a worker.
Watch.stop() has no twin, and that is not an omission: it drops the
backend, which closes the channel the watcher thread is parked on, and
returns without joining it or waiting out a debounce window. Under a
tenth of a millisecond, so a shutdown handler can call it directly.
Runtime layers
The two layers that bracket every source: defaults lose to everything, overrides beat everything.
| Method | Effect |
|---|---|
set_default(path, value) | A fallback the program computes and a file need not state |
set_defaults(mapping_or_model) | Every field of a mapping or model, at once |
set_override(path, value) | Outranks every source — what makes a test authoritative |
set_assignments(["key=value", …]) | --set-style strings |
overrides(**values) | A with block that pins those values and restores the previous layer after it — see Testing |
clear_defaults() / clear_overrides() / clear_assignments() | Empty one layer |
alias(old, new) | Keeps files written before a rename working |
bind_env(path, variable) | Maps one field to one variable by name — PORT, DATABASE_URL |
These take effect on the next load, so a set_override after init()
wants a reload() behind it. overrides(...) is the exception, and that
is the whole reason it exists: it reloads on entry and again on exit.
Diagnostics
| Method | Returns |
|---|---|
source_of(path) | Origin — which layer would supply it — or None |
is_set(path) | Whether anything supplies it |
explain(path) | Explanation — every layer's answer, secrets redacted |
check() | Report — would it load, and is anything unknown |
snapshot() | Snapshot — the resolved section as data |
Telemetry
| Method | Returns |
|---|---|
status() | ConfigStatus — generation, staleness, the last reason, the failure streak |
remote_status() | RemoteStatus — fetches, staleness, reachable, the failure streak |
Both are a handful of atomic loads: no source is re-read and nothing
blocks, which is what makes them cheap enough for a scrape. Exposition
renders either as Prometheus text — see Telemetry.
Properties
key | The section key |
model | The Pydantic class |
generation | How many models have been installed; zero before the first |
repr(config) is those three and nothing else —
<DynamicConfig Database key='db' generation=3> — which is what a
debugger session wants and what a log line can survive: shape, never
values, generation=0 meaning nothing has installed yet.
Testing
overrides(**values)
The override layer, scoped to a with block:
with config.overrides(pool_size=1, host="localhost"):
... # reloaded on entry, with those values pinned
# the previous overrides are restored and reloaded on exit
The long hand is set_override, reload, clear_overrides, reload —
four lines whose last two are easy to forget, and forgetting them leaks
into the next test through whatever configuration the module built.
- Restores rather than clears. The exit puts back the layer the block
found, so a nested
withcomposes and an override set before the block still stands after it. - Restores on an exception too. A failing assertion inside the block does not decide what the next test sees.
__is a dot, the same nesting rule the environment layer uses:pool__max_size=1meanspool.max_size. A field whose own name contains__cannot be spelled this way — useset_override.- With no arguments it pins nothing and still restores, which wraps a
block that calls
set_overrideitself.
The pytest plugin
The package ships one, and pytest finds it through a pytest11 entry
point — installing dynamic-config-py is the whole setup:
def test_the_service_reads_its_file(dynamic_config_workspace):
(dynamic_config_workspace / "app.toml").write_text('[db]\nport = 5432\n')
config = DynamicConfig(Database, key="db").file("app.toml")
assert config.init_and_current().port == 5432
| Fixture | Is |
|---|---|
dynamic_config_workspace | A tmp_path that is also the working directory, so file("app.toml") finds this test's copy |
dynamic_config_env | A factory: dynamic_config_env("APP_") unsets every variable with that prefix for the test |
Nothing is autouse — a plugin that arrives with the wheel should not
change what a test sees until the test asks. The environment one is
usually wanted for every test, which is one fixture in your own
conftest.py:
@pytest.fixture(autouse=True)
def _clean_environment(dynamic_config_env):
dynamic_config_env("APP_")
A suite that turns entry-point discovery off — CI images increasingly set
PYTEST_DISABLE_PLUGIN_AUTOLOAD — asks for it by name instead:
-p dynamic_config.pytest, on the command line or in addopts.
dynamic_config.pytest imports pytest and the standard library and
nothing else — not Pydantic, and not the rest of this package's public
surface. It is auto-loaded in every pytest run of every environment the
package is installed in, so a dependency there would be a dependency for
all of them; the binding's own suite runs on these two fixtures, and a
subprocess test imports the module with Pydantic made unimportable.
Module functions
__version__ and __engine_version__
The wheel's version, and the version of the dynamic-config crate
compiled into it. The two move independently — the Python package
versions on its own schedule — so a bug report can name both.
set_executor(executor)
Process-wide choice of which thread pool pays for the blocking half of
the async calls. None restores the loop's own. Waits deliberately stay
on the loop's default executor — see
Async & asyncio.
secret_paths(model)
Every dotted path in model that is declared secret, in whichever
vocabulary the declaration uses: a SecretStr or SecretBytes — through
Optional, unions, containers, nested models, Pydantic dataclasses and
RootModel — a dataclass field's metadata={"secret": True}, or a
msgspec.Meta(extra={"secret": True}). This is what seeds the redaction,
and it is derived rather than declared twice, so nobody keeps a second
list in step with the first. A field lists every name a file could
carry it under (each alias and the field name), because a secret spelled
the other way is still a secret; see
Aliases.
Values
A configuration with no schema class: pass Values where a model
goes, and every load hands back one of these — a Mapping read by dotted
path. See Values: a configuration with no
schema for what it gives
up, and the schemaless chapter for the Rust half.
| Member | Answers |
|---|---|
values[path] | the value at a dotted path, or KeyError |
values.get(path, default=None) | the same, with a default |
path in values | whether anything is there |
len(values), iter(values) | the top-level keys |
values.to_dict() | a plain dict of the whole configuration |
values.leaf_paths() | every dotted path that holds a value, sorted |
repr(values) | the keys, never a value |
Values.sub(path)
The subtree at path, as a Values of its own — relative paths below it,
so a subsystem can be handed a section without being told where it sits.
Empty when the path holds nothing, and empty when it holds a value rather
than a table; in is how to tell those apart.
changed_paths(previous, current)
Which paths differ between two models (or mappings), as
Change values. Paths only, never values — including for
secrets, whose values are compared but never reported.
@dynamic_config(...)
Attaches a configuration to a model class and returns the class.
Every argument is keyword-only, and every one of them is one fluent call on the configuration it builds — the decorator is the declaration-shaped spelling, not a second set of behaviour.
| Argument | Default | The call it makes | Meaning |
|---|---|---|---|
key | required | DynamicConfig(model, key) | The section key: which top-level table is this model's. Also names the environment prefix, the cache entry and every diagnostic. "" for a configuration with nothing to call itself |
files | () | .file(path), once each | Files to merge, in order — later wins, a missing one is skipped |
discover | None | .discover(name, paths) | (name, paths): look for {name}.{ext} in each directory, below the listed files |
env | None | .env(prefix) | The environment prefix, trailing underscore included |
nest | None | .nest(separator) | What means nesting inside a variable name; __ unless given |
allow_empty_env | False | .allow_empty_env() | Treat FOO= as set-to-empty rather than unset |
strict_env | False | .strict_env() | Refuse ambiguous spellings — off, no, nil |
whole_document | False | .whole_document() | The documents carry no section header: each one is this model's values. See Document Shape |
env_files | () | .env_file(path) | .env files, read as the environment layer and below the real one |
profile_env | None | .profile_env(variable) | The variable naming the active profile, for sibling files |
cache / cache_mode | None / "redacted" | .cache(path, mode) | Last-known-good cache; redacted, full or fingerprint |
init | False | .init() | Load at decoration — off, because import time is not load time |
watch | None | .watch(debounce).detach() | Start a detached watcher with this debounce. It does not load: pair it with init=True |
It attaches config, current, try_current, reload, source_of and
explain to the class, and refuses a model that declares a field with
one of those names.
examples/21_decorator_whole_document.py runs every row of that table,
and shows whole_document=True against a file with no header.
Configured
The mixin that makes those six visible to a type checker and to an
editor — class Database(Configured, BaseModel). Runtime behaviour is
unchanged; what changes is that Database.current() is typed as
Database rather than being an attr-defined error. See
the decorator.
Types
Origin
kind (file, env, inline, remote, runtime, unknown),
detail (the path, the variable, the store). str() renders it as the
crate does: in /etc/app.toml, from APP_DB_PORT.
Explanation
path, rows (a tuple of Contribution: layer, value, origin),
winner. str() is the table; repr() is shape only, because a repr
lands in a log by accident and this is the one object that carries
values.
Report
key, resolved (tuple of Resolved: path, origin), unknown
(tuple of UnknownKey: path, suggestion), failure,
unknown_checked, and the is_clean property. str(report) renders the
table the Rust crate prints — paths and origins, never values.
unknown_checked is False when there was no field list to compare a
document against, which is a Values configuration: an empty
unknown from one is not an all-clear, and the rendering says
unknown keys: not checked (no field list) rather than letting it read
as one.
Snapshot
to_dict(), source_of(path), contains(path), leaf_paths(),
top_level_keys(), is_empty(), diff(other) → Change values.
Change
path and kind (added, removed, changed).
ConfigStatus, RemoteStatus, Failure
What status() and remote_status() hand back, and the failure either
may carry. Frozen dataclasses of counts, durations and fixed enums —
never a value, never a store address. Field by field in
Telemetry.
Exposition
One or more configurations' status as a Prometheus text body:
Exposition().add(name, config).add_remote(name, config).render(), plus
add_with/add_remote_with for labels of your own. Built per scrape and
thrown away. The metric names are API; see Telemetry.
RemoteSource
The ABC a store written in Python subclasses. Two abstract methods, so a
class missing one cannot be instantiated at all — a TypeError where the
store is constructed, rather than something a deployment discovers at its
first refresh:
| Method | Answers |
|---|---|
fetch() | (document, format) — the text, and the Format it is written in. Raise to report a failure |
describe() | The store's name, for provenance and error messages. Asked once, at install |
Name the store, never the credential that reaches it: describe() is
what source_of(...) reports and what every remote error carries.
Format
Format.JSON, Format.TOML, Format.YAML — a str enum, so a plain
"json" is accepted too.
Watch
running, stop(), detach(), and a context manager that stops on
exit.
HookGuard
close(), hook, and a context manager that unregisters on exit. It is
also callable, forwarding to the hook — which is what lets
@config.on_reload decorate a function without taking it away.
Exceptions
DynamicConfigError is the base — catching it catches everything. Each
instance carries kind, path, origin_kind and origin.
| Class | Raised when |
|---|---|
IoError | A source exists but could not be read |
ParseError | A source is not valid in its format |
MissingError | A required value is supplied by nothing |
TypeMismatchError | A value cannot become the requested type |
EnvError | An environment variable could not be interpreted |
InvalidError | The configuration as a whole was rejected — Pydantic's report is on .errors, scrubbed of input values, and [] for a schema that raises a message rather than a report (a dataclass, a msgspec.Struct) |
RemoteError | A remote store could not be read — unreachable, refusing, malformed |
AuthError | A credential was rejected, or could not be obtained. Distinct from RemoteError on purpose: waiting fixes one and not the other |
DecryptError | An encrypted source could not be decrypted |
BackendError | The engine refused — a source added after loading, for instance |
NotInitialisedError | current() before the first successful load |
Callbacks
Loading configuration is the easy half. The half that decides whether hot reload is useful is what happens next: a pool that has to be resized, a client that has to be rebuilt, an audit line somebody will read at three in the morning.
@config.on_change("pool.max_size")
def resize(old, new):
pool.resize(new.pool.max_size)
That is the whole idea. The rest of this page is what the arguments mean, what a hook may and may not do, and the four other shapes the same thing takes.
The five shapes
| Shape | Runs |
|---|---|
config.on_reload(hook) | after every install |
@config.on_reload | the same, with the function's name kept |
@config.on_change("path", …) | only when one of those paths moved |
with config.on_reload(hook): | for the length of the block |
async for model in config.changes() | on your event loop, no callback |
All but the last hand back a HookGuard, which
unregisters on close() or at the end of a with.
What the arguments mean
def hook(old: Model | None, new: Model) -> None:
old is None for the first install and the previous model after that.
That is how a hook tells starting up from something changed without
keeping a flag:
@config.on_reload
def audit(old, new):
if old is None:
log.info("loaded %s:%s", new.host, new.port)
else:
log.info("reloaded: %s", ", ".join(str(c) for c in changed_paths(old, new)))
changed_paths is the
audit half of a reload: which paths moved, never what they moved to. It
compares secrets — comparing the mask would make two different passwords
look equal — and reports paths only, so the line above is safe to log.
A read inside a hook sees the new model: this configuration's own
publish hook is registered first, deliberately, so config.current()
agrees with the new argument rather than lagging it by one install.
config.generation is already bumped, too.
The decorator keeps the function
on_reload returns the guard, and the guard forwards calls to the hook,
so decorating does not take your function away:
@config.on_reload
def resize(old, new):
pool.resize(new.pool.max_size)
resize(None, config.current()) # still the function — useful in a test
resize.hook # the undecorated one, if you need it
resize.close() # and still the registration
Without that, @config.on_reload would quietly rebind the name to
something you cannot call, which is the kind of surprise a decorator
should never be.
Filtering: react to a path, not to an install
A reload installs a whole model whether or not the field you care about is in it — a neighbouring key changed, an operator re-saved the file, a watcher fired on a touch. Rebuilding a connection pool on every one of those is churn a service can feel:
@config.on_change("host", "port")
def reconnect(old, new):
pool.rebuild() # expensive; only when the address really moved
Details worth knowing:
- Paths are dotted, as everywhere else in this crate, and a path
naming a table covers what is inside it:
on_change("pool")fires forpool.max_size. - The first install always counts as a change, because there is
nothing to compare it against. A hook that sets something up runs at
startup rather than waiting for the first edit — register it before
init()and it will. - The comparison is
changed_paths, so a secret that changed is noticed without being printed. - It is a decorator factory:
config.on_change("port")(hook)is the same thing written without the@.
What a hook may do, and what it should not
A hook runs on the thread that reloaded — the watcher's thread, or
the caller's for an explicit reload(). So:
- Do compare, log, set a flag, put something on a queue, call
loop.call_soon_threadsafe. - Do not rebuild a connection pool, make a network call, or take a lock a request handler holds. A slow hook delays the next reload and holds a thread the watcher needs.
The rule is the one the Rust reload lifecycle gives: compare, then signal the thing that owns the resource.
work: queue.Queue[Service] = queue.Queue()
config.on_reload(lambda old, new: work.put(new)) # the hook ends here
On an event loop, the same handover is call_soon_threadsafe:
loop = asyncio.get_running_loop()
config.on_reload(lambda old, new: loop.call_soon_threadsafe(queue.put_nowait, new))
…though if you are on a loop already, changes() is usually
the better answer: same events, awaited rather than pushed, and the body
runs on the loop where it can await.
When a hook raises
The raise is reported, through Python's unraisable channel
(sys.unraisablehook), and the hooks after it still run. The install
itself already happened — a hook is a reaction, not a veto. What vetoes
a bad configuration is validation, which runs
before anything installs.
If you want a hook's failure to be loud, make it loud yourself:
@config.on_reload
def resize(old, new):
try:
pool.resize(new.pool.max_size)
except Exception:
log.exception("resize failed for generation %s", config.generation)
Lifetime
A hook lives as long as its guard is open, and a configuration holds its hooks — so a hook registered and forgotten runs for the life of the process. That is fine for the ones a service sets up at startup, and a leak for the ones a test or a request registers:
with config.on_reload(record):
... # registered here
# and gone here, however the block ended
Hooks hold a weak reference back to the configuration internally, so a hook never keeps a configuration alive; the reverse is not true, so a closure that captures a large object keeps that object alive until the guard closes.
The whole surface, running
examples/16_callbacks.py
runs all five shapes end to end, with a stand-in pool that records what
each hook cost it — including the handover to a thread that owns the
resource, and the async follower that needs no callback at all.
Async & asyncio
Nothing blocking ever runs on your event loop, and nothing on the loop is required to use this library. Both halves matter: a service that reads configuration should not stall its loop on disk I/O, and a script that has no loop should not have to start one.
The shape
await config.init_async() # load, validate, install
candidate = await config.load_async()
await config.reload_async()
model = await config.changed_async(timeout=30) # the next install, once
async for db in config.changes(): # every install, forever
await pool.resize(db.pool_size)
Every _async method is the synchronous one performed on a worker
thread, with the GIL released for the blocking part — reading and parsing
files. What comes back onto the loop is the finished model. The
synchronous methods are not deprecated shadows of these; they are the
right call from a thread, a script or a test.
Why a worker thread rather than "native async"
The engine's work is filesystem I/O and CPU: reading files, merging
layers, deserializing. There is no socket to await and nothing to
overlap, so an async implementation would still block a thread — it
would just be less honest about which one. Handing the work to an
executor and awaiting the result is what "async file I/O" means in
CPython anyway; asyncio.to_thread is the same mechanism.
This is the same decision the Rust crate makes. There, load_async
sends the load to a blocking worker — a fresh thread by default, or
tokio's blocking pool with the tokio feature — precisely so that no
executor thread is parked on a read(). The Python binding inherits the
policy rather than reinventing it.
Which pool pays for the blocking half
By default the work goes to the event loop's own executor — the one
run_in_executor(None, ...) uses, shared with everything else in the
process that calls it. A service that would rather not queue behind an
unrelated batch job gives configuration its own:
from concurrent.futures import ThreadPoolExecutor
import dynamic_config
dynamic_config.set_executor(ThreadPoolExecutor(2, thread_name_prefix="config"))
# or for one configuration only
config = DynamicConfig(Database, key="db", executor=pool)
This is the Python-side twin of the Rust crate's
set_blocking_executor, and answers the same question. What it is not
is tokio: the Rust tokio feature exists so that a Rust program's async
loads land in tokio's blocking pool rather than on a fresh thread. Here
the awaiting side is Python's loop, which cannot await a tokio task, so
the wheel does not carry tokio — it would be a runtime nobody awaits, in
every wheel, for every user. The executor above is the knob that
actually changes where the work runs.
Waits stay on the default executor, whatever you configure. A wait is
a parking spot rather than work, and parking several of them in a pool
you sized for work is how that pool starves — three changes()
iterators against a two-worker executor would otherwise deadlock the
reload they are waiting for.
Waiting for a reload
Two shapes, because two things want to wait:
changed_async(timeout=…) — one await, one answer. For a task that
needs the next configuration and then moves on. Returns None when the
timeout elapses first.
changes() — an async iterator over every install from here on. For
the long-lived task that follows configuration for the life of the
service:
async def follow(config, pool):
async for db in config.changes():
if db.pool_size != pool.size:
await pool.resize(db.pool_size)
Both wait in bounded slices with the GIL released, so cancelling either is noticed within a quarter second rather than at the next reload — which may never come. Cancel the task and the engine is untouched; a reload afterwards behaves exactly as it would have.
Neither is tied to asyncio's implementation details, so uvloop drives them, and so does anything else that provides a running loop and an executor.
Loading several configurations at once
DynamicConfig is a value, so a service with a database file, a cache
file and a feature-flag file has three of them — and three loads that do
not need to queue:
await asyncio.gather(
database.init_async(), cache.init_async(), features.init_async()
)
Each keeps its own watcher, its own generation and its own followers, so
a flag flipping does not re-parse the database file or wake anything
watching it.
examples/13_asyncio_many_files.py
is the whole shape, executor included.
The same thing, on the model classes
The other shape is the decorator: the configuration lives on the model
class, so any module that can import Database can ask
Database.current() without being handed a configuration object first.
The async surface is reached through Model.config, and everything
above applies unchanged:
@dynamic_config(key="db", files=["database.toml"], env="APP_")
class Database(BaseModel):
host: str = "localhost"
pool_size: int = 8
@dynamic_config(key="flags", files=["flags.toml"], env="APP_")
class Flags(BaseModel):
new_checkout: bool = False
# Three files, one await; the loop is free while they are read.
await asyncio.gather(
Database.config.init_async(), Cache.config.init_async(), Flags.config.init_async()
)
watch = await Flags.config.watch_async(debounce=0.25)
async for flags in Flags.config.changes():
... # one follower per configuration
Model.current() stays synchronous everywhere — it is an attribute
lookup on a cached instance, so there is nothing to await, on the loop or
off it. The decorator does not load at import time (init=False is the
default), which is what makes decorating at module level safe: importing
a module should not begin filesystem work, and a loop that does not exist
yet cannot be the thing loading.
examples/14_async_decorator_services.py
runs three decorated services on one loop — concurrent loads, a watcher
and a follower each, and generations that prove one team's edit left the
other two configurations alone.
Reading inside a request
@app.get("/health")
async def health():
db = config.current() # once, at the top
await do_work(db.host)
return {"host": db.host} # the same value, whatever landed meanwhile
The same line works in a synchronous endpoint — FastAPI runs those on a worker thread — because the read is thread-safe and needs no loop: the model is immutable and the swap is atomic, so a reload landing mid-handler cannot tear the value in hand. See Web Frameworks for both styles side by side.
current() is an attribute lookup — no await, no boundary crossing, no
lock a writer can hold. Read it once per request and use that value for
the whole request: a reload landing halfway through would otherwise show
one request two configurations, which is the one bug hot reload
introduces if you let it.
Watching, from a loop
config.watch(...) starts a background thread, not a task, because the
filesystem notification backend is a thread-shaped thing on every
platform. It needs no loop and does not interact with yours: when a
reload lands, it validates on the watcher thread, publishes, and wakes
whatever is awaiting changes() on your loop.
watch = await config.watch_async(debounce=0.25)
watch.detach() # for the life of the process
watch_async, and not because the watcher needs it — it is a thread
either way. What the await moves off the loop is starting it:
resolving the directories to observe, registering each with the
notification backend, spawning the carrier thread. Natively that is a
fraction of a millisecond, growing with the number of directories.
poll_interval is the case that earns the twin: the poll backend takes
a baseline scan of everything it watches before it can report a change,
which measures single-digit milliseconds over a large directory and
worse over the network filesystems that are the reason to poll at all. A
startup handler runs once and would survive the sync call; a loop that
is answering requests should not be the thing waiting on readdir.
Stopping needs no twin. Watch.stop() drops the backend, which closes
the channel the watcher thread is parked on, and returns — it does not
join the thread and does not wait out a debounce window, so a reload
already in flight finishes on its own thread while stop() has long
returned. Call it directly from a shutdown handler.
Hooks registered with on_reload also run on the watcher thread. If a
hook needs to touch loop-owned state, hand the work over rather than
doing it there:
loop = asyncio.get_running_loop()
config.on_reload(lambda old, new: loop.call_soon_threadsafe(queue.put_nowait, new))
That is the same advice the Rust reload lifecycle gives: compare, then signal the thing that owns the resource.
Data Types
Whatever Pydantic can validate, this can load. The binding converts the
resolved tree into plain Python data — dicts, lists, strings, numbers,
booleans, None — and Pydantic does the rest, which means every
coercion, validator and custom type you already use keeps working.
What arrives, and as what
class Everything(BaseModel):
text: str
count: int
ratio: float
enabled: bool
nothing: Optional[str] = None
level: Level # a str or int Enum
mode: Literal["read", "write"]
started: datetime # from an ISO string
day: date
window: timedelta # from seconds, or a duration string
identifier: UUID
amount: Decimal
address: IPv4Address
where: Path
names: list[str]
ports: set[int]
pair: tuple[str, int]
labels: dict[str, str]
primary: Endpoint # a nested model
replicas: list[Endpoint]
by_region: dict[str, Endpoint]
either: Union[int, str]
secret: SecretStr
All of it is covered by the test suite against real TOML, JSON and
environment sources — including the coercions Pydantic performs from
strings, which is how a UUID or a datetime gets into a config file in
the first place.
The conversions that have to be exact
Three of them are silent when they go wrong, so they get their own tests:
An integer stays an integer. port = 5432 must not arrive as
5432.0. The conversion builds Python ints from the loader's integers
rather than routing everything through a float, and there is no JSON
string round trip anywhere in the path.
A boolean stays a boolean. In Python bool is a subclass of int,
so a careless conversion turns True into 1 — and back into True
often enough that nobody notices until a Literal[1] or a is True
somewhere disagrees.
Large integers keep their digits. A u64 above i64::MAX is a real
configuration value (a nanosecond timestamp, a snowflake id). It arrives
as a Python int, not a float that has quietly dropped the last three
digits.
Values you supply from Python
The runtime layers take Python objects and put them where a file's values go, so the conversion runs the other way:
config.set_default("pool_size", 8)
config.set_default("labels", {"team": "platform"})
config.set_defaults(Database(host="fallback")) # a whole model
config.set_override("host", "localhost") # outranks everything
Anything without a configuration meaning is refused at the call rather
than serialized into something surprising: a function, an open file, a
NaN, a dict with non-string keys. SecretStr and SecretBytes are
understood — their value is taken, because you are supplying it — as are
Pydantic models and anything else with model_dump.
Environment variables and types
The environment is strings, and figment's loose parsing turns them into
what the field wants: APP_DB_PORT=5432 reaches an int,
APP_DB_ENABLED=true a bool. Loose parsing is ergonomic and ambiguous
at the edges — off reads like a boolean and arrives as the string
"off" — so strict_env() refuses that family with an error naming the
variable. Nesting uses a doubled separator:
APP_DB_POOL__MAX_SIZE sets pool.max_size.
What the boundary will not do
bytesfields work through Pydantic's own coercion from a string; the loader has no binary literal, because no configuration format this crate reads has one.- Arbitrary Python objects cannot be a configuration value. If a model field is a type Pydantic can build from a string, a number or a mapping, it works; if it can only be built by running Python, the value belongs in code rather than in a config file.
What a schema may be
Five kinds of class can be a schema — and a sixth answer, which is no schema. The whole surface — sources, precedence, watching, recovery, diagnostics — is identical across all of them. What differs is what validation means, and what you have to install:
| Schema | Install | Validation |
|---|---|---|
dataclasses.dataclass | nothing | structural: required fields, unknown keys, nested dataclasses, and each value against its declared type |
pydantic.BaseModel | [pydantic] | Pydantic's, entire — coercion, constraints, validators, computed fields |
pydantic.dataclasses.dataclass | [pydantic] | the same, through the dataclass validator |
pydantic_settings.BaseSettings | [pydantic-settings] | Pydantic's, plus a sourcing declaration this engine can translate |
msgspec.Struct | [msgspec] | msgspec's, in C — types, Meta constraints, and unknown keys if the struct asks |
Values | nothing | none — a configuration with no schema |
The base install has no dependencies at all; each extra buys one more
kind of schema and nothing else. [all] is the Pydantic pair —
deliberately not msgspec, which is a different validation engine rather
than an addition to that one.
Values: a configuration with no schema
The Python spelling of the crate's schemaless configuration, for the keys a program learns at run time rather than declares — a plugin host, a feature-flag table, a tool reading a file it did not write. Pass the class; every load hands back an instance:
from dynamic_config import DynamicConfig, Values
config = DynamicConfig(Values, key="plugins").file("plugins.toml").env("APP_")
config.init()
values = config.current()
values["cache.ttl"] # by dotted path
values.get("cache.ttl", 60) # ...with a default
values["cache"]["ttl"] # or a step at a time
values.sub("cache") # or hand a subsystem its own subtree
dict(values) # a plain dict
sub is what a subsystem gets instead of the whole configuration: below
it the paths are relative — values.sub("db").get("pool.max_size") —
so a function that takes a Values does not have to know where in the
document it lives. Snapshot::sub is the Rust equivalent. A path that
holds nothing, or holds a value rather than a table, answers an empty
Values rather than raising: a subsystem handed a section its deployment
did not configure should reach its own defaults, and in is how to tell
the difference when it matters.
It is a Mapping, so len(), in, .keys(), .items() and iteration
work as they do on a dict, and every value is already a plain Python
object — str, int, float, bool, list, dict, None. There is
nothing to unwrap. Lookup takes a dotted path, which is the one place
it is not a dict: a key that itself contains a dot is not reachable by
name, the same trade the Rust Value::get makes.
Everything else is the engine you already have: the same layers and
precedence, profiles, discovery, the secrets directory, the watcher,
reload hooks, source_of, explain, snapshot and check.
What it gives up is exactly what it never declared. Two answers change, and both are reported rather than assumed:
| A declared model | Values | |
|---|---|---|
check() unknown keys | compared against the field names | nothing to compare — report.unknown_checked is False and the rendering says unknown keys: not checked (no field list) |
| secret paths | derived from the declaration (SecretStr, metadata={"secret": True}) | none, unless DynamicConfig(Values, key=…, secrets=["token"]) says so |
The second has teeth: a redacted or fingerprint cache
is refused for a configuration that never said what is secret, rather
than writing a file that claims a redaction it did not perform. Naming
the paths with secrets= buys the cache and the *** in explain
together.
examples/20_schemaless.py runs all of it.
A dataclass, and what it checks
from dataclasses import dataclass, field
@dataclass
class Database:
host: str = "localhost"
port: int = 5432
password: str = field(default="", metadata={"secret": True})
metadata={"secret": True} is the stdlib's own extension point, and the
natural place for a declaration Pydantic makes with a type — SecretStr
has no stdlib equivalent, metadata does. It drives the same redaction:
the cache drops it, explain renders it ***.
What the adapter checks:
- every required field present, and every key one the class declares;
- nested dataclasses built recursively rather than left as dicts;
- each value against its declared type, with
boolandintkept apart in both directions, andintwidening tofloatbut not back.
What it does not do is coerce, with three deliberate exceptions where
the type parses its own text: an Enum takes its member's value,
date/time/datetime go through fromisoformat, and a type that
builds from a single argument is built from it (UUID, Path,
Decimal, IPv4Address). Anything else that does not match its
annotation is a validation failure naming the field and the two type
names — never the value, because that message travels into diagnostics.
One limitation is worth knowing, and it is Python's rather than this
library's: annotations are resolved with typing.get_type_hints, which
looks in the module where the class was defined. A dataclass declared
inside a function names types that module cannot see, so its
annotations stay strings and there is nothing to check them against.
Declare configuration dataclasses at module level. (Pydantic meets the
same wall and answers it with model_rebuild().)
A msgspec Struct, and what it answers differently
import msgspec
from typing import Annotated
class Database(msgspec.Struct):
host: str
port: int = 5432
password: Annotated[str, msgspec.Meta(extra={"secret": True})] = ""
workers: Annotated[int, msgspec.Meta(ge=1, le=64)] = 4
The declaration reads like a dataclass and validates like Pydantic, and
it is the fastest of the five at exactly what a reload asks of a schema:
one resolved mapping, one instance, once. Decoding is lax
(strict=False), which is what configuration needs — every environment
variable is a string, and refusing APP_DB_PORT=7000 for being one is
not a mistake anybody made.
Three answers are msgspec's own rather than this library's:
| msgspec | Beside it | |
|---|---|---|
| a secret | Meta(extra={"secret": True}) | SecretStr, or field(metadata={"secret": True}) |
| unknown keys | ignored, unless the struct says forbid_unknown_fields=True | a Pydantic model's extra; a dataclass always refuses |
InvalidError.errors | empty — msgspec raises a message, not a report | Pydantic's own report, scrubbed of values |
Meta's extra mapping is msgspec's door for another library's flag, so
nothing is invented here: it drives the same redaction the other two
declarations do — the cache drops the path, explain renders it *** —
and DynamicConfig(Model, key=…, secrets=[…]) still adds to it.
The empty errors is a decision rather than an oversight. msgspec's
ValidationError carries a message and no structured report, and
parsing that message into a report-shaped object would be inventing
structure the library never promised. The attribute is present and
empty for every schema that has no report to give, so a program reading
it after except InvalidError does not have to know which schema library
the configuration was declared with.
One thing this binding does to msgspec's messages: it takes the value
back out. Two of them quote the data they refused — Invalid enum value '…' and, for a tagged union, Invalid value '…' — and the rule here is
that no value reaches a diagnostic, whichever library wrote the sentence.
The path survives, because a path is field names; what a Level field
was set to does not.
A field renamed by rename="camel" or msgspec.field(name=…) is known
here by the name a file writes, which is the name msgspec itself
decodes. A file spelling the Python name is reporting an unknown key
rather than setting the field, and check() says so.
examples/22_msgspec.py runs all of it.
What a Pydantic model may be
Everything, which is the reason to reach for one. All of these work, and each has a test:
| Shape | Notes |
|---|---|
| Inheritance, any depth | Fields accumulate; a subclass may narrow a parent's field |
Mixins beside BaseModel | An ordinary class in the bases is untouched |
model_config | extra (ignore/allow/forbid), frozen, populate_by_name, alias_generator, validate_assignment |
| Validators | field_validator (both modes), model_validator; a rejection is a refused reload, never a half-installed one |
| Computed fields | Available on the installed model, like any property |
| Private attributes | PrivateAttr survives a load; it is not configuration |
RootModel | As a field, and as the outer model |
| Pydantic dataclasses | As a field, secrets inside them included |
| Generic models | Box[int] directly, or a subclass that specialises it |
| Discriminated unions | The discriminator picks the member, as usual |
BaseSettings | See below — it is a model, plus a thing this replaces |
Aliases, in all four shapes
Pydantic accepts four alias declarations, and a field is reachable by all
of them at once: a plain string, an AliasPath into nested data, an
AliasChoices of either, and an alias_generator that writes them for
you. Each names what a file would carry, so each is what this binding
has to look for.
Two places depend on getting that right. The unknown-key report must not
call an accepted spelling unknown. And the redaction list — derived from
SecretStr/SecretBytes on the model — must hold every name the
field could arrive under, not one of them:
class Credentials(BaseModel):
password: SecretStr = Field(
default=SecretStr(""),
validation_alias=AliasChoices("password", "pass"),
)
A file writing pass is writing a secret. secret_paths(Credentials)
answers ["password", "pass"], and both are redacted in explain and
dropped from the redacted cache. Listing a name nothing supplies costs a
key that never appears; missing one puts a password in a diagnostic and
on disk.
pydantic-settings
BaseSettings is two things bolted together: a Pydantic model, and a set
of places to read it from. The first half works here unchanged — it is a
BaseModel. The second half is what this engine does instead, and it
does not run under model_validate, which is how this binding validates.
A class declaring env_prefix would therefore get none of it.
Silently, which is the part worth fixing. So:
from dynamic_config import DynamicConfig
config = DynamicConfig.from_settings(ServiceSettings, key="svc")
config.init()
from_settings reads the class's SettingsConfigDict and rebuilds the
declaration as engine sources:
| Declared | Becomes |
|---|---|
toml_file, json_file, yaml_file | file(...), in that order |
env_file | env_file(...) — the dotenv layer |
env_prefix | one bind_env per leaf field, so APP_PORT stays APP_PORT rather than becoming APP_<KEY>_PORT |
secrets_dir | secrets_dir(...) — a directory of single-value files |
env_nested_delimiter | the separator inside those names |
case_sensitive | whether they are upper-cased |
Precedence is this crate's, which agrees with pydantic-settings where the
two overlap: files lose to .env, which loses to the environment, which
loses to overrides. Bindings see .env files too, so a variable a
deployment writes into .env rather than exporting still reaches the
field it names.
secrets_dir translates too, onto the engine source of the same shape —
a directory where each file is one key.
What has no engine equivalent is refused at the call rather than
dropped: cli_parse_args, and an overridden
settings_customise_sources. Declare those on the configuration
instead — or keep the class for its schema and use DynamicConfig
directly, which is a fine thing to want:
config = DynamicConfig(ServiceSettings, key="svc").file("service.toml")
That path warns if the class declares sourcing, and carries on. The
warning is not disapproval; it is the difference between choosing to be
the source and believing an env_prefix is doing something.
One difference in the schema half surprises people: BaseSettings
defaults to extra="forbid" where BaseModel ignores what it does not
declare. A narrow settings class pointed at a section carrying more than
it declares fails validation rather than shrugging.
Patterns & Style
What using this well looks like from Python, and the mistakes that read fine. The Rust book's Patterns & Style covers the ones that are about the engine; these are the ones that are about Python.
One configuration per subsystem
db = DynamicConfig(Database, key="db").file("config.toml").env("APP_")
cache = DynamicConfig(Cache, key="cache").file("config.toml").env("APP_")
flags = DynamicConfig(Values, key="flags").file("config.toml")
Three objects over one file, and they share nothing: three schemas, three reloads, three failures that do not touch each other. A broken flags section leaves the database's document serving.
The flag table has no schema on purpose. Its keys are a product
decision, and a class that had to declare each one would be edited every
time somebody added a flag — which is what Values is for.
Read current() where you use it
@app.get("/")
def index():
db = config.current() # here, not at import time
return {"host": db.host}
current() is an attribute read on a cached instance — validation ran
once, at the reload. A module-level DB = config.current() is a value
that has stopped reloading, and it is the one mistake this library cannot
stop you making.
In FastAPI, the dependency is the configuration object:
def database() -> Database:
return config.current()
@app.get("/")
def index(db: Annotated[Database, Depends(database)]):
...
Let the schema be the schema you already have
Pydantic if the program uses Pydantic, a dataclass if it does not want a
dependency, msgspec if the shape is hot, Values if there is nothing to
declare. The engine does not care, and swapping one for another changes
no other line. What a schema may be.
Declare secrets where the field is, not in a list somewhere else:
SecretStr, field(metadata={"secret": True}), or
msgspec.Meta(extra={"secret": True}). One declaration drives the
redaction in the cache, in explain, and in a scrubbed validation error.
Hooks are for waking something, not for doing the work
config.on_reload(lambda previous, current: pool.resize(current.pool.max_size))
A hook runs inside the reload, on the thread that noticed the change —
often the watcher's, and in an asyncio program that is not the event
loop. Anything that awaits belongs in changes() instead:
async def follow():
async for db in config.changes():
await pool.resize(db.pool.max_size)
That is the asyncio shape: the iteration happens on the loop, and the reload was over before it started.
Testing without a filesystem
with config.overrides(rate_limit=1, mode="test"):
assert something_that_reads() == "test at 1/s"
Three doors, and none of them writes a file: overrides for a block,
load() for a candidate nobody installs, and defaults for the twelve
fields a test does not care about. The shipped pytest plugin gives you a
scratch directory and a clean environment as fixtures.
What to check in CI, and what at startup
| Question | Where |
|---|---|
| Does the committed file still parse and validate? | CI: DynamicConfig(...).check() |
| Does this deployment's configuration load? | startup — init(), and let it raise |
| Is the store reachable? | a health endpoint, not a startup gate |
@app.get("/healthz")
def healthz():
status = config.status()
code = 200 if status.consecutive_failures == 0 else 503
return JSONResponse(status.__dict__, status_code=code)
Type checking
The stubs are shipped, so mypy --strict sees your model through
current(). Two habits keep that true:
- Annotate the configuration object:
config: DynamicConfig[Database]when it is a module-level name, so the generic parameter does not get lost. try_current()when it may not be installed: it isDatabase | None, which is what the checker wants at a boundary wherecurrent()would raise.
Web Frameworks
One rule carries every integration: read current() once per request
and use that value for the whole request. A reload landing halfway
through would otherwise show one request two configurations — the single
bug hot reload introduces if you let it.
The other rule is what not to do: do not copy configuration into the framework's own settings object at startup. A copy never reloads, and that is exactly the thing this library exists to fix.
FastAPI
Configuration is a dependency, which is what makes it testable:
config = DynamicConfig(Database, key="db").file("config.toml").env("APP_")
config.init()
app = FastAPI()
def current_config() -> Database:
return config.current() # one attribute lookup, no I/O
@app.get("/async/health")
async def health_async(db: Database = Depends(current_config)) -> dict[str, object]:
# On the event loop: the read needs no await, so the handler never
# yields just to see its own configuration.
return {"host": db.host, "pool": db.pool_size}
@app.get("/sync/health")
def health_sync(db: Database = Depends(current_config)) -> dict[str, object]:
# On a worker thread, which FastAPI uses for plain `def` endpoints.
# The same read, and it is thread-safe: the model is immutable and
# the swap atomic, so a reload landing mid-handler cannot tear it.
return {"host": db.host, "pool": db.pool_size}
Both endpoint styles read configuration identically, which is the
point: there is no async variant of current() because there is nothing
to await. A dependency that does touch the sources — explain,
check — is real work and belongs off the loop:
@app.get("/explain")
async def explain(path: str) -> dict[str, str]:
return {"explanation": await asyncio.to_thread(lambda: str(config.explain(path)))}
Start the watcher in the app's lifespan:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
watch = await config.watch_async(debounce=0.25)
try:
yield
finally:
watch.stop()
app = FastAPI(lifespan=lifespan)
Two details, both deliberate.
watch_async rather than watch: the loop starting the app is the loop
that will answer its requests, and starting a watcher registers
directories with the notification backend — syscalls the calling thread
waits out. It is a fraction of a millisecond natively and single-digit
milliseconds when poll_interval makes it scan a large directory first.
This runs once, so the sync call would survive review; the async one
costs nothing to prefer. stop() needs no twin: it drops the backend
and returns without joining the thread or draining a debounce window.
One lifespan rather than a startup handler plus a shutdown handler:
start and stop are the same function, so the watcher cannot be started
without being stopped. That matters because a second watch() on one
configuration is AlreadyExists, deliberately — two watchers on one
file could only mislead — and an app gets instantiated more than once
(uvicorn --reload, a test suite building client after client). Paired
this way each run stops the last one's watcher before the next starts.
Overriding it in a test is FastAPI's own mechanism:
app.dependency_overrides[current_config] = lambda: Database(host="localhost")
Importing a module should not begin filesystem work, which is why none of
this happens at import. detach() is the alternative to holding the
handle when the watcher should simply live as long as the process — the
binding stops it at interpreter shutdown either way.
The runnable version is
examples/10_fastapi_service.py.
Flask
@app.get("/health")
def health():
db = config.current() # inside the view, not in app.config
return jsonify(host=db.host, pool=db.pool_size)
The habit worth breaking here is app.config.update(...) at startup:
values copied into app.config are frozen at the moment they were
copied. Reading through config.current() inside the view keeps reload
working and costs an attribute lookup — Flask does more than that
building the request object.
For a factory-style app, build the DynamicConfig next to the app and
close over it, or hang it on the app object (app.extensions["config"])
so blueprints can reach it.
Runnable:
examples/11_flask_service.py.
Django
Django's settings are read once at import and frozen — right for
INSTALLED_APPS, wrong for the handful of values an operator actually
turns during an incident. Split them:
# settings.py
from dynamic_config import DynamicConfig
RUNTIME = DynamicConfig(Runtime, key="runtime").file("/etc/app/runtime.toml")
RUNTIME.init()
# views.py
from django.conf import settings
def health(request):
runtime = settings.RUNTIME.current()
return JsonResponse({"pool": runtime.pool_size})
Django's static settings keep doing what Django needs; the reloadable
half is validated by Pydantic and can change without a restart. Start the
watcher from an AppConfig.ready() hook rather than from settings.py,
so it starts once per process and after the app registry is built.
Runnable:
examples/12_django_settings.py.
Workers and CLIs
The same rule with a different clock: read once per unit of work.
def handle(job):
settings = config.current() # once per job
...
For a long-running consumer, async for db in config.changes() is the
shape that reacts to a change rather than polling for one — see
Async & asyncio.
Gunicorn, uWSGI and other pre-forking servers
Each worker process gets its own copy of everything, including the watcher — which is fine and is what you want: every worker reloads independently from the same file. Two cautions:
- Start the watcher after the fork (a
post_forkhook, or a startup handler), never at import time in the master. A watcher thread does not survivefork()in the child, and the master's copy would be the only one left running. - With
--preload, the module-levelinit()runs once in the master and the loaded snapshot is inherited by the children, which is a good thing: one parse, N workers. The watcher still has to start per worker.
Telemetry in Python
Two questions, and they are adjacent rather than the same: did the document install, and did the store answer. A service that watches only the first cannot tell a store that went away from a configuration nobody has changed.
status = config.status()
if not status.is_healthy:
log.warning("config has failed %d reloads", status.consecutive_failures)
Everything here is a snapshot of a handful of atomic loads: nothing is
re-read, no source is touched, nothing can block. That is what makes it
cheap enough to take on every scrape — and it is the same contract the
Rust telemetry feature makes, through the same
engine.
ConfigStatus
What config.status() hands back.
| Field | Type | Means |
|---|---|---|
generation | int | Models installed since the process started; zero before the first |
stale_for | float | None | Seconds since the serving model was installed. None — not zero — before the first install, because zero reads as just now |
last_reason | str | None | Why it was installed: initial, manual, file_changed, remote, replaced |
last_failure | Failure | None | The most recent reload that installed nothing |
consecutive_failures | int | Reloads that installed nothing since one did. Zero is healthy |
is_healthy | bool | consecutive_failures == 0, decided by the engine rather than recomputed here |
stale_for is the number most alerts are written against: this
service's configuration has been stale for an hour is the page that
matters, and a failing reload leaves the previous model serving — so
staleness is what says so, not an outage.
RemoteStatus
What config.remote_status() hands back.
| Field | Type | Means |
|---|---|---|
fetches | int | Documents the store handed over, pulled or pushed |
stale_for | float | None | Seconds since the last one arrived |
last_fetch_duration | float | None | How long the last pulled fetch took. None again after a push: the previous pull's duration beside a push's timestamp would describe neither |
last_failure | Failure | None | The most recent fetch that returned nothing |
consecutive_failures | int | Fetches that returned nothing since one did |
reachable | bool | None | Three states. None before anything has been asked of the store at all — a source installed and never fetched is not down, and reporting it as down is how a scrape at startup pages somebody |
It touches no engine, so asking a configuration that has never loaded does not fix its sources.
Failure
| Field | Type | Means |
|---|---|---|
kind | str | The category: io, parse, missing, type, env, invalid, remote, auth, decrypt, backend — the names the exception classes carry |
path | str | The dotted key path, empty when the failure belongs to the load as a whole |
seconds_ago | float | How long before the status was taken it was recorded |
A failure is kept after a later success, because it is history; the
health is consecutive_failures on the status holding it.
Why there are no timestamps
The engine records when with a monotonic clock, deliberately: those
numbers are read as how long ago, and a wall clock stepping backwards
under NTP would make a freshly loaded configuration look stale. A
monotonic instant has no epoch to convert from — not Unix's, and not
time.monotonic()'s, which is a different clock read from a different
origin even inside one process.
So what crosses is elapsed seconds, measured when the status was taken.
There is deliberately no loaded_at and no datetime: building one by
subtracting from time.time() would claim a precision the engine
refused to claim, and would be wrong in exactly the case the monotonic
clock was chosen for. A service that needs a wall-clock timestamp takes
one itself in an on_reload hook — that is its clock, and its decision.
Exposition: Prometheus text
from dynamic_config import Exposition
@app.get("/metrics")
def metrics() -> Response:
body = Exposition().add("db", config).add_remote("db", config).render()
return Response(body, media_type="text/plain; version=0.0.4")
Built per scrape and thrown away: every sample comes from a status, so
there is nothing worth keeping between scrapes and nothing to go stale.
This package chooses no metrics ecosystem for the service importing it —
exactly as the Rust crate does not — so what it hands back is a string
for whatever /metrics route you already have.
| Method | Effect |
|---|---|
add(name, config) | The configuration's own series, labelled config="{name}" |
add_with(labels, config) | The same, with a label mapping of your choosing — an application and a profile, say |
add_remote(name, config) | The configuration's remote store, usually under the same name so the two halves join in a query |
add_remote_with(labels, config) | The same, with your labels |
render() | The body. Durations are measured here, so the seconds are as fresh as the response |
Every method except render() returns the exposition, so the calls
chain.
What it emits
Per configuration added:
| Name | Type | Extra label |
|---|---|---|
dynamic_config_installs_total | counter | |
dynamic_config_last_success_seconds | gauge | |
dynamic_config_consecutive_failures | gauge | |
dynamic_config_last_failure_seconds | gauge | |
dynamic_config_last_reload_info | gauge | reason |
dynamic_config_last_failure_info | gauge | kind |
Per remote source added:
| Name | Type | Extra label |
|---|---|---|
dynamic_config_remote_up | gauge | |
dynamic_config_remote_fetches_total | counter | |
dynamic_config_remote_last_fetch_seconds | gauge | |
dynamic_config_remote_last_fetch_duration_seconds | gauge | |
dynamic_config_remote_consecutive_failures | gauge | |
dynamic_config_remote_last_failure_info | gauge | kind |
These names are API. They end up in dashboards and alert rules, so a rename is a breaking change.
A fact that does not exist yet is absent rather than zero: no
last_success_seconds before the first install, no remote_up before
the store has been asked anything. An absent series is a gap in a graph;
a zero is a claim.
What never becomes a label
No configured value, no key path, no store address. The only string a
source can produce for itself is describe(), which is a URL and
routinely carries user:password@host — so the name a series carries is
yours, passed to add, and there is no overload that takes the
store's own. A Failure's dotted path stops at the status object: the
exposition renders the failure's category and never its path, which is
unbounded label cardinality as well as a detail nobody asked to publish.
Cardinality is bounded and the bound is the caller's: six series per
configuration per scrape, six more per remote source, with label sets
from fixed enums — five reload reasons, ten error kinds. Label names are
sanitised to Prometheus's [a-zA-Z_][a-zA-Z0-9_]* and values escaped, so
no caller can break out of the exposition; the cardinality of what you
pass stays your decision.
Health endpoints
The two statuses are what a readiness probe is made of, and they say different things:
@app.get("/readyz")
def readyz() -> Response:
status = config.status()
if config.try_current() is None:
return Response("no configuration", status_code=503)
if not status.is_healthy:
return Response("configuration stale", status_code=503)
return Response("ok")
Serving something and the last attempt worked are separate
conditions, and a service that conflates them either refuses traffic it
could serve or accepts traffic on a configuration nobody has been able
to reload for an hour. try_current() answers the first without raising;
is_healthy and stale_for answer the second.
Remote Stores in Python
The eight store crates — etcd, Consul, Vault, NATS, Redis, S3, Firestore and git — stay in Rust, and they stay out of the ordinary wheel: their clients are a gRPC stack, the AWS SDK, a git implementation and three HTTP clients between them, and every one of those would ride into every wheel for every user, including the ones reading a single TOML file.
All eight now ship compiled, in an opt-in second wheel:
pip install dynamic-config-py[remote]. See Remote Stores in Rust. This page is the other half, and it needs no extra: a store written in Python is in the base wheel, and is the answer for every store that has no Rust client at all — a company's own service, a sidecar, an API nobody will write a client for — and for the capabilities the wheels do not expose, such as a custom proxy or awatch()that pushes. TLS is no longer one of those: a private certificate authority and a client certificate areTlsConfig, which every store in the second wheel takes.
What ships in the base wheel is the door. Any object with fetch() and
describe() is a remote store here, so a company's own service, a file a
sidecar writes, or an API nobody will ever write a Rust client for needs
no Rust at all:
import httpx
from dynamic_config import DynamicConfig, Format, RemoteSource
class ConfigService(RemoteSource):
def fetch(self):
response = httpx.get("https://config.internal/v1/db", timeout=5)
response.raise_for_status()
return response.text, Format.JSON
def describe(self):
return "the config service"
config = DynamicConfig(Database, key="db").remote(ConfigService())
config.refresh_remote()
config.init()
Remote stores in Rust or in Python, the same engine either way — which is the point of the item: the choice is the user's, and neither answer is a second-class one.
Fetching is explicit
refresh_remote() → fetch, keep the document
init() / reload() → merge the kept document, no I/O
The same split the Rust crate makes, for the same reason: configuration
is read on nearly every request, and a network round trip there would be
indefensible. A fetch() happens when you ask for one, never on a load.
config.watch(...) watches files. A store that pushes — a long poll, a
subscription — is a loop you write, calling refresh_remote() and
reload() when it hears something.
Where the document lands
defaults < files < remote < environment < flags < overrides
Above the files, because centrally distributed configuration should beat what a package shipped; below the environment, because a machine's own settings should beat what a central store thinks it wants.
source_of("port") answers Origin(kind='remote', detail=...), and the
detail is what describe() said.
The GIL, measured
The concern that made this feature wait: a Python object on the fetch path means the interpreter is in the fetch, and if the GIL were held for the length of an HTTP request then every other thread in the process would stop for it.
Measured, that is not what happens. A fetch() doing I/O releases the
GIL itself — socket.recv, time.sleep and every stdlib blocking call
do — so the rest of the process keeps running. Against a second thread
counting in a loop, with a fetch() that sleeps 200 ms:
| the fetch is | the other thread runs at |
|---|---|
I/O-bound (time.sleep, a socket) | 68–102% of its free-running rate |
| CPU-bound (a busy loop) | 37–43% — i.e. it shares, like any two Python threads |
Neither is a stopped thread, which is what the worry was about, and the
CPU-bound row is the ordinary arithmetic of two Python threads under a
GIL rather than anything this binding does. That measurement is
tests/test_remote.py::test_a_python_fetch_does_not_stop_other_threads,
and it is why there is no worker thread behind this API: the indirection
a worker would buy is not needed, and it would have cost a deadlock (see
below).
Everything around the Python call releases the GIL properly:
refresh_remote() detaches for the whole refresh, and the shim re-takes
it only to call fetch().
The timeout is yours
Nothing on the Rust side can interrupt Python that has decided not to
return. A worker thread and a channel recv with a deadline would let
refresh_remote() give up, but the abandoned thread would still be
running the fetch, so it buys an error message rather than a cure.
So the contract is the one Python already has: the deadline belongs to
the client fetch() calls.
def fetch(self):
return httpx.get(URL, timeout=5).text, Format.JSON
# ^^^^^^^^^ this is the timeout
A store with no timeout is a refresh_remote() with no timeout. Ctrl-C
still works: a KeyboardInterrupt raised out of a fetch propagates
unchanged rather than being reinterpreted as a store failure.
When a fetch fails
Whatever fetch() raises arrives as RemoteError — or AuthError, if
that is what was raised, because this credential was refused is worth
telling apart from the store is unreachable: waiting fixes one and not
the other.
try:
config.refresh_remote()
except AuthError:
stop() # waiting will not help
except RemoteError as failure:
log.warning("config store unreachable: %s", failure)
log.debug("the store said", exc_info=failure.__cause__)
Two properties are worth stating plainly, because both are enforced by tests:
The exception's message is not repeated. A store's exception
routinely carries the URL it called, and a URL routinely carries a token
— so the error says which store, and what type was raised, and stops
there. The exception itself is attached as __cause__, which is where a
traceback and logging.exception already look. Values stay out of
diagnostics here as everywhere else.
Nothing is poisoned. A failed fetch installs nothing: the document from the last good fetch is still there, the installed model still serves, the last-known-good cache is untouched, and the next refresh works. A store having a bad afternoon is not a configuration failure.
Reading the configuration from inside a fetch
Allowed, and tested. No lock is held across the fetch, so a fetch() may
call current(), try_current(), snapshot(), explain() or
reload() on the configuration it is fetching for. That is what makes a
source like this one work:
class Incremental(RemoteSource):
def __init__(self, config):
self.config = config # the cycle every real source closes
def fetch(self):
since = self.config.current().revision
return httpx.get(f"{URL}?since={since}").text, Format.JSON
The cycle that closes — the configuration holds the source, the source
holds the configuration — collects like any other: the source is an edge
the engine object reports from tp_traverse and drops from tp_clear.
The one thing a fetch() may not do is call refresh_remote(). That
is the refresh it is answering, and it is refused by name:
BackendError: refresh_remote() was called from inside a remote source's
own fetch(); a fetch must not drive the refresh it is answering.
The refusal is per-thread and does not stick.
describe() is asked once
When the source is installed, not per fetch and not per load. The engine
reads describe() on the load path — it is where a remote value's
provenance comes from — and a load runs with the GIL released, on a
watcher thread as often as on a caller's. Asking Python there would put a
re-entry, and a possible Python exception, on every load of every
configuration that has a store.
So describe() should be cheap and constant, and it should name the
store rather than the credential that reaches it: it is rendered in
source_of, in explain, and in every remote error.
Swapping and clearing
remote(source) may be called again at any time, including after the
first load — unlike file(...) and the other source methods, and exactly
as the Rust set_remote may. Installing a new source drops whatever
the previous one had fetched, because a new store answering with the
old one's values would be a puzzle nobody needs.
clear_remote() drops the document and keeps the source.
Async
refresh_remote_async() runs the fetch on a worker thread, so a
fetch() written with a blocking client — which is most of them — does
not stall the event loop. Note what it does not do: it does not make
fetch() itself awaitable. A source that wants an async client runs its
own loop inside fetch(), or fetches on a thread of its own and hands
this one what it has.
The complete example
examples/18_python_remote_source.py
runs all of the above end to end, GIL measurement included, and needs no
server.
Remote Stores in Rust, from Python
pip install dynamic-config-py[remote]
from dynamic_config import DynamicConfig
from dynamic_config.remote import Etcd
config = DynamicConfig(Database, key="db").remote(
Etcd(["http://etcd.internal:2379"], "myapp/db.json")
)
config.refresh_remote()
config.init()
The other half of Remote Stores in Python. That page
is the door a Python fetch() goes through; this one is the Rust store
clients, compiled, behind an opt-in install.
All eight stores are here — Consul, etcd, Firestore, git, NATS, Redis, S3 and Vault — each mirroring its Rust crate's builder.
| Store | Client | Runtime | The credential |
|---|---|---|---|
Consul | ureq, blocking | none | An ACL token, or a bearer exchanged for one |
Etcd | gRPC, async | tokio | A user and password, in the connection |
Firestore | ureq, blocking | none | An access token, or the metadata server |
Git | gix, blocking | none | An https token, or an ssh key |
Nats | async-nats | tokio | A token, NKey, user pair or .creds, on connect |
Redis | redis, blocking | none | A user and password, in the URL |
S3 | AWS SDK, async | tokio | A ProvideCredentials chain — see below |
Vault | ureq, blocking | none | A login that buys a token with a TTL |
Why it is a second wheel
A wheel is built per platform, so a dependency in the ordinary wheel is
in every install of it — including the ones reading a single TOML file.
etcd speaks gRPC, the AWS SDK brings its own runtime and signing stack,
NATS brings a protocol, git brings gix and three more bring HTTP over
rustls: eight clients is most of an async ecosystem, in every install
that wanted a config.toml.
An extra alone cannot do it. pip install dynamic-config-py[etcd]
installs Python distributions; it cannot turn on a Cargo feature in a
binary that was compiled weeks ago on a release runner. So the extra
resolves to a second wheel, built from a second crate with the store
clients in it.
| base wheel | remote wheel | |
|---|---|---|
| Distribution | dynamic-config-py | dynamic-config-py-remote |
| Import | dynamic_config | dynamic_config.remote |
| Size (Linux x86-64, release) | 1.36 MB | 11.68 MB |
| Extension module | dynamic_config._core | dynamic_config_remote._core |
| tokio | never | one runtime, lazily |
| MSRV | 1.85 | 1.88 — aws-sdk-sts, async-nats and redis each ask for it. gix asks for 1.85 and so moves nothing |
One wheel rather than one per store, and the size table is why. Eight
build matrices is eight times the release runner for a saving nobody has
asked for, and 11.68 MB is a wheel: numpy is 18 MB and cryptography
is 4 MB. The measurement that decided it: etcd and Vault alone were
2.39 MB, so the other five — the AWS SDK, async-nats, redis and two
HTTP clients — cost about 6 MB between them, and no single one of them
dominates enough to be worth splitting out on its own.
git added 2.99 MB of that, measured the same way: 8.69 MB before and
11.68 MB after. It is gix and the reqwest client the store crate builds
for a private authority, plus the engine's three format features — which
this wheel did not need until git, because git is the one store here that
folds several files into one document and a fold is a parse and a
re-render. It is the second largest single contributor after the AWS SDK
and still nowhere near being worth its own build matrix.
The number to revisit at is around 50 MB, where a wheel starts to be
something people notice in a container build. If it is ever crossed, the
split to make is S3 alone: the AWS SDK is the largest single
contributor and the only one that brings its own credential machinery, so
dynamic-config-py[s3] would be a clean seam and the other seven would
stay one wheel.
The import name
dynamic_config.remote is a module in the base wheel that re-exports
dynamic_config_remote, which is what the second wheel installs. Both
names work; the dotted one is the one to write.
The obvious alternative was a namespace package — the base wheel shipping
dynamic_config/* and the remote wheel dynamic_config/remote/*, one
import tree. It was built and measured before being rejected, and it
fails in two ways that matter:
- An editable base install cannot see it.
maturin developinstalls a.pthpointing at the source tree, sodynamic_config.__path__is that one directory and adynamic_config/remote/in site-packages is invisible. That is how this package is developed and tested, so the remote wheel could never have been tested against a developed base. - Uninstalling the base leaves an orphan. With
dynamic_config/__init__.pygone anddynamic_config/remote/still there,import dynamic_configstill succeeds — as a PEP 420 namespace package with no API at all.
Two distributions that never share a directory have neither problem, at
the cost of one re-exporting module. Without the second wheel, importing
that module raises an ImportError naming the extra rather than a
ModuleNotFoundError naming a distribution nobody has heard of.
Credentials may be callables
Every credential argument accepts a str or a callable returning
one. This is the feature rather than a convenience: a configuration
watcher outlives its credentials. A Vault token has a TTL, an AppRole
secret id is rewritten by a sidecar, a workload-identity JWT is refreshed
by a daemon on the node, an etcd password is rotated by whoever rotates
passwords. A store holding the string it was constructed with is a 403
three hours later that nobody can fix without a restart.
from pathlib import Path
from dynamic_config.remote import (
S3, Auth, Consul, ConsulAuth, Etcd, Firestore, FirestoreAuth, Git,
GitAuth, Nats, NatsAuth, Redis, Vault,
)
Etcd(
["http://etcd.internal:2379"], "myapp/db.json",
user="myapp",
password=lambda: os.environ["ETCD_PASSWORD"],
)
Vault(
"https://vault.internal:8200", "secret", "myapp/db",
auth=Auth.token(lambda: Path("/var/run/vault/token").read_text()),
)
Vault(
"https://vault.internal:8200", "secret", "myapp/db",
auth=Auth.app_role(role_id, secret_id=lambda: read_the_secret_id()),
)
Consul(
"http://consul.internal:8500", "myapp/db.json",
auth=ConsulAuth.token(lambda: os.environ["CONSUL_HTTP_TOKEN"]),
)
Redis(
"redis://redis.internal:6379", "myapp/db.json",
password=lambda: Path("/var/run/redis/password").read_text(),
)
Nats(
["nats://nats.internal:4222"], "config", "db.json",
# Reads the file on every fetch, which is what an operator replacing
# a `.creds` under a running process needs.
auth=NatsAuth.credentials_file("/etc/myapp/nats.creds"),
)
Firestore(
"my-project", "config/db",
auth=FirestoreAuth.access_token(lambda: mint_a_token()),
)
S3(
"myapp-config", "prod/db.json",
access_key_id=lambda: current_key_id(),
secret_access_key=lambda: current_secret(),
)
Git(
"https://github.com/acme/config.git", "services/api/db.yaml",
# A GitHub App installation token lives one hour, and this is the
# store where that is the ordinary case rather than the exotic one.
auth=GitAuth.token(lambda: mint_an_installation_token()),
)
The callable is invoked on every fetch, as ordinary Python, on the thread that asked for the refresh — before the GIL is released for the network read. It may block, and it may raise, in which case the refresh fails the way any other fetch failure does: the previous document and the previous model both keep serving.
When what it returns has changed, the store rebuilds its client with the new credential. When it has not — overwhelmingly the common case — nothing is rebuilt and the store's own token cache is untouched. That second half matters as much as the first: rebuilding per fetch would turn one Vault login into one per refresh, which is a poor thing to do to a secrets store, and one Redis connection into one per refresh, which is a poor thing to do to anything.
S3 is the exception and the better end of the same bargain: its
credential is not inside the client at all, so a rotation rebuilds
nothing. Why is a section of
its own.
Two layers of renewal are at work and they do not fight. The Rust crate
already refreshes the token it was issued, within a minute of expiry
and reactively after a 403. What a callable adds is the layer above — the
login credential itself rotating, which the Rust crate cannot notice
because it was handed a String.
Which layer a callable belongs to differs per store, and the ones where it is not needed are worth naming:
| Store | Where a callable earns its place |
|---|---|
Vault | The login credential — a secret id, a password, a JWT. The Vault token is the crate's to renew |
Consul | The bearer presented to an auth method. The ACL token is the crate's |
Firestore | FirestoreAuth.access_token, which cannot renew. metadata_server renews itself and needs none |
Etcd, Redis, Nats | The credential itself: it lives in the connection, so a new value is a reconnection |
S3 | The key pair — but nothing is rebuilt, because the SDK asks per request |
Git | The https token — and nothing is rebuilt either, for a different reason: rebuilding would throw away the object database |
Three of them read a file instead, and deliberately:
ConsulAuth.kubernetes(...) and Auth.kubernetes(...) carry a path,
which the Rust crate re-reads at every login, so a projected
service-account token the kubelet rotates already works with no callable
at all — the path is SERVICE_ACCOUNT_TOKEN, exported so a deployment
that mounts it somewhere else can say where without spelling
/var/run/secrets/... from memory; GitAuth.ssh_key(...) is the same, one layer further out, because
ssh opens the key file itself at every fetch.
NatsAuth.credentials_file(...) is the idea one layer up: it is a
callable, reading the file on every fetch.
Git is the second store whose rotation rebuilds nothing, and the
reason is worth stating because it is not S3's. A GitSource owns a
working directory — a bare object database, filled by the first fetch —
so rebuilding one would discard every object it holds and re-transfer the
repository's whole tree, for a store whose headline property is that an
unchanged ref transfers nothing. A named cache_dir is worse: it is
claimed by the source that holds it, so a rebuild would be refused by the
source it was replacing. The credential is therefore a slot the fetch
path writes and the source reads, exactly as it is for S3, and a rotation
costs a mutex write.
That has one visible edge. A closure credential is replaceable as far as the store crate is concerned, so a host that refuses one costs an extra attempt: the source invalidates what it holds and tries once more, and the slot answers with the same value because Python resolved it for this fetch already. One wasted round trip on a refusal, in exchange for a rotation that costs no transfer.
Credentials never appear in a diagnostic
Not in an exception message, not in a repr, not in describe() — which
is what Origin records and what every remote error carries.
>>> Etcd(["http://app:hunter2@etcd.internal:2379"], "myapp/db.json")
<etcd http://app:***@etcd.internal:2379 key myapp/db.json>
>>> Auth.app_role("role-id", "hunter2-secret-id")
Auth.app_role('role-id', '***')
Two rules, and they are belt and braces:
-
A URL loses its password, by the rule the Rust store crates use rather than a second copy of it: split on the last
@, because a password may itself contain one. The user name survives, because redaction that hides the half worth seeing is redaction nobody can debug through.The one thing the stores disagree about is what an authority with no colon in it means, and the shared rule takes it as an argument rather than being forked:
nats://token@hostis a secret, andredis://user@hostis a user name.>>> Nats(["nats://hunter2@nats.internal:4222"], "config", "db.json") <nats nats://***@nats.internal:4222 bucket config key db.json> >>> Redis("redis://app@redis.internal:6379", "myapp/db.json") <redis redis://app:***@redis.internal:6379 key myapp/db.json> -
Every credential resolved for a call is scrubbed by value from anything that call reports. The URL rule only reaches a credential that is in a URL; this one reaches a client library that decided to be helpful in a version nobody here has read.
The non-secret half of an auth method stays printable, and the split is
the one the Rust crate's Debug makes: an AppRole role id and a userpass
user name are shown, the secret id and the password are not.
TLS: a private authority and a client certificate
A deployment behind an internal certificate authority — an enterprise CA, a TLS-inspecting proxy, a MinIO with its own certificate, a GitLab on the company's own root — needs to trust one more certificate than the platform does. A hardened one needs to present one as well. Every store here takes both, spelled the same way, through one type:
from dynamic_config.remote import Auth, TlsConfig, Vault
vault = Vault(
"https://vault.internal:8200", "secret", "myapp/db",
auth=Auth.kubernetes("myapp"),
tls=TlsConfig()
.with_ca_certificate_file("/etc/ssl/private-ca.pem")
.with_client_certificate_files("/etc/ssl/app.crt", "/etc/ssl/app.key"),
)
This is the one client-configuration surface that crosses into Python at
all, and that is a property of how it was built rather than of how hard
anyone tried. TlsConfig is dynamic_config_store_core::tls::TlsConfig,
which holds paths and PEM bytes and nothing else — no tonic
configuration, no ureq::Agent, no SdkConfig anywhere in it. A surface
made of data has a Python spelling; a surface made of a client's own types
does not, which is why everything else on that list
is still on it.
Four settings, in two spellings each:
TlsConfig() | The platform's own trust store, no client certificate |
.with_ca_certificate_file(path) | Trust the authority in this PEM file |
.with_ca_certificate_pem(bytes) | Trust the authority in these PEM bytes |
.with_client_certificate_files(certificate, key) | Present this certificate and key (mTLS) |
.with_client_certificate_pem(certificate, key) | The same, from bytes |
.is_empty() | Whether this asks for anything at all |
Both spellings exist because both deployments do. A file is what a
Kubernetes secret mount or an /etc/ssl layout produces; bytes are what a
program that already fetched its material from a secrets manager has, and
writing those to a temporary file so a client could read them back would
put a private key on a disk that never asked for one.
Three smaller things hold everywhere. An empty TlsConfig is not "no
TLS" — it is the platform's trust store, so tls=None and
tls=TlsConfig() are the same store. A named authority replaces the
platform trust store rather than joining it, because that is what pinning
means; a deployment needing both puts both in the one file — with one
exception, Git, where the authority is added to the platform's, so
that one source configuration reaches both a private GitLab and
github.com. And nothing is read at construction: the files are opened when the store builds its
client, so a missing certificate is an error naming the path at the first
refresh rather than an exception in the middle of building a
configuration.
Pem, ClientCertificate and CertificateAndKey are not bound. They
exist in Rust so a store can ask which spelling was this; a Python caller
never asks — they say what they have — so binding them would add three
names to the surface and three more places a private key could be
rendered.
What each store accepts, and what two of them refuse
| Store | CA from a file | CA from bytes | Client certificate |
|---|---|---|---|
Consul, Etcd, Firestore, Redis, Vault | yes | yes | yes |
Git | yes | yes | yes — on an https:// url, and refused on any other |
Nats | yes | no — async-nats opens the file itself | file paths only |
S3 | yes | yes | no — the SDK's TLS context has no slot for one |
The refusals are refusals, not omissions. A binding that quietly
ignored either would leave a program believing it had pinned a private
authority when it had not, which is worse than a program that will not
start — so both raise a ValueError at construction, in the store crates'
own wording, naming the call and the way out:
>>> Nats(["tls://nats.internal:4222"], "config", "db.json",
... tls=TlsConfig().with_ca_certificate_pem(ca_bytes))
ValueError: nats tls://nats.internal:4222 bucket config key db.json: a
certificate authority from PEM bytes cannot be expressed here, and is
refused rather than ignored; `async-nats` opens the file itself; name a
file with `with_ca_certificate_file`
>>> S3("myapp-config", "prod/db.json",
... tls=TlsConfig().with_client_certificate_files("app.crt", "app.key"))
ValueError: s3 myapp-config/prod/db.json: a client certificate cannot be
expressed here, and is refused rather than ignored; the AWS SDK's TLS
context has a trust store and no client-certificate slot; mTLS to an
S3-compatible server means building the connector, which only the Rust
crate can do
Git's is the same rule pointed at a different mistake. It expresses all
four settings, and only over https://: an ssh:// remote authenticates
its host through known_hosts and its client through a key, so a
certificate authority has nothing to do with it and is refused rather
than half-applied.
>>> Git("ssh://git@github.com/acme/config.git", "db.yaml",
... auth=GitAuth.ssh_agent(),
... tls=TlsConfig().with_ca_certificate_file("/etc/ssl/private-ca.pem"))
ValueError: git: `tls` configures the https transport and this url is not
an https one, so it cannot be applied here and is refused rather than
ignored; an ssh remote authenticates its host through `known_hosts` and
its client through a key — GitAuth.ssh_agent(), GitAuth.ssh_key(path) or
GitAuth.ssh_command(command)
Its refusals are the one place this wheel writes its own wording rather than borrowing the store crate's, and for a reason: a git remote url routinely carries a token, the redaction that removes one lives in Rust, and a message assembled in Python has no way to reach it. So these messages name the argument and never the url. The store crate refuses the same configuration again when it builds, which is the belt to this braces.
At construction rather than at the first fetch, and before the tokio runtime is started: a configuration a store cannot express should be an error at the line it was written on, not two worker threads and a failure one refresh later. The Rust crates refuse the same thing again when they build their client, which is the belt to this braces.
Two stores have one more thing worth knowing. Nats turns TLS on when
an authority is named — the store crate sets require_tls, so a nats://
URL that would have negotiated plaintext fails rather than quietly
connecting without the authority just named. And Redis needs a
rediss:// URL: TLS material on a redis:// one is refused too, but by
the client as it is built, so that arrives as a RemoteError at the first
fetch. The URL is parsed where it is used rather than twice by two
implementations that could disagree.
One consequence of how the wheel is compiled: it enables the tls feature
of dynamic-config-etcd and dynamic-config-redis, which is what makes
Etcd and Redis able to speak TLS at all — an extra cannot turn on a
Cargo feature, so the choice is made once, here, and costs about 0.2 MB in
the wheel every install carries. It does not enable
etcd's tls-roots, which would resolve to a tonic call the store crate
never makes and add a native-certificate crate to every wheel for no
change in behaviour. So etcd over TLS from Python trusts the authority you
name, and a client certificate for etcd goes with one.
The private key never appears
It is the sharpest secret this package handles, and repr is where a
repr usually leaks one. TlsConfig.__repr__ delegates to the Rust
type's own Debug rather than rendering the arguments again:
>>> TlsConfig().with_client_certificate_files("/etc/ssl/app.crt", "/etc/ssl/app.key")
TlsConfig { ca_certificate: None, client_certificate: ClientCertificate {
certificate: file /etc/ssl/app.crt, key: "file /etc/ssl/app.key" } }
>>> TlsConfig().with_client_certificate_pem(certificate, private_key)
TlsConfig { ca_certificate: None, client_certificate: ClientCertificate {
certificate: <pem bytes>, key: "<redacted>" } }
A path is printed, because it names which key and is the question
somebody debugging this is actually asking; bytes are withheld, because
they are the key. One implementation of that rule, in the crate that owns
the type, with a planted-key test over it — a second copy here would be the
one nobody thinks to check. The remote wheel's suite plants a private key
of its own and greps every diagnostic this surface can produce: each
store's repr and describe(), what the engine records as provenance, and
the text of both refusals.
There is no way to turn verification off, and the
Rust chapter
argues that at length. The short version: it could not be uniform, it
answers nothing with_ca_certificate_file does not, and every client
underneath still has its own dangerous switch under its own frightening
name for the case nobody anticipated.
The tokio runtime
One, lazily, owned by the module.
Three of the eight are async — etcd speaks gRPC, NATS has its own
protocol, and the AWS SDK is async throughout — so something has to drive
their futures. The other five are ureq, a plain socket or a git fetch:
blocking, no executor anywhere in them, and no runtime at all.
>>> from dynamic_config.remote import Auth, Etcd, Vault, runtime_started
>>> runtime_started() # import starts nothing
False
>>> Vault("https://vault:8200", "secret", "a/b", auth=Auth.token("t"))
>>> runtime_started() # nor does a blocking store
False
>>> Etcd(["http://etcd:2379"], "myapp/db.json")
>>> runtime_started() # this one needs it
True
It starts at construction rather than at the first fetch, because construction is the moment a user can observe; a reactor appearing on a later network call is a thread count nobody can explain. It has two worker threads — the work is one request per configuration refresh, and sizing it to the machine would put sixty-four parked threads in a container that reads one key.
It is multi-threaded rather than current-thread so that two Python
threads refreshing two stores do not serialise behind each other, and it
is never shut down. Runtime::drop blocks until its workers park;
registering that at atexit would put a join between the interpreter and
its own exit, behind whatever a worker happens to be doing — which for a
store is a network read with a ten-second deadline on it. The base wheel
already registers an atexit sweep, and two that can each block would be
ordered by registration accident.
An immortal runtime is dangerous in a binding whose tasks call Python, and it is safe here because no task on this runtime ever touches Python: credentials are resolved in Python before the call, and the futures driven here hold nothing but Rust. A worker still running while CPython finalises cannot re-enter a dying interpreter.
That invariant is what shapes S3's credential provider, which is the one piece of this wheel the SDK calls back into: it reads a value the fetch path already resolved, and never calls Python.
If the calling thread is already inside a tokio runtime — a Rust program
embedding CPython, calling in from a task — block_on would panic with
Cannot start a runtime from within a runtime. That case is detected with
Handle::try_current() and the future is handed to this wheel's own
workers instead: a different runtime, so no reentrancy and no deadlock.
set_executor is unchanged and still means what it meant: which Python
pool pays for the blocking half. refresh_remote_async() runs the whole
fetch on that pool, and the tokio runtime is what the fetch uses once it
gets there.
S3: the credential that is a trait
Seven of the eight stores take their credential as a string, so a callable
resolves to a string and the string is handed over. The AWS SDK does not.
Its credential surface is a ProvideCredentials implementation, asked
per request, from inside the SDK's own async machinery — and that shape is
the reason S3 was the first store here with a design to make rather than a
builder to mirror. Git was the second, and it reuses this one: the slot
below is the shape its credential takes too, for a different reason
(above).
Passing no credentials is a first-class mode, not a fallback. With
access_key_id and secret_access_key absent, the SDK's own chain runs
untouched: environment variables, the shared profile, the EC2 instance
role, the ECS task role, IRSA on EKS. On anything running inside AWS that
is the right answer, and a second credential chain in a program that
already has one is a bug waiting for a rotation.
When they are passed, the callable becomes a shim. A provider is installed in the chain's place which answers from a slot the fetch path writes:
S3.fetch()
→ resolves access_key_id / secret_access_key Python, on the caller's thread
→ writes them into the slot still holding the GIL
→ py.detach GIL released
→ the SDK signs a request
→ asks the provider a mutex read; no Python
The alternative — a provider that called the Python callable when the SDK asked — was rejected for a reason the runtime section already states: nothing on that runtime may touch Python. The runtime is never shut down, which is safe precisely because a worker outliving the interpreter holds nothing but Rust. A provider acquiring the GIL from a tokio worker would have turned the one safe immortal runtime here into the dangerous kind.
Two consequences fall out of the slot, and both are improvements:
- A rotated key rebuilds nothing. Six of the other seven rebuild a
client when their credential moves, because the credential is inside
it. S3's is not: the next request signs with the new value, and the
connection pool and the resolved endpoint are untouched. (
Gitis the seventh, and does not rebuild either — it has an object database to protect rather than a connection pool.) - The SDK's identity cache has to be off. It exists to keep a provider
that calls IMDS from being called per request, and it caches a
credential with no expiry indefinitely — which for a deliberately
mutable slot would mean the first key signing every request forever.
IdentityCache::no_cache()costs nothing here, because the provider it defeats is a mutex read.
What is not exposed
-
The clients' own configuration types.
etcd'sConnectOptions, Vault's, Consul's and Firestore'sureq::Agent, NATS'ConnectOptionsand S3'sSdkConfigare all taken by their Rust builders deliberately, so that options this project has never heard of keep working. There is no Python spelling for atonicconfiguration or aureqagent, so a custom proxy, a hand-built connector or a DNS resolver belongs to a deployment that uses the Rust crate directly.TLS used to be on this list and no longer is, and the difference is the shape of the surface rather than the effort: a certificate authority and a client certificate are data, so they cross. Anything still here is a client type, and a client type does not.
One of those types also holds a credential kind that is therefore absent: NATS' JWT-with-signing-callback.
-
Several keys as one document — for the seven that are not git. Consul, Redis and the rest can read a list of keys or a whole prefix and merge them; here each of them reads one key. That is a second vocabulary — a merge order, an overlap rule — and for a key/value store it is also a document that never existed at any instant, because the set is one request per key. A Python deployment that needs it can merge two sources instead, which is what the layering is already for.
Gitis the exception, and the reason is the object model rather than the effort. One fetch resolves one commit, and a commit has one tree, so a list of paths or a whole directory is read as of one instant with nothing arranged for it: no transaction, no listing race, no second round trip. SoGitKeysis bound and the other seven still read one key. -
watch(). Every crate here can watch its store and push changes. That is a Rust callback on a Rust thread calling into Python, which is a second GIL story on top of this one;refresh_remote()on a timer is what Python has, and it is what the base wheel's remote path already offers.Git is where that costs something, and it is left costing it on purpose. It is the only store in the family whose multi-file sources can be watched at all — what moves is a ref, and what a ref names is a commit, so a watch wakes on the repository rather than on one file and the re-read that follows takes every file out of that one commit. Making git the exception would mean the second GIL story for one store, and a binding whose watch works for one of eight is a worse surface than one whose watch is absent for all eight. Against git a poll is cheap anyway: each tick is one ref advertisement, and only a ref that moved costs a transfer.
-
A credential with a lifetime the issuer stamped on it.
Credential::expiringhands the Rust crate a token and its TTL, so it is refreshed within a minute of expiry and after a refusal rather than per fetch. Python has the per-fetch shape only — every credential argument is called on every fetch — because the caching would have to live in Python anyway: nothing on a fetch may call back into the interpreter, so a Rust-side cache could not invoke a Python closure. A caller who mints an installation token per hour caches it in their own closure, which is four lines and is where the exchange already lives. -
from_client/ a shared connection. Nothing in a Python process is holding anetcd_client::Clientor anaws_sdk_s3::Clientto share. -
A Firestore service-account JSON key. Absent in the Rust crate too, and as a recommendation rather than a gap: signing one means an RS256 stack in a configuration library, and Google's own guidance is that a downloaded key is the option of last resort.
API
Etcd(endpoints, key, *, format=None, timeout=10.0, user=None, password=None, tls=None)
A key in an etcd v3 store. Mirrors dynamic_config_etcd::Etcd.
| Argument | Meaning |
|---|---|
endpoints | etcd's, as http://host:port. At least one |
key | The key whose value is the configuration document |
format | "json", "toml" or "yaml". Read from the key's extension when it has one |
timeout | The deadline for one fetch attempt, in seconds |
user, password | etcd's own authentication. Either both or neither; each may be a callable |
tls | A TlsConfig: a private certificate authority, a client certificate, or both |
Nothing connects at construction — etcd's client connects lazily, and a bad endpoint surfaces at the first refresh, exactly as in Rust.
Vault(address, mount, path, *, auth, key="db", namespace=None, timeout=10.0, tls=None)
A secret in Vault's KV v2 store. Mirrors dynamic_config_vault::Vault.
| Argument | Meaning |
|---|---|
address | https://vault.internal:8200 |
mount, path | The secret at {mount}/{path} |
auth | An Auth. Required — a Vault with no credentials could only ever produce a 403 |
key | The section key the secret is wrapped under. Must match the configuration's |
namespace | The Vault Enterprise namespace, if there is one |
timeout | The deadline for one fetch attempt, in seconds |
tls | A TlsConfig: a private certificate authority, a client certificate, or both |
Vault stores a section's contents rather than a whole document, which
is why key exists and why it has to agree with the one
DynamicConfig(..., key=...) was given.
Auth
How to obtain a Vault token — one classmethod per Rust variant. Every
credential argument accepts a callable. It is Auth rather than
VaultAuth because Vault's shipped first and an installed wheel already
imports it under that name; the three that followed are named for their
store. Redis and S3 have none, because neither has a login.
Auth.token(token) | A token somebody already obtained |
Auth.app_role(role_id, secret_id) | AppRole, on the approle mount |
Auth.kubernetes(role) | The pod's service-account token, on kubernetes |
Auth.jwt(jwt) | JWT/OIDC, on jwt |
Auth.userpass(username, password) | On userpass |
Auth.ldap(username, password) | On ldap |
Auth.certificate() | A TLS client certificate, on cert |
| Modifier | Effect |
|---|---|
at_mount(path) | A different mount path. No effect on token, which has none |
with_role(role) | For kubernetes, jwt and certificate |
with_token_path(path) | For kubernetes |
Each returns a new Auth rather than mutating: one shared between
two stores cannot be changed by either.
Consul(address, key, *, format=None, auth=None, datacenter=None, timeout=10.0, tls=None)
A key in Consul's KV store. Mirrors dynamic_config_consul::Consul.
| Argument | Meaning |
|---|---|
address | The agent's, as http://host:8500 |
key | The key whose value is the whole configuration document |
format | Read from the key's extension when it has one |
auth | A ConsulAuth. Defaults to anonymous(), which is what a Consul with ACLs disabled wants |
datacenter | One that is not the agent's own |
timeout | The deadline for one fetch attempt, in seconds |
tls | A TlsConfig: a private certificate authority, a client certificate, or both |
Consul stores an opaque blob, so the value is a whole document — the
opposite of Vault, which wraps a secret's fields under a section key.
ConsulAuth
ConsulAuth.anonymous() | No token. ACLs disabled, or a readable default policy |
ConsulAuth.token(token) | Usually CONSUL_HTTP_TOKEN |
ConsulAuth.kubernetes(method) | The pod's service-account token, presented to an auth method |
ConsulAuth.jwt(method, token) | A JWT or OIDC id token |
.with_bearer_file(path) | Reads the bearer from a file, re-read at every login |
.with_meta(name, value) | Consul's Meta, for the audit log |
Firestore(project, path, *, auth, key="db", database="(default)", endpoint=None, timeout=10.0, tls=None)
A document in Firestore. Mirrors dynamic_config_firestore::Firestore.
| Argument | Meaning |
|---|---|
project, path | The GCP project, and collection-then-document — config/db |
auth | A FirestoreAuth. Required |
key | The section key the document is wrapped under. Must match the configuration's |
database | One that is not (default) |
endpoint | What the emulator needs — http://127.0.0.1:8080 |
timeout | The deadline for one fetch attempt, covering the token fetch too |
tls | A TlsConfig: a private certificate authority, a client certificate, or both |
auth is required where the Rust builder defaults to the emulator: send
no credentials is a reasonable default for a builder being filled in and
a poor one for a constructor, where it would quietly produce a 401 against
the real service.
FirestoreAuth
FirestoreAuth.metadata_server() | The workload's own identity: GKE, Cloud Run, GCE. Renews itself |
FirestoreAuth.access_token(token) | Anything that already has one. Cannot renew — this is the one a callable is for |
FirestoreAuth.emulator() | No token at all |
.with_url(url) | A sidecar's metadata address |
Git(url, path, *, branch=None, tag=None, commit=None, format=None, auth=None, cache_dir=None, timeout=None, max_bytes=None, compact_after=None, tls=None)
A file — or a set of them — in a git repository. Mirrors
dynamic_config_git::GitSource.
| Argument | Meaning |
|---|---|
url | Anything git understands: https://…, ssh://…, git@host:org/repo.git, or a local path |
path | One /-separated path relative to the repository root, a list of them, or a GitKeys |
branch, tag, commit | Three spellings of one reference — name at most one. main when none is named; a commit is the full hexadecimal object id |
format | Read from a path's extension when it has one. Required for a name that does not say, for a list naming two formats, and always for a directory |
auth | A GitAuth. Defaults to anonymous(), which is what a public repository wants |
cache_dir | Keeps the object database somewhere that survives restarts. Two sources may not name one directory |
timeout | The deadline for one fetch attempt. The Rust crate's thirty seconds when absent |
max_bytes | The largest single file that will be read. A megabyte when absent |
compact_after | Transfers a working directory may accumulate before it is emptied and refilled. Thirty-two when absent; 0 never empties it |
tls | A TlsConfig — on an https:// url, and refused on any other |
A fetch is shallow and single-ref: the ref advertisement, then that one
commit at depth 1 if it is not already held, then one blob read out of its
tree. Nothing is ever checked out, so a repository containing a symlink to
/etc/shadow cannot make a checkout that never happens write anywhere. An
unchanged ref transfers nothing, which is what makes polling a git host
reasonable — and what the first fetch costs is the repository's whole tree,
because a commit's tree is what the protocol delivers.
describe() names the commit once one has been read, and the ref that was
asked for until then: which commit is this program actually serving is the
first question of every configuration-in-git incident, and a branch name does
not answer it.
Three things are refused at construction rather than half-applied, each naming
the call and the way out: two references (three keyword arguments have no
order, where Rust's three builder calls do), a credential for the transport
this url does not use (an ssh key on an https:// remote is not
half-configured, it is silently anonymous), and tls on a url with no TLS in
it.
The default working directory is a private temporary one, 0700 from the
moment it exists, removed with the store. It is the one construction in this
wheel that touches the filesystem, and it touches no network.
GitKeys
What a source reads. Mirrors dynamic_config_git::Keys; a bare string is
one and a list of strings is several, so only a directory needs the import.
Git(url, "services/api/db.yaml") | One file, handed to the loader byte for byte |
Git(url, ["base.yaml", "local.yaml"]) | Several, merged in the order given — later wins |
GitKeys.prefix("services/api") | Every file under a directory, merged as disjoint sections — an overlap is a deployment bug and is reported as one |
A directory, not a string prefix: prefix("services/api") reads
services/api/db.yaml and does not read services/api-old.yaml. It needs
format, because a directory has no extension to read one from.
It is GitKeys rather than Keys because eight stores share one namespace and
only this one has it — see what is not exposed for why
the other seven read a single key.
GitAuth
How to authenticate to a git host — one classmethod per Rust Credential
constructor. git has exactly two places a credential can go, so this is those
two plus the absence of both.
GitAuth.anonymous() | A public repository |
GitAuth.token(token) | A PAT, an installation token, a deploy token. Travels as basic auth with x-access-token in the user half |
GitAuth.basic(username, password) | For the host that reads the user half — a GitLab deploy token, gitlab-ci-token with CI_JOB_TOKEN |
GitAuth.ssh_agent() | Whatever ssh would do unaided: SSH_AUTH_SOCK, ~/.ssh/config, a ProxyJump, a hardware key |
GitAuth.ssh_key(path) | One private key file, with -o IdentitiesOnly=yes so an agent cannot offer others first |
GitAuth.ssh_command(command) | Run this instead of ssh. Redacted whole, because it may be carrying a secret |
The ssh binary must be on the host for the last three: gix carries an
SSH stream by spawning it, exactly as git does — and in exchange everything
already configured for ssh works.
ssh_key takes a path and no callable, deliberately: ssh opens the file at
every fetch, so a key an operator replaces is picked up already. A passphrase
is not accepted in any spelling — ssh has no way to take one that does not
put it on a command line where ps can read it, so a passphrase-protected key
belongs in an agent.
Nats(servers, bucket, key, *, format=None, auth=None, timeout=10.0, tls=None)
A key in a JetStream KV bucket. Mirrors dynamic_config_nats::Nats.
| Argument | Meaning |
|---|---|
servers | NATS URLs, as nats://host:4222. A list, because a cluster is ordinary |
bucket, key | The KV bucket, and the key in it |
format | Read from the key's extension when it has one |
auth | A NatsAuth. Defaults to anonymous() |
timeout | The deadline for one fetch attempt, in seconds |
tls | A TlsConfig — file paths only, and naming an authority turns TLS on |
Nothing connects at construction, which is a difference from the Rust
crate: Nats::with_options connects and resolves the bucket in its
constructor. Here construction touches no network, like every other store
in this wheel. The bucket is never created.
NatsAuth
NatsAuth.anonymous() | No credential |
NatsAuth.token(token) | What nats://token@host carries, as an argument |
NatsAuth.user_and_password(user, password) | |
NatsAuth.nkey_seed(seed) | The SU… half, which signs the server's nonce |
NatsAuth.credentials(contents) | A .creds file's contents |
NatsAuth.credentials_file(path) | The same, read on every fetch |
Redis(url, key, *, format=None, user=None, password=None, timeout=10.0, tls=None)
A key in Redis. Mirrors dynamic_config_redis::Redis.
| Argument | Meaning |
|---|---|
url | redis://host:6379, or rediss:// for TLS |
key | The key whose value is the whole configuration document |
format | Read from the key's extension when it has one |
user, password | Each may be a callable. password alone is requirepass, which implies the default user |
timeout | The deadline for one fetch attempt — connecting, writing and waiting |
tls | A TlsConfig. Needs a rediss:// URL, and material on a redis:// one is refused at the first fetch |
The credentials are arguments rather than part of url on purpose: a
callable cannot rotate a substring of a string somebody passed at
construction. They are spliced into the authority, percent-encoded, and
they replace any the URL already carried.
S3(bucket, key, *, format=None, region=None, endpoint=None, access_key_id=None, secret_access_key=None, session_token=None, timeout=10.0, tls=None)
An object in S3. Mirrors dynamic_config_s3::S3.
| Argument | Meaning |
|---|---|
bucket, key | The object, whose body is a whole configuration document |
format | Read from the key's extension when it has one |
region, endpoint | Resolved from the environment when absent. endpoint is what reaches MinIO, Ceph, R2 or B2 |
access_key_id, secret_access_key | Both or neither; each may be a callable. Absent means the SDK's own chain |
session_token | For assumed credentials. Needs the pair |
timeout | The deadline for one fetch attempt — and the SDK retries, so a fetch can take this three times over |
tls | A TlsConfig — a certificate authority only; a client certificate is refused |
Path-style addressing is always on, because the virtual-host form needs DNS entries only AWS has.
TlsConfig
A private certificate authority and a client certificate, as data. Mirrors
dynamic_config_store_core::tls::TlsConfig method for method, and every
store takes one as tls. Each method answers a new TlsConfig, so one
shared between two stores cannot be changed by either.
TlsConfig() | The platform's own trust store, no client certificate |
.with_ca_certificate_file(path) | Trust the authority in this PEM file. str or os.PathLike |
.with_ca_certificate_pem(pem) | Trust the authority in these PEM bytes |
.with_client_certificate_files(certificate, key) | Present this certificate and key (mTLS) |
.with_client_certificate_pem(certificate, key) | The same, from bytes |
.is_empty() | Whether this asks for anything at all |
repr() is the Rust type's own Debug: a path where there is one,
<redacted> where the key is bytes, and never the material.
The section above has
what each store accepts, what Nats and S3 refuse, and why refusing is
the only honest answer.
runtime_started()
Whether the tokio runtime has been started. It exists so the promise above is testable rather than merely stated.
Credential
Union[str, Callable[[], str]] — the type every credential argument
takes. Not a coroutine: the fetch path is synchronous by construction, so
an async def here would never be awaited by anything.
Errors
A failed fetch raises the base wheel's exceptions, not a second set:
dynamic_config.AuthError | The credential was refused. Waiting will not fix it |
dynamic_config.RemoteError | Anything else. Waiting might |
The compiled half raises its own pair — it is a different extension
module and cannot raise the base wheel's class objects — and the Python
facade translates, with the original attached as __cause__. Unlike a
store written in Python, the message is repeated here, because this
wheel wrote it and has already taken the credentials out.
Testing without a store
The scripted servers in this project's own suite are the pattern: a
ThreadingHTTPServer speaking enough of Vault's KV v2 API — or Consul's,
or Firestore's, or S3's GET — to be read by the real client, whose
request log is then asserted on. It is the only way to prove a credential
rotation: the claim is about which bytes reached the server, so only a
server that records them can settle it.
Four of the eight can be scripted that way because they speak HTTP. Three
speak binary protocols — etcd's gRPC, NATS', Redis' RESP — where a
scripted server would be a protocol implementation rather than a fixture,
so those prove the same claim against a real server, by rotating from
a credential it refuses to one it accepts: the first fetch raises
AuthError, the second returns the document, and the same store object
did both.
Git is scripted and real at once. Its host is a ThreadingHTTPServer
that checks the Authorization header and hands the git half to a real
git upload-pack --stateless-rpc over a real repository — so the protocol
is the one GitHub serves, and the token is the one a test chose. It is
served over TLS behind a throwaway authority rather than over plain HTTP,
and not for symmetry: gix refuses to put a credential on an unencrypted
connection unless it is compiled with
gix-transport/http-client-insecure-credentials, which this wheel is not
and should not be, so a scripted host over http:// would never be shown
a token at all. Everything that is not authentication is read over
file:// from a repository the suite builds with git — no network, no
container, no mock of the thing under test.
S3's proof is worth naming separately, because S3 has no login and no
reconnection to count. What names the credential there is the SigV4
Authorization header — Credential=AKID/… — so the scripted server
reads the access key id out of it, and the rotation is the two ids it
saw. The containers prove the protocol; the scripted servers prove the
rotation.
Implementation Details
How the binding is built, for anyone changing it — or deciding whether to trust it. This page is what the code does, which is not always what the design document that preceded it said; that document has been retired now that every decision in it either shipped or was replaced by one recorded here.
The two halves
Python application code
│ attribute access, nothing else
▼
cached Pydantic model instance ← swapped atomically per install
▲
│ model_validate(dict), once per resolve
▼
PyO3 boundary (dynamic_config._core)
▲
│ resolved tree → dict, no JSON detour
▼
dynamic-config instance engine (Rust)
files · env · dotenv · profiles · strict_env · precedence
watch · debounce · LKG cache · provenance · explain · check
dynamic_config._core is the compiled half: the engine, the value
conversion, and the one place Python is entered on a reload.
dynamic_config is ordinary Python around it — the generic
DynamicConfig, the decorator, the asyncio bridge, the secret
derivation. That split is deliberate: typing, introspection and event
loops are all clearer in Python, and none of them is on the read path.
Where validation happens, and why it is there
The binding registers Pydantic validation as the engine's own validate
hook, which the loader calls after deserializing and before installing
anything. Everything else follows from that placement:
- A model Pydantic rejects never installs, so the previous snapshot keeps serving.
- The last-known-good cache is written after a successful install, so a configuration that fails validation never reaches it.
- Recovery from that cache goes through the same hook, so a cache that no longer validates does not resurrect.
Getting there needed one change in the Rust crate: Builder::validate
used to take a bare fn pointer, which cannot capture a Pydantic class.
It now takes a closure. A plain fn still coerces, so nothing that
existed before changed.
Publishing a validated model, exactly once
The validate hook cannot install — it returns Result<(), Error> — so it
stages the model it built, and the install path publishes it. Two
paths arrive at that publish:
- the engine's own
on_reloadhook, which fires for every install after the first; - the explicit call after
init()orreload()returns, which is what covers the first install (the engine skips hooks there, deliberately — installing is not reloading).
Both call the same commit, and both can arrive for one install. A
sequence number stamped at validation makes the second a no-op: publish
once, bump the generation once, run the hooks once. Without it every
reload fired every hook twice, which is exactly what the test suite
caught.
If the staged tree does not match the tree being installed — a
concurrent load() staged something else in between — commit
validates the installed tree rather than publishing the wrong model.
The read path
current() never crosses into Rust. Each published model is copied onto
the Python configuration object by a hook registered at construction, so
a read is self._cached and a None check.
The measurement that forced this: returning the model from Rust cost
251 ns against a module global's 20, because a PyO3 method call is
roughly ten attribute lookups. The Python-side cache measures 28 ns. The
hook holds a weak reference to the configuration — a strong one would
be a cycle through a #[pyclass], which Python's collector cannot
traverse, so nothing would ever be freed.
Two caches mean they can disagree, so a test asserts they do not — after
init, reload, a watch-driven reload, replace, and recovery.
Threads, and the GIL
- Loading never holds the GIL. Every call into the engine releases it
(
py.detach) and the validate hook re-acquires it for the convert- validate-swap step, which is microseconds. - The engine handle is cloned out of its lock before anything slow.
Holding a Rust mutex while waiting for the GIL is a deadlock: a second
thread blocked on the mutex is a thread holding the GIL the first one
needs. The threading suite found exactly that, and the fix was to make
the engine an
Arcthat callers clone and use outside the lock. - Hooks run on the thread that reloaded. A raising hook is reported through Python's unraisable channel and the rest still run — the crate's panic-isolation contract in Python's vocabulary.
- Waits release the GIL and are bounded (a quarter second per slice),
so cancelling an
async foris noticed promptly rather than at the next reload. - A Python remote source is called straight through, not handed to a
worker thread.
refresh_remote()detaches for the whole refresh and the shim re-takes the GIL only to callfetch(); the design note that preceded the feature assumed that would stop the process, and the measurement says otherwise — afetch()doing I/O releases the GIL itself, so a second thread keeps running at 68–102% of its free rate. The worker would also have created the one deadlock this shape does not have: afetch()calling back into the extension would be waiting on the thread it is running on. See Remote Stores in Python. - No lock is held across a fetch. The engine clones its source out of
the remote slot, and the shim clones the Python object out of its own,
before either calls anything. That is what lets a
fetch()readcurrent(),snapshot()orexplain()on the configuration it is fetching for. Callingrefresh_remote()from inside one is refused by a thread-local flag, because that is recursion rather than re-entrancy.
Interpreter shutdown
A watcher thread that outlives finalization would call into a Python that
is no longer there. The binding registers an atexit handler that stops
every live watcher and drops every cached model while the interpreter is
still whole; live watchers and configurations are tracked in weak sets so
this costs nothing and keeps nothing alive. Rust's Drop never touches
Python.
The suite runs this for real, in subprocesses: a detached watcher at exit, an exit during a reload storm, a hook that reads back through its own configuration, a configuration dropped while watching, and a process exiting while a Python remote source is mid-fetch.
A Python remote source is the same hazard wearing a different hat, and it
needed one more move than the watchers did. The shim the engine calls
lives in a Remote that is leaked &'static, so a Py<PyAny> stored
inside it would be immortal — and since the ordinary shape of a source
is one that holds the configuration it feeds, that would be a cycle
running through a static no collector could reach. The object lives on
the configuration instead, behind a Mutex; the shim holds a Weak to
it. That makes it an ordinary edge: visited by tp_traverse, dropped by
tp_clear, and dropped again by the atexit release, after which a late
fetch answers released rather than calling into a torn-down
interpreter.
Secrets
At construction the binding walks model_fields for SecretStr and
SecretBytes — through Optional, unions and nested models, as dotted
paths — and seeds the same secret list the generated Rust builder()
seeds. The names used are the ones a file could carry — all of
them: the field name and every alias Pydantic accepts, whether that is a
plain string, an AliasPath, an AliasChoices of either, or one an
alias_generator wrote. Deliberately generous, because the two errors
are not symmetrical: listing a name nothing supplies costs a key that
never appears, and missing one puts a password in explain and in the
"redacted" cache on disk. That was not hypothetical — the earlier rule
picked one name per field, and every other spelling leaked.
The same walk descends into Pydantic dataclasses, and treats a
RootModel as living where its outer field is rather than at the
root key no file writes.
Each other schema is walked the same way in its own vocabulary: a
dataclass field's metadata={"secret": True}, and a
msgspec.Meta(extra={"secret": True}) — under encode_name, which is
the key msgspec decodes and therefore the one a file writes.
That list drives everything downstream: the redacted cache drops those
paths, explain renders them ***, and the scrubbed ValidationError
keeps locations and messages but not input values.
Nested secrets needed a second change in the Rust crate: both redaction
doors matched only the head of a path, so credentials.password was
redacted nowhere. touches_secret now answers one question for both —
is this path a secret, under one, or an ancestor of one.
Value conversion
Both directions build the target structure directly. A JSON string round
trip would parse twice and lose the integer/float distinction — port = 5432 must not arrive as 5432.0, a bool must not arrive as 1, and a
u64 above i64::MAX must keep its digits. Each of those has a test.
Coming back the other way (set_default, set_override), anything
without a configuration meaning is refused at the call: a function, a
NaN, a dict with non-string keys. SecretStr is understood, because
there the caller is supplying the value.
What the wheel contains
cdylib + PyO3 abi3-py39: one wheel per platform covers every
supported interpreter. The remote store crates are deliberately absent —
their clients would multiply the build matrix and ride into every wheel.
That is also why the wheel carries no tokio: the Rust tokio feature
routes the crate's own async loads into tokio's blocking pool, and this
binding never takes that path, because a Python loop can only await a
Python future.
Versioning
The package versions independently of the Rust crates, and is excluded
from cargo release. It embeds the engine rather than depending on a
published version of it, so a Rust-only release has nothing in it for a
Python user. dynamic_config.__version__ is the package;
__engine_version__ is the crate it was built against.
Free-Threaded CPython
The wheel is declared free-threading-safe, and this page is the audit
behind the declaration — every static, every place correctness rode on
the GIL, every shared Python object, with what the audit found and what it
changed. Two of its predictions measured false and two real defects came
out of it; both are described below.
The declaration is #[pymodule(gil_used = false)], which sets
Py_mod_gil = Py_MOD_GIL_NOT_USED so a free-threaded interpreter does not
turn the GIL back on for the process at import. It is written out in
src/lib.rs even though PyO3 has made it the default since 0.28, because
a claim this size belongs in the source that makes it rather than in a
dependency's default — and because that default has already moved once, in
the other direction.
What proves it is tests/test_free_threaded.py, run by CI's
python-free-threaded job on CPython 3.14.0t: the whole suite, plus ten
further iterations of test_threading.py, test_shutdown.py and this
file. What it does not prove is at the bottom of the page.
The wheel: one per interpreter, not one per platform
The ordinary wheel is abi3-py39, which is what makes one wheel per
platform cover every interpreter from 3.9 up. A free-threaded build has
no stable ABI to target: Py_GIL_DISABLED interpreters are not abi3, so
they need a second wheel per platform, built without abi3 and tagged
cp314t.
The interpreter selects the ABI, not a build flag. maturin build -i python3.14t produces a version-specific cp314-cp314t wheel; without
-i, maturin builds abi3 against no interpreter in particular and never
looks at the one on PATH. That is measured, not assumed — and it means
the -i is the load-bearing part of the recipe.
abi3 is nonetheless a Cargo feature the free-threaded build switches
off. Cargo features are additive, so nothing can turn abi3 off by
being turned on: it is a default feature that every ordinary build gets
by doing nothing, and the free-threaded wheel is built with
--no-default-features. That does not change the wheel's tag. What it
changes is that pyo3 is never asked for abi3, so the build does not lean
on a fallback pyo3's own authors label a backward-compatibility path —
with abi3 on, both maturin and pyo3 warn and fall back rather than fail.
3.14t only, not 3.13t. PyO3 0.29 dropped 3.13t, following CPython,
which promoted free-threading from experimental to supported in 3.14. A
cp313t wheel is therefore not buildable from this source, and the
release job builds one free-threaded interpreter rather than two.
Linux only, for now. The free-threaded wheel wave covers manylinux x86-64 and aarch64. The macOS and Windows runners were not verified for a free-threaded interpreter when this landed, and a release job that fails on an unverified guess is worse than one platform building from source. The job asserts its own wheel tags, so widening it is a matter of running it once.
The audit
Every static in the compiled half
There are none. The engine's runtime layers are &'static references,
but each is a Box::leak made per configuration object rather than a
shared global — one Layer each for defaults, overrides and flags, one
EnvBindings, one Aliases, one Remote. None is reachable from any
other configuration, and every one of them guards its own contents with a
Mutex in the core crate rather than relying on the interpreter.
The one thread-local is remote.rs's in-fetch flag, which is a per-thread
fact by construction and stays one without a GIL.
Every unsendable #[pyclass]
There are none. All three classes — Config, Watch, Snapshot — are
frozen, hold their mutable state behind Mutex/RwLock, and are
therefore Send + Sync. Nothing here is pinned to the thread that made
it, which is the property unsendable exists to express and the one a
free-threaded build makes load-bearing.
commit() — the staged sequence
src/config.rs claims the published sequence with fetch_max rather than
read-then-store, so two commit paths for one install cannot both win. That
was written pre-emptively for this item, and the audit's job was to check
it stayed that way. It did — and
test_readers_and_reloaders_agree_under_real_parallelism now exercises it
where the GIL is not doing the serialising for it.
Inner::validate — the staged slot
Last-writer-wins, deliberately. Two concurrent loads can both stage, and
the loser's model is dropped rather than published — because both load()
and commit() compare the staged tree against the one they are acting
on and re-validate when it does not match. That comparison is what makes
the slot safe to share; it is not new, and it does not depend on the GIL.
The hook list
hooks: Mutex<Vec<(u64, Py<PyAny>)>>. The design note expected the lock to
be held while hooks run, which would make a hook that registers or
unregisters another one a re-entrant lock — a deadlock, not a panic.
Measured: it is not held. run_hooks clones the list out of the lock
before running any of it, and
test_a_hook_that_registers_another_hook_does_not_deadlock and its
self-unregistering twin assert that on every build. Both pass under the
GIL, which is where a regression would first appear.
__traverse__
try_lock, returning Ok(()) when the lock is held — the right shape, and
more so on a free-threaded build, where the collector runs concurrently
with Python code rather than between bytecodes. A traverse that blocked on
a lock a running hook held would stop the collector. The same rule covers
the Python remote source, which is visited the same way, and __clear__
drops both edges.
_LIVE_CONFIGS / _LIVE_WATCHES
Changed by this audit. weakref.WeakSet is only as atomic as the GIL
makes it: its add is several bytecodes over an internal set plus a
pending-removals list, and configurations are built from many threads in
this suite alone. A registry that dropped entries would leave watchers
running into finalization, which is the crash the whole atexit sweep
exists to prevent. Both sets are now guarded by one threading.Lock, held
around the mutation and around the snapshot the sweep takes — never while a
watcher is being stopped.
The Python remote source
The object lives in the configuration rather than in the shim the engine
holds, behind a Mutex, and is cloned out of that lock before Python is
entered. The in-fetch guard is a thread-local. Nothing about it assumes one
thread.
The read path, measured
current() never crosses into Rust: it is a Python attribute lookup on the
configuration object, kept fresh by a hook. Nothing on it clones a
Py<PyAny>, so the "every clone is a real atomic" cost of a free-threaded
build should not land there. That was the structural argument; here is the
measurement, from benchmarks/read_path.py on both interpreters:
| Interpreter | config.current() | a module global | ratio |
|---|---|---|---|
| CPython 3.14.6, GIL | 29 ns | 21 ns | 1.4× |
| CPython 3.14.0t, no GIL | 27 ns | 20 ns | 1.35× |
Read the ratio, not the nanoseconds. The two interpreters are different patch releases, the machine was doing other work, and the spread across runs of the same interpreter reached 29–46 ns — larger than any difference between the two. What survives that noise is the column that normalises it away: a read costs the same multiple of a plain attribute lookup on both builds. Free-threading does not put anything extra on the read path, which is what the structure predicted.
What is tested
tests/test_free_threaded.py holds two kinds of test. The ones that run
everywhere are places the audit found correctness riding on the GIL
without saying so — each a latent bug on a GIL build too. The ones guarded
by Py_GIL_DISABLED need real parallelism to mean anything.
| Test | Runs |
|---|---|
| a hook that registers another hook does not deadlock | everywhere |
| a hook that unregisters itself does not deadlock | everywhere |
| configurations built from many threads are all registered | everywhere |
| readers and reloaders agree under real parallelism | on 3.14t, in CI |
| the module declares itself GIL-free | on 3.14t, in CI |
The last one asserts sys._is_gil_enabled() is false after importing the
extension. An earlier version watched for the interpreter's warning
instead and passed whether or not the declaration was there — the warning
is emitted once per process at the first import, so reloading the module
and catching warnings catches nothing. A gate that cannot fail is not a
gate, which is the whole reason this page exists.
What this still does not prove
- One interpreter, one platform. 3.14.0t on x86-64 Linux. The races that matter are timing-dependent, and a different core count or memory model can surface one this machine never will.
- Ten iterations is evidence, not proof. Most of these races need contention to appear. The ten-iteration loop under load is the floor, and a green run of it is the absence of a failure rather than the presence of a guarantee.
- The engine's own concurrency is argued, not model-checked, from
Python.
loomandshuttlecover the Rust side's fence and wake protocol; nothing model-checks the binding's staged-slot protocol through a Python interpreter. - The remote wheel is abi3 only.
dynamic-config-py[remote]has no free-threaded build, so a 3.14t install cannot take the remote stores with it.
Stability & Production Use
Beta, and the surface is finished for 0.x.
dynamic-config-py and dynamic-config-py-remote are Beta, like every
crate and package in this repository. Between here and 1.0, only
security fixes and hotfixes land: no new sources, no new schema kinds,
no new methods on the settled types. What still ships is a defect that
produces a wrong answer, a security advisory, and documentation — each as
a patch.
That is a change of intent rather than of policy, and it is worth saying plainly because the two look identical from outside: a project that publishes weekly because it is growing and one that publishes rarely because it is finished are both quiet. This is the second.
What that means for your program
Pin the minor version and take patches automatically.
dynamic-config-py ~= 0.1.2
A patch will not break you. Pre-1.0 a break bumps the minor, is called out in the changelog, and comes with what to change on your side — and there is no plan to spend one before 1.0.
The two wheels version together. dynamic-config-py[remote] resolves
to a pair; a gap between them is a combination nobody has tested, which is
why CI asserts their versions agree and one command moves both.
The engine's version is a separate number. dynamic_config.__version__
is this package's; __engine_version__ is the Rust crate it was built
against. They move on two schedules, because a Rust-only release has
nothing in it for a Python user.
Python versions
| Line | Status | Tested in CI | Notes |
|---|---|---|---|
| 3.9 | supported — the floor | ✅ every commit | The requires-python floor. X | None is a syntax error here, which is why a test parses every file at this level |
| 3.10 | supported | ✅ every commit | |
| 3.11 | supported | ✅ every commit | asyncio.timeout exists from here; the binding does not depend on it |
| 3.12 | supported | ✅ every commit | |
| 3.13 | supported | ✅ every commit | |
| 3.14 | supported | ✅ every commit | |
| 3.14t | supported | ✅ every commit | Free-threaded, its own cp314t wheel — the concurrency suite ten times over |
| 3.8 and older | not supported | — | End of life; the wheel's requires-python refuses to install |
One abi3 wheel per platform covers 3.9 upwards, which is why the table
is short and why a new CPython does not need a new wheel. The matrix is
what makes the claim true rather than plausible: the Python half is
ordinary code, and a version can break it — asyncio.timeout arrived in
3.11, dataclass slots in 3.10, and the typing syntax the stubs use has
moved twice.
Raising the floor is a breaking change, treated exactly as an API break: it bumps the minor and is called out in the changelog. It will not happen before 1.0.
| Platform | x86-64 | aarch64 |
|---|---|---|
| Linux (manylinux 2_28) | ✅ | ✅ |
| macOS | ✅ | ✅ |
| Windows | ✅ | — |
What is tested, and where you can see it
The claim behind Beta is evidence rather than time:
| The suite | 344 tests, on CPython 3.9 through 3.14 — every version the wheel claims, not just the newest |
| Free-threaded CPython | a cp314t wheel, with the concurrency suite run ten times over on a real no-GIL build |
| The base install | a job with the wheel and nothing else, proving pip install dynamic-config-py pulls in no schema library |
| Every example | all twenty-two run in CI; an example that only imports is not an example |
| The engine underneath | the Rust crate's own suite, its property tests, loom and shuttle models for the reload path, and instruction-count gates |
| The stores | each against a real server in a container, and three of them unplugged mid-watch by a proxy |
What running this in production actually asks of you
Decide what a failed reload should do. The default is the right one
for most services — the previous configuration keeps serving and the
failure is recorded — but recorded means somebody has to look. Wire
status() into a health endpoint, or config.on_reload into whatever you
alert on. The
telemetry chapter has the two numbers that matter:
consecutive_failures, and how old the serving document is.
Give the last-known-good cache a path that survives a restart. A
redacted cache means a broken source at startup is a warning rather than
an outage — and it refuses to write at all unless the configuration has
said what is secret, which is the point.
Watch the watcher. A file watcher is not a promise that a file will be
watched: a container bind mount and some network filesystems deliver no
events, and poll_interval is the answer there rather than a mystery.
Nothing here needs a sidecar, an agent or a server. The engine is in your process, the reads are lock-free, and the only thing that leaves is what a remote store you configured goes to fetch.
Limitations
What the Python bindings deliberately do not do, and why. As with the Rust crate's Limitations, the list exists so that a missing feature reads as a decision rather than an oversight — and so that anyone who disagrees can argue with the reason instead of guessing at one.
Not exposed
Remote stores, in the base wheel
etcd, Consul, Vault, NATS, Redis, S3, Firestore and git stay out of the ordinary wheel. Their clients are a gRPC stack, the AWS SDK, a git implementation and three HTTP clients between them, and a wheel is built per platform — every one of those dependencies would ride into every install, including the ones reading a single TOML file.
Two things are exposed instead, and between them they cover most of it.
The door: RemoteSource is implementable in
Python, so a store with no Rust client — a company's own service, a file
a sidecar writes — is a class with fetch() and describe(). That is in
the base wheel and needs nothing extra.
The stores, as an opt-in second wheel:
pip install dynamic-config-py[remote] buys all eight
Rust clients — etcd, Consul, Vault, NATS, Redis, S3, Firestore and git —
compiled, imported as dynamic_config.remote. An extra cannot turn on a
Cargo feature in a binary that was compiled weeks ago on a release
runner, so it resolves to a distribution of its own; the base install is
unchanged, and importing dynamic_config.remote without it raises an
ImportError naming the extra.
Custom proxies are not exposed for any of them, and the reason is
structural rather than an omission: each Rust builder reaches one by
taking its client's own configuration type — an etcd_client::ConnectOptions,
a ureq::Agent, an SdkConfig — which exists precisely so that options
this project has never heard of keep working, and which has no Python
spelling. Nor is watch(): a Rust callback on a Rust thread calling into
Python is a second GIL story, and refresh_remote() on a timer is what
Python has instead. That one costs more for git than for the others,
and the wheel says so rather than leaving it to be discovered: git is the
only store whose multi-file source can be watched in Rust, because one
fetch resolves one commit — so it is the only place where the missing
watch() costs a capability rather than a convenience.
TLS is exposed, and it is the counter-example worth reading before
adding to this list. A private certificate authority and a client
certificate are the settings a hardened deployment actually needs, and
they reach Python because the Rust surface for them was built as
data
— paths and PEM bytes, with no client type in any signature — rather than
as another door onto a client's own type. Two stores cannot express all of
it and refuse the part they cannot rather than ignoring it: Nats
takes certificate paths and not PEM bytes, because async-nats opens the
files itself, and S3 takes no client certificate at all, because the AWS
SDK's TLS context is a trust store with no slot for one. Both raise at
construction, naming the call and the way out — a caller who believes they
pinned an authority and did not is worse off than one whose program will
not start.
Encrypted files
encrypted_file(...) needs a Decryptor implementation, which is a Rust
trait. Shipping age to make one usable would put a crypto stack in
every wheel for a door only Rust can open. Decrypt with the
CLI or your deployment's own tooling and point this at the
result.
save and JSON Schema
Pydantic already serializes models and emits JSON Schema, and does both
better than a second implementation would. model_dump_json() and
model_json_schema() are the answers.
Constraints worth knowing
Sources are fixed after the first load
.file(...), .env(...) and the rest raise once anything has loaded.
Sources are how a configuration is identified; changing them makes it a
different configuration, and pretending otherwise would leave the watcher
watching one thing and the loader reading another. Build a second
DynamicConfig.
One watcher per configuration object
A second watch() on the same object raises AlreadyExists, exactly as
the Rust engine does — a second watcher could only mislead. Two
DynamicConfig objects over the same model watch side by side without
interfering, which is what
multi-tenant
uses.
validate is Pydantic's, not a second hook
There is no .validate(fn) on the Python builder, because the model
already has field_validator and model_validator. A rejection there
behaves exactly as a Rust validate refusal: nothing installs, the cache
is not written, the previous model keeps serving.
ValidationError does not pass through untouched
Pydantic's str() embeds input_value=..., which would put the
offending configuration value into every log line that caught it. A
rejection raises InvalidError instead, whose message is the scrubbed
rendering and whose .errors is Pydantic's own report with the input
values removed. The locations, messages and error types are all there.
There is no pip install dynamic-config-py[tokio]
The Rust crate has a tokio feature, and it is reasonable to expect the
wheel to expose the same switch. It does not, and the reason has changed
shape now that the remote wheel exists — so this
section says both halves.
A wheel is already compiled. A pip extra installs Python
distributions; it cannot turn on a Cargo feature in a binary that was
built weeks ago on a release runner. Anything that needs a different
build has to be a second wheel. That much was always true, and it is
exactly what dynamic-config-py[remote] turned out to be.
Nothing in the base wheel awaits a tokio task. The Rust tokio
feature routes the crate's own async loads into tokio's blocking pool.
The base binding never takes that path: Python's event loop can await a
Python future and nothing else, so the blocking half goes to a Python
executor and the result comes back as a Python object. Enabling tokio
there would add a runtime to every wheel that no code in it would enter —
which is why the base wheel still refuses the feature, and why a
[tokio] extra would be a distribution differing in a way nobody could
observe.
The remote wheel is where a runtime finally means something, and it
owns one rather than turning the engine's feature on. etcd's client is
async, so something has to drive its fetch; that is one runtime, two
worker threads, started when the first store that needs one is
constructed and never shut down. The
whole story — including what happens
if the calling thread is already inside somebody else's runtime, and why
an immortal runtime is safe here — is on that page.
Note what the remote wheel does not do: it does not enable
dynamic-config/tokio. That feature is about where the engine's own
async loads go, and the engine in these wheels does no async loading at
all. The runtime exists for the store client and for nothing else.
set_executor is unchanged and still answers the question it always
answered — which pool pays for the blocking work:
dynamic_config.set_executor(ThreadPoolExecutor(2, thread_name_prefix="config"))
refresh_remote_async() runs the whole fetch on that pool, remote wheel
or not. With the remote wheel installed, the tokio runtime is what the
fetch uses once it arrives there; the two are stacked, not competing.
Free-threaded CPython is one interpreter and one platform
The module declares Py_mod_gil = Py_MOD_GIL_NOT_USED, the suite runs on
a real 3.14 free-threading build, and the audit behind that is
a page of its own. What the claim rests on is
narrower than the claim sounds: one interpreter version, one platform,
and ten repeated runs of the threading and shutdown suites — evidence
rather than proof. The free-threaded wheels are manylinux x86_64 and
aarch64 only, so macOS and Windows on a t interpreter build from
source. cp313t does not exist at all: PyO3 0.29 dropped it when CPython
promoted free-threading from experimental to supported in 3.14.
A Python fetch() cannot be timed out from outside
A remote store written in Python runs as ordinary
Python on the thread that asked for the refresh, and nothing on the Rust
side can interrupt Python that has decided not to return. A worker thread
and a deadline would let refresh_remote() give up while the fetch kept
running, which is an error message rather than a cure. The deadline
belongs to the client fetch() calls — httpx.get(..., timeout=5) — and
Ctrl-C still works, because a KeyboardInterrupt out of a fetch
propagates unchanged.
Creating configurations in a loop leaks a little
Each DynamicConfig allocates the runtime layers the engine takes as
&'static — a few hundred bytes, once, per configuration object, never
per reload. A program with a handful of configurations pays nothing worth
measuring; a program constructing thousands in a loop is doing something
the design did not anticipate, and should hold one and use
set_override instead.
The decorator does not load at import time
@dynamic_config(...) attaches a configuration and stops. Reading files
while a module is being imported is a side effect nobody asked for, and
it makes import order load-bearing. Call Model.config.init() where your
program starts, or pass init=True if you are writing a script and want
exactly that.
Versioning
The Python package versions independently of the Rust crates. The ten crates on crates.io move in lockstep because they pin each other exactly; the wheel has no such tie — it embeds the engine rather than depending on a published version of it — so bumping it for a Rust-only fix would ask every Python user to upgrade for a release with nothing in it for them.
It moves when the Python package changes: a new API, a behaviour change,
or an engine bump worth shipping. dynamic_config.__version__ and
pip show dynamic-config-py report that number; the engine's own version
is what the wheel was built against and is recorded in the changelog
entry that shipped it.
What a dataclass schema does not do
The dependency-free schema validates structurally and does not coerce.
Three exceptions aside — an Enum takes its member's value,
date/time/datetime parse through fromisoformat, and a type that
builds from a single argument is built from it — a value whose type does
not match its annotation is a validation failure rather than an
assignment. If you want a string parsed into something the stdlib cannot
parse it into, constraints, aliases, or validators, that is what
pip install dynamic-config-py[pydantic] buys.
One limitation there is Python's rather than this library's: annotations
are resolved with typing.get_type_hints, which looks in the module
where the class was defined. A dataclass declared inside a function
names types that module cannot see, so its annotations stay strings and
there is nothing to check them against — the fields are filled without a
type check. Declare configuration dataclasses at module level. Pydantic
meets the same wall and answers it with model_rebuild().
What a msgspec schema does not carry
InvalidError.errors is empty for a msgspec.Struct, and stays that
way. msgspec's ValidationError is a message and a path; there is no
structured report behind it, and building one by parsing that message
would be inventing structure the library never promised — the kind of
plausible lie a program would then branch on. str(error) names the
field, which is what a dataclass schema gives too.
Secrets are declared through Meta(extra={"secret": True}) rather than a
type, because msgspec has no SecretStr and does not want one: its
Annotated metadata carries constraints, plus an extra mapping meant
for exactly this kind of flag. A SecretStr annotation inside a struct
is not a secret declaration here — msgspec cannot build one, so the field
would not load at all.
Not planned
- A settings-source shim for
pydantic-settings. The two libraries answer the same question differently; wiring this in as aPydanticBaseSettingsSourcewould inherit that library's lifecycle (read once, at construction) and lose the reloading that is the whole point. Support went the other way instead — a settings class is a schema here, andfrom_settingstranslates its declaration into engine sources. See pydantic-settings. - Automatic reload on attribute access. Reading configuration would become an I/O operation with unpredictable latency, which is precisely the design this library exists to avoid.
- A global default configuration.
dynamic_config.current()with no object would be a singleton by another name — the same thing the Rust crate refuses in Not planned.