Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 validate refusal 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 readagainst a global
config.current()291.1×
a module global27
Model.model_validate(dict)99034×

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 exposedWhy
The store crates themselvestheir clients — gRPC, the AWS SDK, three HTTP stacks — would ride into every wheel
Encrypted filesdecryption needs a Decryptor, which is a Rust trait; decrypt with the CLI and point this at the result
save, JSON SchemaPydantic 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.

ParameterDefaultMeaning
modelrequiredthe 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
keyrequiredthe 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()
executorNonewhich 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.

MethodEffect
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.

SynchronousAsync twinDoes
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_descriptionWhat 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 as RemoteError — or AuthError, 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 configurationcurrent(), snapshot(), explain() — because no lock is held across it. The one thing it may not do is call refresh_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

SynchronousAsync twinDoes
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.

MethodEffect
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

MethodReturns
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

MethodReturns
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

keyThe section key
modelThe Pydantic class
generationHow 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 with composes 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=1 means pool.max_size. A field whose own name contains __ cannot be spelled this way — use set_override.
  • With no arguments it pins nothing and still restores, which wraps a block that calls set_override itself.

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
FixtureIs
dynamic_config_workspaceA tmp_path that is also the working directory, so file("app.toml") finds this test's copy
dynamic_config_envA 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.

MemberAnswers
values[path]the value at a dotted path, or KeyError
values.get(path, default=None)the same, with a default
path in valueswhether 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.

ArgumentDefaultThe call it makesMeaning
keyrequiredDynamicConfig(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 eachFiles to merge, in order — later wins, a missing one is skipped
discoverNone.discover(name, paths)(name, paths): look for {name}.{ext} in each directory, below the listed files
envNone.env(prefix)The environment prefix, trailing underscore included
nestNone.nest(separator)What means nesting inside a variable name; __ unless given
allow_empty_envFalse.allow_empty_env()Treat FOO= as set-to-empty rather than unset
strict_envFalse.strict_env()Refuse ambiguous spellings — off, no, nil
whole_documentFalse.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_envNone.profile_env(variable)The variable naming the active profile, for sibling files
cache / cache_modeNone / "redacted".cache(path, mode)Last-known-good cache; redacted, full or fingerprint
initFalse.init()Load at decoration — off, because import time is not load time
watchNone.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:

MethodAnswers
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.

ClassRaised when
IoErrorA source exists but could not be read
ParseErrorA source is not valid in its format
MissingErrorA required value is supplied by nothing
TypeMismatchErrorA value cannot become the requested type
EnvErrorAn environment variable could not be interpreted
InvalidErrorThe 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)
RemoteErrorA remote store could not be read — unreachable, refusing, malformed
AuthErrorA credential was rejected, or could not be obtained. Distinct from RemoteError on purpose: waiting fixes one and not the other
DecryptErrorAn encrypted source could not be decrypted
BackendErrorThe engine refused — a source added after loading, for instance
NotInitialisedErrorcurrent() 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

ShapeRuns
config.on_reload(hook)after every install
@config.on_reloadthe 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 for pool.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

  • bytes fields 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:

SchemaInstallValidation
dataclasses.dataclassnothingstructural: 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
Valuesnothingnone — 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 relativevalues.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 modelValues
check() unknown keyscompared against the field namesnothing to compare — report.unknown_checked is False and the rendering says unknown keys: not checked (no field list)
secret pathsderived 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 bool and int kept apart in both directions, and int widening to float but 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:

msgspecBeside it
a secretMeta(extra={"secret": True})SecretStr, or field(metadata={"secret": True})
unknown keysignored, unless the struct says forbid_unknown_fields=Truea Pydantic model's extra; a dataclass always refuses
InvalidError.errorsempty — msgspec raises a message, not a reportPydantic'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:

ShapeNotes
Inheritance, any depthFields accumulate; a subclass may narrow a parent's field
Mixins beside BaseModelAn ordinary class in the bases is untouched
model_configextra (ignore/allow/forbid), frozen, populate_by_name, alias_generator, validate_assignment
Validatorsfield_validator (both modes), model_validator; a rejection is a refused reload, never a half-installed one
Computed fieldsAvailable on the installed model, like any property
Private attributesPrivateAttr survives a load; it is not configuration
RootModelAs a field, and as the outer model
Pydantic dataclassesAs a field, secrets inside them included
Generic modelsBox[int] directly, or a subclass that specialises it
Discriminated unionsThe discriminator picks the member, as usual
BaseSettingsSee 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:

DeclaredBecomes
toml_file, json_file, yaml_filefile(...), in that order
env_fileenv_file(...) — the dotenv layer
env_prefixone bind_env per leaf field, so APP_PORT stays APP_PORT rather than becoming APP_<KEY>_PORT
secrets_dirsecrets_dir(...) — a directory of single-value files
env_nested_delimiterthe separator inside those names
case_sensitivewhether 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

