dynamic-config-py
Hot-reloadable configuration for Python: Rust resolves, your schema validates.
pip install dynamic-config-py # dataclasses; no dependencies
pip install dynamic-config-py[pydantic] # + Pydantic models
pip install dynamic-config-py[pydantic-settings] # + BaseSettings classes
pip install dynamic-config-py[all] # all of it
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
)
The schema can be a dataclasses.dataclass, a Pydantic model, a Pydantic
dataclass or a BaseSettings class. Everything else — sources,
precedence, watching, recovery, diagnostics — is the same object either
way; what changes is what validation means and what you install.
The engine is the dynamic-config Rust crate: files, environment
layering, .env, profiles, discovery, precedence, a debounced file
watcher, last-known-good recovery and provenance. A dataclass schema is
validated structurally — required fields, unknown keys, nested
dataclasses, declared types. A Pydantic one is validated by Pydantic, all
of it: field_validator, model_validator, aliases, SecretStr.
Validation runs once per successful resolve, never per read.
current() returns a cached instance, so reading configuration on every
request costs an attribute lookup rather than a boundary crossing.
What it gives you
config.init() # load, validate, install
config.init_and_current() # …and hand back the model, in one line
config.reload() # again, on demand
watch = config.watch(debounce=0.25) # and again on every file change
config.current() # the model, cached
config.try_current() # or None, before the first load
@config.on_change("pool_size") # only when that path moved
def resize(old, new):
pool.resize(new.pool_size)
Every blocking call has an async twin that runs the work off the loop —
init_async, load_async, reload_async — plus two ways to wait:
await config.init_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)
Cancelling either wait is noticed within a quarter second, and leaves the
engine untouched. Which thread pool pays for the blocking half is yours
to choose — dynamic_config.set_executor(pool) process-wide, or
DynamicConfig(..., executor=pool) for one configuration — the same
question the Rust crate's set_blocking_executor answers.
A reload that Pydantic rejects keeps the previous model serving — exactly as a bad file edit does. Nothing installs, the last-known-good cache is not written, and the error is reported rather than raised at a reader.
Diagnostics that answer the actual question
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
config.check() # would it load? any unknown keys?
config.snapshot().to_dict() # the resolved section, as data
explain is the one diagnostic that prints values, and it redacts:
fields typed SecretStr or SecretBytes read ***. Nobody
re-declares which fields are secret — the binding derives the list from
the model's own types, nested models included, and the redacted cache and
the scrubbed validation errors follow from the same list.
The decorator, for the settings crowd
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()
It does not load at import time — reading files while a module is being
imported is a surprise nobody asked for. init=True says otherwise.
The rules it keeps
- A reader never pays for a reload. No per-read validation, no per-read boundary crossing, no lock a writer can hold.
- A bad reload changes nothing. The previous model keeps serving; the failure is reported where it happened.
- Values stay out of diagnostics. Every
reprhere shows shape, not values;explainis the documented exception, and it redacts secrets. Pydantic'sValidationErrornormally echoes the offending input — at this boundary it is scrubbed to locations, messages and error types, attached aserror.errors. - Interpreter shutdown is not a crash. Watcher threads are stopped before finalization, so nothing calls into a Python that is no longer there.
Not exposed, deliberately
- The remote stores (etcd, Consul, Vault, NATS, Redis, S3, Firestore). Their clients would ride into every wheel; they stay in Rust until there is a reason to pay that.
RemoteSourceimplemented in Python — a Python object on the fetch path deserves its own design pass.- Encrypted files. Decryption needs a
Decryptorimplementation, which is a Rust trait; a deployment that needs it decrypts with the CLI and points this at the result. saveand JSON Schema. Pydantic already does both, better.- A
pydantic-settingssource shim. Wiring in as aPydanticBaseSettingsSourcewould inherit that library's lifecycle — read once, at construction — and lose the reloading that is the point. Support goes the other way instead:DynamicConfig.from_settingsturns a settings class's own declaration into engine sources.
pydantic-settings
A BaseSettings class is a BaseModel, so it works here as a schema
unchanged. What does not carry over is its sourcing: pydantic-settings
reads its sources in __init__, and this binding validates with
model_validate, which does not go through it. A class declaring
env_prefix would therefore get none of it — silently, which is the part
worth fixing.
config = DynamicConfig.from_settings(ServiceSettings, key="svc")
config.init()
from_settings reads the class's SettingsConfigDict and rebuilds it as
engine sources: toml_file/json_file/yaml_file become files,
env_file becomes the dotenv layer, and env_prefix becomes one binding
per leaf field — so APP_PORT stays APP_PORT rather than becoming
APP_<KEY>_PORT, and a deployment's existing variables keep working.
env_nested_delimiter and case_sensitive shape those names.
What has no engine equivalent is refused at the call rather than dropped:
secrets_dir, cli_parse_args, and an overridden
settings_customise_sources. Using DynamicConfig(...) directly on a
class that declares sourcing warns and carries on — the configuration is
the source there, which is a fine thing to want, as long as nobody
believes the env_prefix is doing something.
One difference in the schema half is worth knowing: BaseSettings
defaults to extra="forbid" where BaseModel ignores what it does not
declare, so a narrow settings class pointed at a wide section fails
validation rather than shrugging.
Examples
Sixteen runnable scripts in examples/ — the quick start,
layering and precedence, watching, asyncio (single- and multi-file), the
decorator (plain, and several configurations on one event loop),
multi-tenant configuration, secrets and recovery, the diagnostics tour,
test overrides, every callback shape, pydantic-settings, and FastAPI,
Flask and Django integrations. All of them run in CI.
python examples/01_quick_start.py
How it works
Implementation Details covers the inside: validation hooked before the install (which is what makes a rejected reload change nothing), the sequence number that publishes each model exactly once, the Python-side cache that keeps a read at 28 ns, the GIL and thread rules, and interpreter-shutdown safety.
Requirements
Python 3.9+ (abi3 wheels), Pydantic 2. The distribution is
dynamic-config-py; the import is dynamic_config.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dynamic_config_py-0.1.0-cp39-abi3-win_amd64.whl.
File metadata
- Download URL: dynamic_config_py-0.1.0-cp39-abi3-win_amd64.whl
- Upload date:
- Size: 1.0 MB
- Tags: CPython 3.9+, Windows x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: maturin/1.14.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d854cfde40f12a886fe920f4d151c31df784614971cf2dea7efc64663efa91e8
|
|
| MD5 |
f45513f818cdd67c33c330e0b0591e95
|
|
| BLAKE2b-256 |
39d674e973045e38ef42240128e3944a8749cf321503270107f52c244d7b81cd
|
File details
Details for the file dynamic_config_py-0.1.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: dynamic_config_py-0.1.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 1.3 MB
- Tags: CPython 3.9+, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: maturin/1.14.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
58ec5304a570474ce8ffdce6ea3582ff7225fcb7fed3878a775e167d38b17ff0
|
|
| MD5 |
da8730de8fc870c1d23fb082ffdd1d65
|
|
| BLAKE2b-256 |
ae12bb5309658b2c7e63ca501ae990e8d7079c0a7ce7fe3872ae0d5b901de9c4
|
File details
Details for the file dynamic_config_py-0.1.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.
File metadata
- Download URL: dynamic_config_py-0.1.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
- Upload date:
- Size: 1.3 MB
- Tags: CPython 3.9+, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: maturin/1.14.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6f037a9263bf8e2a20a991cdb4ab4a941bb8ce90ff488d38ffe09055e1def540
|
|
| MD5 |
6379eb41eb1bb30257d7fe88dc39b2a3
|
|
| BLAKE2b-256 |
881b05e3b11599e3b7ae89bcc3d1402e9645b804676441ff828a66febd40f2bd
|
File details
Details for the file dynamic_config_py-0.1.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl.
File metadata
- Download URL: dynamic_config_py-0.1.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl
- Upload date:
- Size: 2.3 MB
- Tags: CPython 3.9+, macOS 10.12+ universal2 (ARM64, x86-64), macOS 10.12+ x86-64, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: maturin/1.14.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cf18ca4f1557dcf34bae8186e72ecbc0bf204e95d5f581a9e498167add489162
|
|
| MD5 |
b0af49b4bf3f8546ced1369127756ee6
|
|
| BLAKE2b-256 |
dedf6f0ddc0c3b3de36bee99e3ffe7e39b6d84badf81042656102d063a6cb98f
|