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 — a RemoteSource or an AsyncRemoteSource. 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. The synchronous one raises on an async store |
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 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 |
| — | events() | An async iterator of Reloaded and ReloadFailed — refusals wake it natively. No event carries a value |
watch(debounce=0.25, poll_interval=None) | watch_async(…) | Starts a watcher; returns a Watch |
watching(debounce=0.25, poll_interval=None) | watching_async(…) | The same as a block, stopped on the way out |
running(watch=True, …) | running_async(watch=True, …) | init, then watch, then stop — the whole lifetime as one block, yielding the first model |
on_reload(hook, *, dispatch=None, backpressure=None) | — | 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 |
| — | on_reload_async(hook, *, backpressure=LATEST) | on_reload for a coroutine function: a task on the registering loop |
| — | on_change_async(*paths, backpressure=LATEST) | The same, filtered by path |
changed_async, changes and events are answered by a notifier thread
rather than by polling, so cancelling one is immediate and an idle
service does no work at all — see
how a wait is answered.
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 what it is for: 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. The pool stays the
caller's: it is never shut down here, including at exit. Waiting for a
reload uses no executor at all, so this sizes loads and refreshes only —
see Async & asyncio.
configure_executor(workers=2, *, thread_name_prefix="dynamic-config")
set_executor with the pool built for you, and owned: the threads are
named dynamic-config-blocking-N, and the pool is shut down at
interpreter exit. Calling it again replaces the pool and closes the
previous one. Returns the pool.
executor(pool=None, *, workers=2)
The choice as a block, restored on the way out. A pool this builds is closed at the end of the block; a pool passed in is left alone, because it is the caller's.
with dynamic_config.executor(workers=4):
await config.init_async()
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, and that
Database.config is DynamicConfig[Database] — so everything reached
through it, changes() and changed_async() included, stays the
model's own type. 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.
AsyncRemoteSource
The same door for a store whose client is async: async def fetch() and
a synchronous describe(). refresh_remote_async() awaits fetch() on
the calling loop and hands the document to the engine afterwards, so an
async client runs on the loop it was built on; a cancelled refresh
cancels the fetch, and a raising fetch() reaches the caller as its own
exception rather than as RemoteError. The synchronous
refresh_remote() raises on such a store rather than driving it from a
private loop. See Async & asyncio.
Format
Format.JSON, Format.TOML, Format.YAML — a str enum, so a plain
"json" is accepted too.
ConfigGroup
Several configurations under one lifecycle:
ConfigGroup(db, cache, queue, concurrency=None).
| Method | Does |
|---|---|
init() / init_async() | Loads every member; the first failure stops the group |
reload() / reload_async() | Reloads every member independently |
reload_atomic() / reload_atomic_async() | Every member validates, or none installs |
watch(…) / watch_async(…), stop() | A watcher per member, and stopping all of them |
watching(…) / watching_async(…) | The same as a block |
running(watch=True, …) / running_async(…) | init, then watch, then stop |
status(), generations() | Per key, for a health endpoint |
configs, len(), iteration, repr() | The members themselves |
concurrency bounds how many members load at once; None — the default
— loads them one at a time. The group owns lifecycle, not storage: the
read path is still db.current().
Dispatch and Backpressure
str enums naming where a reload hook runs and what happens when
installs outrun it: Dispatch.INLINE / EXECUTOR / ASYNCIO, and
Backpressure.EVERY / LATEST / SERIAL / CANCEL_PREVIOUS. A value
outside the set is a ValueError naming the ones inside it. See
Callbacks.
Reloaded and ReloadFailed
What events() yields — frozen dataclasses, both carrying generation
and at (a Unix timestamp).
| Type | Fields |
|---|---|
Reloaded | changed — the dotted paths that moved — and reason |
ReloadFailed | kind, path, and consecutive: how many refusals in a row |
No event carries a value, the same rule explain() and check()
follow: a value in an event is a secret in a log.
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 |
AbsentError | The store answered, and the document is not there — a key deleted, a path that never existed. Distinct from RemoteError, which is an outage waiting cures, and from MissingError, which is a key absent from a document that was read |
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 |