QuestionWhere
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 is Database | None, which is what the checker wants at a boundary where current() 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_fork hook, or a startup handler), never at import time in the master. A watcher thread does not survive fork() in the child, and the master's copy would be the only one left running.
  • With --preload, the module-level init() 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.

FieldTypeMeans
generationintModels installed since the process started; zero before the first
stale_forfloat | NoneSeconds since the serving model was installed. None — not zero — before the first install, because zero reads as just now
last_reasonstr | NoneWhy it was installed: initial, manual, file_changed, remote, replaced
last_failureFailure | NoneThe most recent reload that installed nothing
consecutive_failuresintReloads that installed nothing since one did. Zero is healthy
is_healthyboolconsecutive_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.

FieldTypeMeans
fetchesintDocuments the store handed over, pulled or pushed
stale_forfloat | NoneSeconds since the last one arrived
last_fetch_durationfloat | NoneHow 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_failureFailure | NoneThe most recent fetch that returned nothing
consecutive_failuresintFetches that returned nothing since one did
reachablebool | NoneThree 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

FieldTypeMeans
kindstrThe category: io, parse, missing, type, env, invalid, remote, auth, decrypt, backend — the names the exception classes carry
pathstrThe dotted key path, empty when the failure belongs to the load as a whole
seconds_agofloatHow 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.

MethodEffect
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:

NameTypeExtra label
dynamic_config_installs_totalcounter
dynamic_config_last_success_secondsgauge
dynamic_config_consecutive_failuresgauge
dynamic_config_last_failure_secondsgauge
dynamic_config_last_reload_infogaugereason
dynamic_config_last_failure_infogaugekind

Per remote source added:

NameTypeExtra label
dynamic_config_remote_upgauge
dynamic_config_remote_fetches_totalcounter
dynamic_config_remote_last_fetch_secondsgauge
dynamic_config_remote_last_fetch_duration_secondsgauge
dynamic_config_remote_consecutive_failuresgauge
dynamic_config_remote_last_failure_infogaugekind

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 a watch() that pushes. TLS is no longer one of those: a private certificate authority and a client certificate are TlsConfig, 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 isthe 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.

StoreClientRuntimeThe credential
Consulureq, blockingnoneAn ACL token, or a bearer exchanged for one
EtcdgRPC, asynctokioA user and password, in the connection
Firestoreureq, blockingnoneAn access token, or the metadata server
Gitgix, blockingnoneAn https token, or an ssh key
Natsasync-natstokioA token, NKey, user pair or .creds, on connect
Redisredis, blockingnoneA user and password, in the URL
S3AWS SDK, asynctokioA ProvideCredentials chain — see below
Vaultureq, blockingnoneA 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 wheelremote wheel
Distributiondynamic-config-pydynamic-config-py-remote
Importdynamic_configdynamic_config.remote
Size (Linux x86-64, release)1.36 MB11.68 MB
Extension moduledynamic_config._coredynamic_config_remote._core
tokioneverone runtime, lazily
MSRV1.851.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 develop installs a .pth pointing at the source tree, so dynamic_config.__path__ is that one directory and a dynamic_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__.py gone and dynamic_config/remote/ still there, import dynamic_config still 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:

StoreWhere a callable earns its place
VaultThe login credential — a secret id, a password, a JWT. The Vault token is the crate's to renew
ConsulThe bearer presented to an auth method. The ACL token is the crate's
FirestoreFirestoreAuth.access_token, which cannot renew. metadata_server renews itself and needs none
Etcd, Redis, NatsThe credential itself: it lives in the connection, so a new value is a reconnection
S3The key pair — but nothing is rebuilt, because the SDK asks per request
GitThe 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@host is a secret, and redis://user@host is 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

