whence 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- whence-1.0.0/.gitignore +66 -0
- whence-1.0.0/CHANGELOG.md +57 -0
- whence-1.0.0/LICENSE +21 -0
- whence-1.0.0/PKG-INFO +201 -0
- whence-1.0.0/README.md +172 -0
- whence-1.0.0/docker/secrets/README.md +2 -0
- whence-1.0.0/examples/README.md +127 -0
- whence-1.0.0/pyproject.toml +208 -0
- whence-1.0.0/src/whence/__init__.py +121 -0
- whence-1.0.0/src/whence/_platform.py +196 -0
- whence-1.0.0/src/whence/binding/__init__.py +223 -0
- whence-1.0.0/src/whence/binding/_dataclasses.py +144 -0
- whence-1.0.0/src/whence/binding/_pydantic.py +120 -0
- whence-1.0.0/src/whence/binding/coerce.py +220 -0
- whence-1.0.0/src/whence/chain.py +176 -0
- whence-1.0.0/src/whence/cli.py +83 -0
- whence-1.0.0/src/whence/config.py +405 -0
- whence-1.0.0/src/whence/decorators.py +371 -0
- whence-1.0.0/src/whence/discovery.py +426 -0
- whence-1.0.0/src/whence/errors.py +68 -0
- whence-1.0.0/src/whence/formats/__init__.py +90 -0
- whence-1.0.0/src/whence/formats/json.py +40 -0
- whence-1.0.0/src/whence/formats/properties.py +105 -0
- whence-1.0.0/src/whence/formats/toml.py +37 -0
- whence-1.0.0/src/whence/formats/xml.py +90 -0
- whence-1.0.0/src/whence/formats/yaml.py +73 -0
- whence-1.0.0/src/whence/interpolate.py +180 -0
- whence-1.0.0/src/whence/keys.py +184 -0
- whence-1.0.0/src/whence/origin.py +166 -0
- whence-1.0.0/src/whence/profiles.py +93 -0
- whence-1.0.0/src/whence/py.typed +0 -0
- whence-1.0.0/src/whence/secret.py +148 -0
- whence-1.0.0/src/whence/sources/__init__.py +48 -0
- whence-1.0.0/src/whence/sources/argv.py +84 -0
- whence-1.0.0/src/whence/sources/dotenv.py +175 -0
- whence-1.0.0/src/whence/sources/env.py +128 -0
- whence-1.0.0/src/whence/sources/files.py +68 -0
- whence-1.0.0/src/whence/sources/mapping.py +51 -0
- whence-1.0.0/src/whence/sources/secrets.py +60 -0
- whence-1.0.0/src/whence/tree.py +122 -0
- whence-1.0.0/tests/conftest.py +32 -0
- whence-1.0.0/tests/test_whence_binding.py +276 -0
- whence-1.0.0/tests/test_whence_chain.py +66 -0
- whence-1.0.0/tests/test_whence_cli.py +76 -0
- whence-1.0.0/tests/test_whence_config.py +149 -0
- whence-1.0.0/tests/test_whence_container.py +134 -0
- whence-1.0.0/tests/test_whence_decorators.py +298 -0
- whence-1.0.0/tests/test_whence_discovery.py +194 -0
- whence-1.0.0/tests/test_whence_e2e.py +462 -0
- whence-1.0.0/tests/test_whence_formats.py +125 -0
- whence-1.0.0/tests/test_whence_interpolate.py +97 -0
- whence-1.0.0/tests/test_whence_keys.py +71 -0
- whence-1.0.0/tests/test_whence_origin.py +51 -0
- whence-1.0.0/tests/test_whence_platform.py +104 -0
- whence-1.0.0/tests/test_whence_precedence.py +88 -0
- whence-1.0.0/tests/test_whence_profiles.py +68 -0
- whence-1.0.0/tests/test_whence_secrets.py +103 -0
- whence-1.0.0/tests/test_whence_smoke.py +35 -0
- whence-1.0.0/tests/test_whence_sources.py +248 -0
- whence-1.0.0/tests/test_whence_tree.py +41 -0
whence-1.0.0/.gitignore
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# --- Secrets -----------------------------------------------------------
|
|
2
|
+
# Configuration is what this library reads; keep real config and keys out of the repo.
|
|
3
|
+
# The fixtures under examples/ and docker/secrets/ are fake and committed on purpose.
|
|
4
|
+
.env
|
|
5
|
+
.env.*
|
|
6
|
+
!.env.example
|
|
7
|
+
*.pem
|
|
8
|
+
*.key
|
|
9
|
+
secrets.toml
|
|
10
|
+
|
|
11
|
+
# --- Python ------------------------------------------------------------
|
|
12
|
+
__pycache__/
|
|
13
|
+
*.py[cod]
|
|
14
|
+
*$py.class
|
|
15
|
+
*.so
|
|
16
|
+
.Python
|
|
17
|
+
|
|
18
|
+
# --- Builds ------------------------------------------------------------
|
|
19
|
+
build/
|
|
20
|
+
dist/
|
|
21
|
+
*.egg
|
|
22
|
+
*.egg-info/
|
|
23
|
+
.eggs/
|
|
24
|
+
|
|
25
|
+
# --- Environments ------------------------------------------------------
|
|
26
|
+
.venv/
|
|
27
|
+
.venv*/
|
|
28
|
+
venv/
|
|
29
|
+
env/
|
|
30
|
+
.direnv/
|
|
31
|
+
|
|
32
|
+
# --- Tooling caches ----------------------------------------------------
|
|
33
|
+
.pytest_cache/
|
|
34
|
+
.mypy_cache/
|
|
35
|
+
.dmypy.json
|
|
36
|
+
dmypy.json
|
|
37
|
+
.ruff_cache/
|
|
38
|
+
.nox/
|
|
39
|
+
.tox/
|
|
40
|
+
.cache/
|
|
41
|
+
.hypothesis/
|
|
42
|
+
.ipynb_checkpoints/
|
|
43
|
+
|
|
44
|
+
# --- Coverage ----------------------------------------------------------
|
|
45
|
+
.coverage
|
|
46
|
+
.coverage.*
|
|
47
|
+
coverage.xml
|
|
48
|
+
htmlcov/
|
|
49
|
+
|
|
50
|
+
# --- Docs ---------------------------------------------------------------
|
|
51
|
+
# .gitignore has no inline comments - each pattern gets its own line.
|
|
52
|
+
# mkdocs build output
|
|
53
|
+
site/
|
|
54
|
+
|
|
55
|
+
# --- Logs / scratch ----------------------------------------------------
|
|
56
|
+
*.log
|
|
57
|
+
*.orig
|
|
58
|
+
*.rej
|
|
59
|
+
scratch/
|
|
60
|
+
tmp/
|
|
61
|
+
|
|
62
|
+
# --- Editors / OS ------------------------------------------------------
|
|
63
|
+
.DS_Store
|
|
64
|
+
.idea/
|
|
65
|
+
.vscode/
|
|
66
|
+
*.swp
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [1.0.0] - 2026-09-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Provenance spine: `Origin`, `Tracked`, and a merge that records what each
|
|
15
|
+
value shadowed.
|
|
16
|
+
- `Source` protocol with a `SourceChain` supporting name-addressed
|
|
17
|
+
insertion, plus environment, `.env`, file, mapping and secrets-directory
|
|
18
|
+
sources.
|
|
19
|
+
- Format loaders for TOML, JSON, `.properties` and XML on the standard library,
|
|
20
|
+
and YAML behind the `whence[yaml]` extra. YAML, `.env` and `.properties`
|
|
21
|
+
carry exact line and column numbers.
|
|
22
|
+
- `Discovery`: a declarative five-step search chain over explicit paths,
|
|
23
|
+
`$APP_CONFIG`, project roots, per-user config directories and
|
|
24
|
+
`pyproject.toml`, with cross-platform directory conventions.
|
|
25
|
+
- `Config` with `get`, `require`, `explain`, `dump`, `bind` and `with_fallback`.
|
|
26
|
+
- Interpolation with `${a.b}`, `${a.b:-default}`, `${?a.b}`, `${env:VAR}` and
|
|
27
|
+
`${file:/path}`, resolved lazily after the merge.
|
|
28
|
+
- Profiles, profile groups, and the invariant that profiles never reorder
|
|
29
|
+
sources.
|
|
30
|
+
- Binding to frozen dataclasses (standard library) and to pydantic models when
|
|
31
|
+
pydantic is installed, with batched errors that carry origins.
|
|
32
|
+
- `Secret`, `unlock_secrets()`, `_FILE` indirection and a sanitizer applied to
|
|
33
|
+
every dump.
|
|
34
|
+
- `@settings` and `@from_config` decorators.
|
|
35
|
+
- `ArgvSource`, reading `--set key=value` out of `sys.argv`.
|
|
36
|
+
- `RelativePath`, a path resolved against the file that declared it rather than
|
|
37
|
+
the process working directory -- possible only because values carry origins.
|
|
38
|
+
- A `whence` CLI: `explain`, `dump` and `discovery`.
|
|
39
|
+
|
|
40
|
+
### Design notes
|
|
41
|
+
|
|
42
|
+
- **Loading is synchronous, and there is no reload.** Configuration is read once
|
|
43
|
+
before the application runs, where there is no event loop for an `await` to
|
|
44
|
+
yield to. Reload was cut with it: a poll loop means whence owning a thread or a
|
|
45
|
+
loop and mutating a generation other code holds, which is precisely where
|
|
46
|
+
.NET's `ChangeToken`, viper's `WatchConfig` and koanf's `Watch()` each went
|
|
47
|
+
wrong. A process that must pick up a change calls `Config.load(...)` again on a
|
|
48
|
+
schedule it owns.
|
|
49
|
+
|
|
50
|
+
### Stability
|
|
51
|
+
|
|
52
|
+
- The public API is whatever `whence.__init__` lists in `__all__`. From this
|
|
53
|
+
release on it follows semantic versioning: anything else is internal, and a
|
|
54
|
+
breaking change to the public surface requires a 2.0.0.
|
|
55
|
+
|
|
56
|
+
[Unreleased]: https://github.com/izmailov-labs/whence/compare/v1.0.0...HEAD
|
|
57
|
+
[1.0.0]: https://github.com/izmailov-labs/whence/releases/tag/v1.0.0
|
whence-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 izmailov-labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
whence-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: whence
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Typed configuration that remembers where it came from: layered loading with full provenance.
|
|
5
|
+
Project-URL: Homepage, https://github.com/izmailov-labs/whence
|
|
6
|
+
Project-URL: Documentation, https://izmailov-labs.github.io/whence/
|
|
7
|
+
Project-URL: Repository, https://github.com/izmailov-labs/whence
|
|
8
|
+
Project-URL: Changelog, https://github.com/izmailov-labs/whence/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/izmailov-labs/whence/issues
|
|
10
|
+
Author: izmailov-labs
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: config,configuration,dotenv,env,provenance,settings
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: System Administrators
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: System :: Systems Administration
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.12
|
|
26
|
+
Provides-Extra: yaml
|
|
27
|
+
Requires-Dist: pyyaml>=6.0.2; extra == 'yaml'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# whence
|
|
31
|
+
|
|
32
|
+
**Typed configuration that remembers where it came from.**
|
|
33
|
+
|
|
34
|
+
Every configuration library can tell you a value. `whence` can tell you *why*
|
|
35
|
+
it has that value — which file, which line, which profile, and what it
|
|
36
|
+
overrode.
|
|
37
|
+
|
|
38
|
+
```console
|
|
39
|
+
$ whence explain db.host --profile prod
|
|
40
|
+
db.host = "db.internal"
|
|
41
|
+
<- config/app.prod.yaml:4:9 [profile=prod]
|
|
42
|
+
shadowed:
|
|
43
|
+
overrides - not set
|
|
44
|
+
MYAPP_DB__HOST - not set
|
|
45
|
+
.env - not set
|
|
46
|
+
config/app.yaml:2:9 = "localhost"
|
|
47
|
+
<defaults> = "localhost"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Spring has this (`Origin`, `/actuator/env`), HOCON has it (`ConfigOrigin`), Rust's
|
|
51
|
+
`figment` has it (`Metadata`). Python has not — pydantic-settings' debug output
|
|
52
|
+
is top-level-only and unredacted, dynaconf has history but no line numbers, and
|
|
53
|
+
everything else discards provenance at the first merge.
|
|
54
|
+
|
|
55
|
+
`whence` is a **complement to pydantic, not a replacement**. Point it at a
|
|
56
|
+
pydantic model or a frozen dataclass; it does the layering, the provenance and
|
|
57
|
+
the diagnostics.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```console
|
|
62
|
+
pip install whence # env, .env, TOML, JSON, .properties, XML
|
|
63
|
+
pip install whence[yaml] # + YAML
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The core has **no runtime dependencies**. That is a design constraint, not an
|
|
67
|
+
accident: every format but YAML is parsed on the standard library.
|
|
68
|
+
|
|
69
|
+
## Use
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from dataclasses import dataclass
|
|
73
|
+
from whence import Config, Secret, settings
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@settings(prefix="db")
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class Db:
|
|
79
|
+
host: str = "localhost"
|
|
80
|
+
port: int = 5432
|
|
81
|
+
password: Secret | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
cfg = Config.load(app="myapp", profiles=["prod"])
|
|
85
|
+
db = cfg.bind(Db)
|
|
86
|
+
|
|
87
|
+
cfg.explain("db.host") # the winner, and everything it shadowed
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Loading is synchronous, and deliberately so. Configuration is read once,
|
|
91
|
+
before the application runs — there is no event loop at that point, so an
|
|
92
|
+
`await` would have nothing to yield to. A `Source` that reaches the network
|
|
93
|
+
blocks the startup it is already part of, which is exactly what starting up
|
|
94
|
+
means.
|
|
95
|
+
|
|
96
|
+
## What it does
|
|
97
|
+
|
|
98
|
+
| | |
|
|
99
|
+
|---|---|
|
|
100
|
+
| **Formats** | env vars, `.env`, TOML, JSON, YAML, Java `.properties`, XML |
|
|
101
|
+
| **Precedence** | seven named layers, `overrides > cli > env > .env > secrets-dir > files > defaults` |
|
|
102
|
+
| **Discovery** | explicit path → `$MYAPP_CONFIG` → project roots → per-user config dir → `pyproject.toml`, all configurable |
|
|
103
|
+
| **Profiles** | `app.prod.yaml` overlays, profile groups, and profiles never reorder sources |
|
|
104
|
+
| **Interpolation** | `${a.b}`, `${a.b:-default}`, `${?a.b}`, `${env:VAR}`, `${file:/run/secrets/x}` |
|
|
105
|
+
| **Secrets** | `Secret`, `_FILE` indirection, a sanitizer on every dump, reads gated by `unlock_secrets()` |
|
|
106
|
+
| **Binding** | frozen dataclasses on the stdlib; pydantic models when pydantic is installed |
|
|
107
|
+
| **Diagnostics** | batched errors carrying origin, and did-you-mean for unknown keys |
|
|
108
|
+
|
|
109
|
+
Exact `line:column` comes from YAML, `.env` and `.properties`. `tomllib`, `json`
|
|
110
|
+
and `ElementTree` expose no positions, so TOML, JSON and XML get file-level
|
|
111
|
+
origins — stated here rather than implied away.
|
|
112
|
+
|
|
113
|
+
## Reloading
|
|
114
|
+
|
|
115
|
+
There isn't any. To change configuration, redeploy.
|
|
116
|
+
|
|
117
|
+
`Config` is immutable and loaded once. A library that also owns a poll loop owns
|
|
118
|
+
a thread or an event loop, and the generational swap it needs is where every
|
|
119
|
+
prior art went wrong: .NET's `ChangeToken.OnChange` has fired **twice per save**
|
|
120
|
+
since 2017, Go viper's `WatchConfig` carries a documented data race, and koanf's
|
|
121
|
+
`Watch()` is not safe against concurrent reads. Each of those is a property of
|
|
122
|
+
changing an object other code is holding — so whence does not hold one.
|
|
123
|
+
|
|
124
|
+
What whence does guarantee is that a fresh `Config.load(...)` sees the current
|
|
125
|
+
state of the world, including through a **Kubernetes ConfigMap's `..data`
|
|
126
|
+
symlink**. kubelet republishes by pointing that symlink at a new directory, and
|
|
127
|
+
an inotify watch on the config *file* is bound to the old inode and goes
|
|
128
|
+
permanently deaf; re-reading resolves the symlink afresh. That is covered by a
|
|
129
|
+
test against real symlinks on a real Linux filesystem (`make test-docker`).
|
|
130
|
+
|
|
131
|
+
If you need a running process to pick up a change, call `Config.load(...)` again
|
|
132
|
+
on a schedule you own and swap your own pointer. It is a handful of lines, it
|
|
133
|
+
lives where your concurrency model already is, and whence stays out of it.
|
|
134
|
+
|
|
135
|
+
## Cross-platform
|
|
136
|
+
|
|
137
|
+
Config directories follow each platform's own convention: XDG on Linux,
|
|
138
|
+
`~/Library/Application Support` on macOS (plus XDG when you set it explicitly),
|
|
139
|
+
`%APPDATA%` and `%LOCALAPPDATA%` on Windows. Case-insensitive filesystems do not
|
|
140
|
+
produce phantom ambiguities, CRLF files parse, and every file is read as UTF-8
|
|
141
|
+
regardless of the platform's default encoding.
|
|
142
|
+
|
|
143
|
+
## Contributing
|
|
144
|
+
|
|
145
|
+
Pull requests are welcome. Fork the repository, branch off `main`, and open the PR against
|
|
146
|
+
`main`. CI runs on every pull request and every job is required, so run `make all` before
|
|
147
|
+
pushing: it is the local mirror of the CI gate, and a green run here is the cheapest way to
|
|
148
|
+
avoid a red one there.
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
make install # once: sync the environment, install the pre-commit hooks
|
|
152
|
+
make all # lint, typecheck, coverage, docs, build
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The pre-commit hooks cover ruff and the lockfile on the way in; mypy is deliberately not among
|
|
156
|
+
them and is gated in CI instead, because it is slow enough that people start reaching for
|
|
157
|
+
`--no-verify`.
|
|
158
|
+
|
|
159
|
+
What CI adds on top of `make all`:
|
|
160
|
+
|
|
161
|
+
| Job | What it catches |
|
|
162
|
+
| --- | --- |
|
|
163
|
+
| `test` | The real matrix: Linux, macOS and Windows, at both ends of the supported Python range |
|
|
164
|
+
| `encoding` | Any `open()` or `read_text()` missing an explicit `encoding=` |
|
|
165
|
+
| `minimums` | Declared floors that are only ever tested at their latest versions |
|
|
166
|
+
| `no-deps` | A runtime import that quietly breaks the zero-dependency install |
|
|
167
|
+
| `build` | Broken packaging, a missing `py.typed`, a wheel that does not import |
|
|
168
|
+
|
|
169
|
+
The Python 3.15 leg is advisory until 3.15.0 ships; everything else must be green.
|
|
170
|
+
|
|
171
|
+
### What a reviewable PR looks like
|
|
172
|
+
|
|
173
|
+
- **A changelog entry** under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in the Keep a
|
|
174
|
+
Changelog sections the file already uses. The version is bumped at release time, not here.
|
|
175
|
+
- **Annotated code, tests and examples included.** mypy runs `strict = true` over `src/`,
|
|
176
|
+
`tests/` and `examples/` alike, and suppressions are specific: `# type: ignore[code]`, never
|
|
177
|
+
bare.
|
|
178
|
+
- **Docstrings on public API.** The docs site generates its reference from them, and ruff's `D`
|
|
179
|
+
rules (google convention) enforce them in `src/`.
|
|
180
|
+
- **A new runtime dependency is a decision, not a detail.** `dependencies = []` is the product
|
|
181
|
+
claim, not a preference: every format but YAML is parsed on the standard library, and YAML
|
|
182
|
+
lives behind the `whence[yaml]` extra. Open an issue before the PR. Dev tooling goes in
|
|
183
|
+
`[dependency-groups]`, which is never published.
|
|
184
|
+
- **`uv.lock` committed** whenever a dependency changes; the `uv-lock` hook fails on drift.
|
|
185
|
+
|
|
186
|
+
Two rules exist because breaking them fails late and somewhere else. **Probe, never branch on
|
|
187
|
+
`sys.platform`** — case-insensitivity is a property of a directory, not a platform, and a name
|
|
188
|
+
that can be created is not always a name that round-trips. And **pass `encoding=` explicitly on
|
|
189
|
+
every read**: without it a bug appears only on a Windows code page, only for non-ASCII content,
|
|
190
|
+
and only in someone else's CI, which is what `make encoding` exists to prevent.
|
|
191
|
+
|
|
192
|
+
If a change touches mounts, musl, a read-only root or a non-UTF-8 locale, `make test-docker`
|
|
193
|
+
runs the six Linux scenarios CI cannot reproduce. Tests that need a real mount carry the
|
|
194
|
+
`container` marker and are excluded from the default run.
|
|
195
|
+
|
|
196
|
+
Anything large enough to have a design is worth an issue before the PR; small fixes can go
|
|
197
|
+
straight to one.
|
|
198
|
+
|
|
199
|
+
## License
|
|
200
|
+
|
|
201
|
+
MIT
|
whence-1.0.0/README.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# whence
|
|
2
|
+
|
|
3
|
+
**Typed configuration that remembers where it came from.**
|
|
4
|
+
|
|
5
|
+
Every configuration library can tell you a value. `whence` can tell you *why*
|
|
6
|
+
it has that value — which file, which line, which profile, and what it
|
|
7
|
+
overrode.
|
|
8
|
+
|
|
9
|
+
```console
|
|
10
|
+
$ whence explain db.host --profile prod
|
|
11
|
+
db.host = "db.internal"
|
|
12
|
+
<- config/app.prod.yaml:4:9 [profile=prod]
|
|
13
|
+
shadowed:
|
|
14
|
+
overrides - not set
|
|
15
|
+
MYAPP_DB__HOST - not set
|
|
16
|
+
.env - not set
|
|
17
|
+
config/app.yaml:2:9 = "localhost"
|
|
18
|
+
<defaults> = "localhost"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Spring has this (`Origin`, `/actuator/env`), HOCON has it (`ConfigOrigin`), Rust's
|
|
22
|
+
`figment` has it (`Metadata`). Python has not — pydantic-settings' debug output
|
|
23
|
+
is top-level-only and unredacted, dynaconf has history but no line numbers, and
|
|
24
|
+
everything else discards provenance at the first merge.
|
|
25
|
+
|
|
26
|
+
`whence` is a **complement to pydantic, not a replacement**. Point it at a
|
|
27
|
+
pydantic model or a frozen dataclass; it does the layering, the provenance and
|
|
28
|
+
the diagnostics.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```console
|
|
33
|
+
pip install whence # env, .env, TOML, JSON, .properties, XML
|
|
34
|
+
pip install whence[yaml] # + YAML
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The core has **no runtime dependencies**. That is a design constraint, not an
|
|
38
|
+
accident: every format but YAML is parsed on the standard library.
|
|
39
|
+
|
|
40
|
+
## Use
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from dataclasses import dataclass
|
|
44
|
+
from whence import Config, Secret, settings
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@settings(prefix="db")
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class Db:
|
|
50
|
+
host: str = "localhost"
|
|
51
|
+
port: int = 5432
|
|
52
|
+
password: Secret | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
cfg = Config.load(app="myapp", profiles=["prod"])
|
|
56
|
+
db = cfg.bind(Db)
|
|
57
|
+
|
|
58
|
+
cfg.explain("db.host") # the winner, and everything it shadowed
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Loading is synchronous, and deliberately so. Configuration is read once,
|
|
62
|
+
before the application runs — there is no event loop at that point, so an
|
|
63
|
+
`await` would have nothing to yield to. A `Source` that reaches the network
|
|
64
|
+
blocks the startup it is already part of, which is exactly what starting up
|
|
65
|
+
means.
|
|
66
|
+
|
|
67
|
+
## What it does
|
|
68
|
+
|
|
69
|
+
| | |
|
|
70
|
+
|---|---|
|
|
71
|
+
| **Formats** | env vars, `.env`, TOML, JSON, YAML, Java `.properties`, XML |
|
|
72
|
+
| **Precedence** | seven named layers, `overrides > cli > env > .env > secrets-dir > files > defaults` |
|
|
73
|
+
| **Discovery** | explicit path → `$MYAPP_CONFIG` → project roots → per-user config dir → `pyproject.toml`, all configurable |
|
|
74
|
+
| **Profiles** | `app.prod.yaml` overlays, profile groups, and profiles never reorder sources |
|
|
75
|
+
| **Interpolation** | `${a.b}`, `${a.b:-default}`, `${?a.b}`, `${env:VAR}`, `${file:/run/secrets/x}` |
|
|
76
|
+
| **Secrets** | `Secret`, `_FILE` indirection, a sanitizer on every dump, reads gated by `unlock_secrets()` |
|
|
77
|
+
| **Binding** | frozen dataclasses on the stdlib; pydantic models when pydantic is installed |
|
|
78
|
+
| **Diagnostics** | batched errors carrying origin, and did-you-mean for unknown keys |
|
|
79
|
+
|
|
80
|
+
Exact `line:column` comes from YAML, `.env` and `.properties`. `tomllib`, `json`
|
|
81
|
+
and `ElementTree` expose no positions, so TOML, JSON and XML get file-level
|
|
82
|
+
origins — stated here rather than implied away.
|
|
83
|
+
|
|
84
|
+
## Reloading
|
|
85
|
+
|
|
86
|
+
There isn't any. To change configuration, redeploy.
|
|
87
|
+
|
|
88
|
+
`Config` is immutable and loaded once. A library that also owns a poll loop owns
|
|
89
|
+
a thread or an event loop, and the generational swap it needs is where every
|
|
90
|
+
prior art went wrong: .NET's `ChangeToken.OnChange` has fired **twice per save**
|
|
91
|
+
since 2017, Go viper's `WatchConfig` carries a documented data race, and koanf's
|
|
92
|
+
`Watch()` is not safe against concurrent reads. Each of those is a property of
|
|
93
|
+
changing an object other code is holding — so whence does not hold one.
|
|
94
|
+
|
|
95
|
+
What whence does guarantee is that a fresh `Config.load(...)` sees the current
|
|
96
|
+
state of the world, including through a **Kubernetes ConfigMap's `..data`
|
|
97
|
+
symlink**. kubelet republishes by pointing that symlink at a new directory, and
|
|
98
|
+
an inotify watch on the config *file* is bound to the old inode and goes
|
|
99
|
+
permanently deaf; re-reading resolves the symlink afresh. That is covered by a
|
|
100
|
+
test against real symlinks on a real Linux filesystem (`make test-docker`).
|
|
101
|
+
|
|
102
|
+
If you need a running process to pick up a change, call `Config.load(...)` again
|
|
103
|
+
on a schedule you own and swap your own pointer. It is a handful of lines, it
|
|
104
|
+
lives where your concurrency model already is, and whence stays out of it.
|
|
105
|
+
|
|
106
|
+
## Cross-platform
|
|
107
|
+
|
|
108
|
+
Config directories follow each platform's own convention: XDG on Linux,
|
|
109
|
+
`~/Library/Application Support` on macOS (plus XDG when you set it explicitly),
|
|
110
|
+
`%APPDATA%` and `%LOCALAPPDATA%` on Windows. Case-insensitive filesystems do not
|
|
111
|
+
produce phantom ambiguities, CRLF files parse, and every file is read as UTF-8
|
|
112
|
+
regardless of the platform's default encoding.
|
|
113
|
+
|
|
114
|
+
## Contributing
|
|
115
|
+
|
|
116
|
+
Pull requests are welcome. Fork the repository, branch off `main`, and open the PR against
|
|
117
|
+
`main`. CI runs on every pull request and every job is required, so run `make all` before
|
|
118
|
+
pushing: it is the local mirror of the CI gate, and a green run here is the cheapest way to
|
|
119
|
+
avoid a red one there.
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
make install # once: sync the environment, install the pre-commit hooks
|
|
123
|
+
make all # lint, typecheck, coverage, docs, build
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The pre-commit hooks cover ruff and the lockfile on the way in; mypy is deliberately not among
|
|
127
|
+
them and is gated in CI instead, because it is slow enough that people start reaching for
|
|
128
|
+
`--no-verify`.
|
|
129
|
+
|
|
130
|
+
What CI adds on top of `make all`:
|
|
131
|
+
|
|
132
|
+
| Job | What it catches |
|
|
133
|
+
| --- | --- |
|
|
134
|
+
| `test` | The real matrix: Linux, macOS and Windows, at both ends of the supported Python range |
|
|
135
|
+
| `encoding` | Any `open()` or `read_text()` missing an explicit `encoding=` |
|
|
136
|
+
| `minimums` | Declared floors that are only ever tested at their latest versions |
|
|
137
|
+
| `no-deps` | A runtime import that quietly breaks the zero-dependency install |
|
|
138
|
+
| `build` | Broken packaging, a missing `py.typed`, a wheel that does not import |
|
|
139
|
+
|
|
140
|
+
The Python 3.15 leg is advisory until 3.15.0 ships; everything else must be green.
|
|
141
|
+
|
|
142
|
+
### What a reviewable PR looks like
|
|
143
|
+
|
|
144
|
+
- **A changelog entry** under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in the Keep a
|
|
145
|
+
Changelog sections the file already uses. The version is bumped at release time, not here.
|
|
146
|
+
- **Annotated code, tests and examples included.** mypy runs `strict = true` over `src/`,
|
|
147
|
+
`tests/` and `examples/` alike, and suppressions are specific: `# type: ignore[code]`, never
|
|
148
|
+
bare.
|
|
149
|
+
- **Docstrings on public API.** The docs site generates its reference from them, and ruff's `D`
|
|
150
|
+
rules (google convention) enforce them in `src/`.
|
|
151
|
+
- **A new runtime dependency is a decision, not a detail.** `dependencies = []` is the product
|
|
152
|
+
claim, not a preference: every format but YAML is parsed on the standard library, and YAML
|
|
153
|
+
lives behind the `whence[yaml]` extra. Open an issue before the PR. Dev tooling goes in
|
|
154
|
+
`[dependency-groups]`, which is never published.
|
|
155
|
+
- **`uv.lock` committed** whenever a dependency changes; the `uv-lock` hook fails on drift.
|
|
156
|
+
|
|
157
|
+
Two rules exist because breaking them fails late and somewhere else. **Probe, never branch on
|
|
158
|
+
`sys.platform`** — case-insensitivity is a property of a directory, not a platform, and a name
|
|
159
|
+
that can be created is not always a name that round-trips. And **pass `encoding=` explicitly on
|
|
160
|
+
every read**: without it a bug appears only on a Windows code page, only for non-ASCII content,
|
|
161
|
+
and only in someone else's CI, which is what `make encoding` exists to prevent.
|
|
162
|
+
|
|
163
|
+
If a change touches mounts, musl, a read-only root or a non-UTF-8 locale, `make test-docker`
|
|
164
|
+
runs the six Linux scenarios CI cannot reproduce. Tests that need a real mount carry the
|
|
165
|
+
`container` marker and are excluded from the default run.
|
|
166
|
+
|
|
167
|
+
Anything large enough to have a design is worth an issue before the PR; small fixes can go
|
|
168
|
+
straight to one.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
MIT
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# whence by example
|
|
2
|
+
|
|
3
|
+
Eight runnable scripts, each adding one idea to the one before it. Read them in
|
|
4
|
+
order and you have seen the whole library; run them and you have seen it work.
|
|
5
|
+
|
|
6
|
+
| | Script | What it adds |
|
|
7
|
+
| --- | --- | --- |
|
|
8
|
+
| 1 | `01_hello.py` | reading, defaults, coercion, and where a value came from -- no files at all |
|
|
9
|
+
| 2 | `02_files.py` | a real TOML file, discovered rather than named, plus interpolation |
|
|
10
|
+
| 3 | `03_typed.py` | binding onto frozen dataclasses, and what a typo'd key looks like |
|
|
11
|
+
| 4 | `04_layers.py` | the whole precedence stack: cli, env, `.env`, two file roots, defaults |
|
|
12
|
+
| 5 | `05_profiles.py` | `demo.prod.toml` overlays, and profile groups |
|
|
13
|
+
| 6 | `06_secrets.py` | a secrets directory, `Secret`, and the reveal gate |
|
|
14
|
+
| 7 | `07_injection.py` | `@from_config`: a function that declares what it needs |
|
|
15
|
+
| 8 | `08_advanced.py` | `RelativePath`, a source you wrote, `with_fallback`, discovery knobs |
|
|
16
|
+
|
|
17
|
+
## Running them
|
|
18
|
+
|
|
19
|
+
Every script imports `whence` and the standard library, nothing else, and finds
|
|
20
|
+
its data files relative to its own path -- so it behaves the same from any
|
|
21
|
+
working directory, under either installer.
|
|
22
|
+
|
|
23
|
+
```console
|
|
24
|
+
# from a clone of this repository
|
|
25
|
+
$ uv run python examples/01_hello.py
|
|
26
|
+
|
|
27
|
+
# or with pip, in any virtual environment
|
|
28
|
+
$ python -m venv .venv && source .venv/bin/activate
|
|
29
|
+
$ pip install whence
|
|
30
|
+
$ python examples/01_hello.py
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
There is no YAML in the examples on purpose: they run on a bare
|
|
34
|
+
`pip install whence`, with no extras. `pip install whence[yaml]` adds the one
|
|
35
|
+
format that needs a third-party parser.
|
|
36
|
+
|
|
37
|
+
## The data they read
|
|
38
|
+
|
|
39
|
+
| Path | The layer it is |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| `config/demo.toml` | the base file: interpolation, a `${...:-default}`, an optional `${?...}`, an `${env:...}`, and values that coerce to `bool`, `timedelta` and `list` |
|
|
42
|
+
| `config/demo.prod.toml` | a profile overlay, carrying only what differs |
|
|
43
|
+
| `config/base/demo.properties` | a **second, lower search root**, in a hand-scanned format that keeps exact `line:column` |
|
|
44
|
+
| `config/ca.crt` | the target of a `RelativePath`, resolved against its own config file |
|
|
45
|
+
| `demo.env` | a dotenv file, named `demo.env` because this repository gitignores `.env` |
|
|
46
|
+
| `secrets/db.password` | a key-per-file secrets directory, which outranks every config file |
|
|
47
|
+
|
|
48
|
+
`demo` is the application name *and* the file stem: it is what makes
|
|
49
|
+
`demo.toml`, `demo.prod.toml`, the `DEMO_` environment prefix, `$DEMO_CONFIG`,
|
|
50
|
+
`$DEMO_PROFILES` and `[tool.demo]` all line up. Each of those has its own
|
|
51
|
+
override on `Discovery` if you need them decoupled.
|
|
52
|
+
|
|
53
|
+
## Things to try
|
|
54
|
+
|
|
55
|
+
Example 4 is the one that reads the real environment and the real command line.
|
|
56
|
+
|
|
57
|
+
```console
|
|
58
|
+
$ cd examples
|
|
59
|
+
|
|
60
|
+
# the environment outranks every file; __ nests, so this is db.port
|
|
61
|
+
$ DEMO_DB__PORT=6543 python 04_layers.py
|
|
62
|
+
|
|
63
|
+
# the command line outranks even that
|
|
64
|
+
$ python 04_layers.py --set db.host=from-the-cli
|
|
65
|
+
|
|
66
|
+
# an ${env:...} placeholder reads a variable with no prefix at all
|
|
67
|
+
$ AWS_REGION=us-east-1 python 04_layers.py
|
|
68
|
+
|
|
69
|
+
# the profile switch, from outside the process
|
|
70
|
+
$ DEMO_PROFILES=prod python 05_profiles.py
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Two failures are worth causing on purpose.
|
|
74
|
+
|
|
75
|
+
**A typo'd key.** Add `pool_sze = 1` under `[db]` in `config/demo.toml` and run
|
|
76
|
+
example 3 or 4:
|
|
77
|
+
|
|
78
|
+
```text
|
|
79
|
+
BindError: 1 error
|
|
80
|
+
|
|
81
|
+
Property: db.pool_sze
|
|
82
|
+
Value: 1
|
|
83
|
+
Origin: .../config/demo.toml
|
|
84
|
+
Reason: no such setting - did you mean 'db.pool_size'?
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Two formats in one directory.** `cp config/demo.toml config/demo.json` -- the
|
|
88
|
+
JSON will not parse, but discovery fails first, and says what to do about it:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
AmbiguousConfigError: .../config holds more than one demo configuration file
|
|
92
|
+
(demo.toml, demo.json); remove one, or narrow `formats` to say which wins
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A preference order would have quietly picked one. Delete the `.json` again when
|
|
96
|
+
you are done.
|
|
97
|
+
|
|
98
|
+
## The CLI, without any of them
|
|
99
|
+
|
|
100
|
+
The bundled `whence` command reads the same files with no application running.
|
|
101
|
+
It uses whence's *default* discovery, which searches the current directory
|
|
102
|
+
rather than `config/`, so point it at the files one of two ways:
|
|
103
|
+
|
|
104
|
+
```console
|
|
105
|
+
$ cd examples
|
|
106
|
+
|
|
107
|
+
# step 2 of discovery: name the file outright
|
|
108
|
+
$ DEMO_CONFIG=config/demo.toml whence demo explain db.host
|
|
109
|
+
|
|
110
|
+
# or just stand in the directory the files are in
|
|
111
|
+
$ cd config
|
|
112
|
+
$ whence demo explain db.host --profile prod
|
|
113
|
+
$ whence demo dump --json
|
|
114
|
+
$ whence demo discovery # every step, including the ones that found nothing
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## What these examples do not reach
|
|
118
|
+
|
|
119
|
+
Honest scope, so you know where to look next rather than assuming it is all here.
|
|
120
|
+
|
|
121
|
+
| Not exercised | Where it lives |
|
|
122
|
+
| --- | --- |
|
|
123
|
+
| YAML, JSON, XML files | `Discovery(formats=...)` -- the same loader machinery as the two formats used here |
|
|
124
|
+
| pydantic models | `@settings` on a `BaseModel` instead of a dataclass; same `load_settings` call |
|
|
125
|
+
| `search_parents`, `boundary`, `mode="first"`, `file=` | fields on `Discovery`; example 8 names each and exercises `on_missing` |
|
|
126
|
+
| `Binder`, `Problem`, `render_problems` | the binding API underneath `bind`, for reporting problems yourself |
|
|
127
|
+
| Kubernetes ConfigMap symlink swaps | covered by the container test suite (`make test-docker`), not by an example |
|