whence
Typed configuration that remembers where it came from.
Every configuration library can tell you a value. whence can tell you why
it has that value — which file, which line, which profile, and what it
overrode.
$ whence explain db.host --profile prod
db.host = "db.internal"
<- config/app.prod.yaml:4:9 [profile=prod]
shadowed:
overrides - not set
MYAPP_DB__HOST - not set
.env - not set
config/app.yaml:2:9 = "localhost"
<defaults> = "localhost"
Spring has this (Origin, /actuator/env), HOCON has it (ConfigOrigin), Rust's
figment has it (Metadata). Python has not — pydantic-settings' debug output
is top-level-only and unredacted, dynaconf has history but no line numbers, and
everything else discards provenance at the first merge.
whence is a complement to pydantic, not a replacement. Point it at a
pydantic model or a frozen dataclass; it does the layering, the provenance and
the diagnostics.
Install
pip install whence # env, .env, TOML, JSON, .properties, XML
pip install whence[yaml] # + YAML
The core has no runtime dependencies. That is a design constraint, not an accident: every format but YAML is parsed on the standard library.
Use
from dataclasses import dataclass
from whence import Config, Secret, settings
@settings(prefix="db")
@dataclass(frozen=True, slots=True)
class Db:
host: str = "localhost"
port: int = 5432
password: Secret | None = None
cfg = Config.load(app="myapp", profiles=["prod"])
db = cfg.bind(Db)
cfg.explain("db.host") # the winner, and everything it shadowed
Loading is synchronous, and deliberately so. Configuration is read once,
before the application runs — there is no event loop at that point, so an
await would have nothing to yield to. A Source that reaches the network
blocks the startup it is already part of, which is exactly what starting up
means.
What it does
| Formats | env vars, .env, TOML, JSON, YAML, Java .properties, XML |
| Precedence | seven named layers, overrides > cli > env > .env > secrets-dir > files > defaults |
| Discovery | explicit path → $MYAPP_CONFIG → project roots → per-user config dir → pyproject.toml, all configurable |
| Profiles | app.prod.yaml overlays, profile groups, and profiles never reorder sources |
| Interpolation | ${a.b}, ${a.b:-default}, ${?a.b}, ${env:VAR}, ${file:/run/secrets/x} |
| Secrets | Secret, _FILE indirection, a sanitizer on every dump, reads gated by unlock_secrets() |
| Binding | frozen dataclasses on the stdlib; pydantic models when pydantic is installed |
| Diagnostics | batched errors carrying origin, and did-you-mean for unknown keys |
Exact line:column comes from YAML, .env and .properties. tomllib, json
and ElementTree expose no positions, so TOML, JSON and XML get file-level
origins — stated here rather than implied away.
Reloading
There isn't any. To change configuration, redeploy.
Config is immutable and loaded once. A library that also owns a poll loop owns
a thread or an event loop, and the generational swap it needs is where every
prior art went wrong: .NET's ChangeToken.OnChange has fired twice per save
since 2017, Go viper's WatchConfig carries a documented data race, and koanf's
Watch() is not safe against concurrent reads. Each of those is a property of
changing an object other code is holding — so whence does not hold one.
What whence does guarantee is that a fresh Config.load(...) sees the current
state of the world, including through a Kubernetes ConfigMap's ..data
symlink. kubelet republishes by pointing that symlink at a new directory, and
an inotify watch on the config file is bound to the old inode and goes
permanently deaf; re-reading resolves the symlink afresh. That is covered by a
test against real symlinks on a real Linux filesystem (make test-docker).
If you need a running process to pick up a change, call Config.load(...) again
on a schedule you own and swap your own pointer. It is a handful of lines, it
lives where your concurrency model already is, and whence stays out of it.
Cross-platform
Config directories follow each platform's own convention: XDG on Linux,
~/Library/Application Support on macOS (plus XDG when you set it explicitly),
%APPDATA% and %LOCALAPPDATA% on Windows. Case-insensitive filesystems do not
produce phantom ambiguities, CRLF files parse, and every file is read as UTF-8
regardless of the platform's default encoding.
Contributing
Pull requests are welcome. Fork the repository, branch off main, and open the PR against
main. CI runs on every pull request and every job is required, so run make all before
pushing: it is the local mirror of the CI gate, and a green run here is the cheapest way to
avoid a red one there.
make install # once: sync the environment, install the pre-commit hooks
make all # lint, typecheck, coverage, docs, build
The pre-commit hooks cover ruff and the lockfile on the way in; mypy is deliberately not among
them and is gated in CI instead, because it is slow enough that people start reaching for
--no-verify.
What CI adds on top of make all:
| Job | What it catches |
|---|---|
test |
The real matrix: Linux, macOS and Windows, at both ends of the supported Python range |
encoding |
Any open() or read_text() missing an explicit encoding= |
minimums |
Declared floors that are only ever tested at their latest versions |
no-deps |
A runtime import that quietly breaks the zero-dependency install |
build |
Broken packaging, a missing py.typed, a wheel that does not import |
The Python 3.15 leg is advisory until 3.15.0 ships; everything else must be green.
What a reviewable PR looks like
- A changelog entry under
## [Unreleased]in CHANGELOG.md, in the Keep a Changelog sections the file already uses. The version is bumped at release time, not here. - Annotated code, tests and examples included. mypy runs
strict = trueoversrc/,tests/andexamples/alike, and suppressions are specific:# type: ignore[code], never bare. - Docstrings on public API. The docs site generates its reference from them, and ruff's
Drules (google convention) enforce them insrc/. - A new runtime dependency is a decision, not a detail.
dependencies = []is the product claim, not a preference: every format but YAML is parsed on the standard library, and YAML lives behind thewhence[yaml]extra. Open an issue before the PR. Dev tooling goes in[dependency-groups], which is never published. uv.lockcommitted whenever a dependency changes; theuv-lockhook fails on drift.
Two rules exist because breaking them fails late and somewhere else. Probe, never branch on
sys.platform — case-insensitivity is a property of a directory, not a platform, and a name
that can be created is not always a name that round-trips. And pass encoding= explicitly on
every read: without it a bug appears only on a Windows code page, only for non-ASCII content,
and only in someone else's CI, which is what make encoding exists to prevent.
If a change touches mounts, musl, a read-only root or a non-UTF-8 locale, make test-docker
runs the six Linux scenarios CI cannot reproduce. Tests that need a real mount carry the
container marker and are excluded from the default run.
Anything large enough to have a design is worth an issue before the PR; small fixes can go straight to one.
License
MIT
Metadata
Release files for whence 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| whence-1.0.0.tar.gz | 83.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| whence-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 152.8 kB
Release files / whence-1.0.0.tar.gz
| Download URL | whence-1.0.0.tar.gz |
|---|---|
| Size | 83.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0b950d55afff84d22031056fc8bdffc8dc9b380b2037610514555c7b74722c30
|
|
BLAKE2b-256 checksum How to use checksums |
290d1cc7c57c41f8a12a3b50f5057b2798707839f6e128da3b7014910e5b61ad
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.
Transparency logRelease files / whence-1.0.0-py3-none-any.whl
| Download URL | whence-1.0.0-py3-none-any.whl |
|---|---|
| Size | 69.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
76871e8a60ec84224fe6f2e18246d76d79cc257d6c7a12c05659e5cf1daae2c6
|
|
BLAKE2b-256 checksum How to use checksums |
d41ffdfdae7ec18d221d47483ac8328ba96eb7f66bb33c643828e303bf1cf4c4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.
Transparency log