StoreCA from a fileCA from bytesClient certificate
Consul, Etcd, Firestore, Redis, Vaultyesyesyes
Gityesyesyes — on an https:// url, and refused on any other
Natsyesnoasync-nats opens the file itselffile paths only
S3yesyesno — 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. (Git is 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's ConnectOptions, Vault's, Consul's and Firestore's ureq::Agent, NATS' ConnectOptions and S3's SdkConfig are all taken by their Rust builders deliberately, so that options this project has never heard of keep working. There is no Python spelling for a tonic configuration or a ureq agent, 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.

    Git is 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. So GitKeys is 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::expiring hands 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 an etcd_client::Client or an aws_sdk_s3::Client to 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.

ArgumentMeaning
endpointsetcd's, as http://host:port. At least one
keyThe key whose value is the configuration document
format"json", "toml" or "yaml". Read from the key's extension when it has one
timeoutThe deadline for one fetch attempt, in seconds
user, passwordetcd's own authentication. Either both or neither; each may be a callable
tlsA 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.

ArgumentMeaning
addresshttps://vault.internal:8200
mount, pathThe secret at {mount}/{path}
authAn Auth. Required — a Vault with no credentials could only ever produce a 403
keyThe section key the secret is wrapped under. Must match the configuration's
namespaceThe Vault Enterprise namespace, if there is one
timeoutThe deadline for one fetch attempt, in seconds
tlsA 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
ModifierEffect
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.

ArgumentMeaning
addressThe agent's, as http://host:8500
keyThe key whose value is the whole configuration document
formatRead from the key's extension when it has one
authA ConsulAuth. Defaults to anonymous(), which is what a Consul with ACLs disabled wants
datacenterOne that is not the agent's own
timeoutThe deadline for one fetch attempt, in seconds
tlsA 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.

ArgumentMeaning
project, pathThe GCP project, and collection-then-document — config/db
authA FirestoreAuth. Required
keyThe section key the document is wrapped under. Must match the configuration's
databaseOne that is not (default)
endpointWhat the emulator needs — http://127.0.0.1:8080
timeoutThe deadline for one fetch attempt, covering the token fetch too
tlsA 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.

ArgumentMeaning
urlAnything git understands: https://…, ssh://…, git@host:org/repo.git, or a local path
pathOne /-separated path relative to the repository root, a list of them, or a GitKeys
branch, tag, commitThree spellings of one reference — name at most one. main when none is named; a commit is the full hexadecimal object id
formatRead 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
authA GitAuth. Defaults to anonymous(), which is what a public repository wants
cache_dirKeeps the object database somewhere that survives restarts. Two sources may not name one directory
timeoutThe deadline for one fetch attempt. The Rust crate's thirty seconds when absent
max_bytesThe largest single file that will be read. A megabyte when absent
compact_afterTransfers a working directory may accumulate before it is emptied and refilled. Thirty-two when absent; 0 never empties it
tlsA 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 spellingssh 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.

ArgumentMeaning
serversNATS URLs, as nats://host:4222. A list, because a cluster is ordinary
bucket, keyThe KV bucket, and the key in it
formatRead from the key's extension when it has one
authA NatsAuth. Defaults to anonymous()
timeoutThe deadline for one fetch attempt, in seconds
tlsA TlsConfigfile 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.

ArgumentMeaning
urlredis://host:6379, or rediss:// for TLS
keyThe key whose value is the whole configuration document
formatRead from the key's extension when it has one
user, passwordEach may be a callable. password alone is requirepass, which implies the default user
timeoutThe deadline for one fetch attempt — connecting, writing and waiting
tlsA 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.

ArgumentMeaning
bucket, keyThe object, whose body is a whole configuration document
formatRead from the key's extension when it has one
region, endpointResolved from the environment when absent. endpoint is what reaches MinIO, Ceph, R2 or B2
access_key_id, secret_access_keyBoth or neither; each may be a callable. Absent means the SDK's own chain
session_tokenFor assumed credentials. Needs the pair
timeoutThe deadline for one fetch attempt — and the SDK retries, so a fetch can take this three times over
tlsA TlsConfiga 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.AuthErrorThe credential was refused. Waiting will not fix it
dynamic_config.RemoteErrorAnything 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:

  1. the engine's own on_reload hook, which fires for every install after the first;
  2. the explicit call after init() or reload() 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 Arc that 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 for is 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 call fetch(); the design note that preceded the feature assumed that would stop the process, and the measurement says otherwise — a fetch() 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: a fetch() 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() read current(), snapshot() or explain() on the configuration it is fetching for. Calling refresh_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:

Interpreterconfig.current()a module globalratio
CPython 3.14.6, GIL29 ns21 ns1.4×
CPython 3.14.0t, no GIL27 ns20 ns1.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.

TestRuns
a hook that registers another hook does not deadlockeverywhere
a hook that unregisters itself does not deadlockeverywhere
configurations built from many threads are all registeredeverywhere
readers and reloaders agree under real parallelismon 3.14t, in CI
the module declares itself GIL-freeon 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. loom and shuttle cover 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

LineStatusTested in CINotes
3.9supported — the floor✅ every commitThe requires-python floor. X | None is a syntax error here, which is why a test parses every file at this level
3.10supported✅ every commit
3.11supported✅ every commitasyncio.timeout exists from here; the binding does not depend on it
3.12supported✅ every commit
3.13supported✅ every commit
3.14supported✅ every commit
3.14tsupported✅ every commitFree-threaded, its own cp314t wheel — the concurrency suite ten times over
3.8 and oldernot supportedEnd 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.

Platformx86-64aarch64
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 suite344 tests, on CPython 3.9 through 3.14 — every version the wheel claims, not just the newest
Free-threaded CPythona cp314t wheel, with the concurrency suite run ten times over on a real no-GIL build
The base installa job with the wheel and nothing else, proving pip install dynamic-config-py pulls in no schema library
Every exampleall twenty-two run in CI; an example that only imports is not an example
The engine underneaththe Rust crate's own suite, its property tests, loom and shuttle models for the reload path, and instruction-count gates
The storeseach 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 a PydanticBaseSettingsSource would 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, and from_settings translates 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.