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.
Files changed (60) hide show
  1. whence-1.0.0/.gitignore +66 -0
  2. whence-1.0.0/CHANGELOG.md +57 -0
  3. whence-1.0.0/LICENSE +21 -0
  4. whence-1.0.0/PKG-INFO +201 -0
  5. whence-1.0.0/README.md +172 -0
  6. whence-1.0.0/docker/secrets/README.md +2 -0
  7. whence-1.0.0/examples/README.md +127 -0
  8. whence-1.0.0/pyproject.toml +208 -0
  9. whence-1.0.0/src/whence/__init__.py +121 -0
  10. whence-1.0.0/src/whence/_platform.py +196 -0
  11. whence-1.0.0/src/whence/binding/__init__.py +223 -0
  12. whence-1.0.0/src/whence/binding/_dataclasses.py +144 -0
  13. whence-1.0.0/src/whence/binding/_pydantic.py +120 -0
  14. whence-1.0.0/src/whence/binding/coerce.py +220 -0
  15. whence-1.0.0/src/whence/chain.py +176 -0
  16. whence-1.0.0/src/whence/cli.py +83 -0
  17. whence-1.0.0/src/whence/config.py +405 -0
  18. whence-1.0.0/src/whence/decorators.py +371 -0
  19. whence-1.0.0/src/whence/discovery.py +426 -0
  20. whence-1.0.0/src/whence/errors.py +68 -0
  21. whence-1.0.0/src/whence/formats/__init__.py +90 -0
  22. whence-1.0.0/src/whence/formats/json.py +40 -0
  23. whence-1.0.0/src/whence/formats/properties.py +105 -0
  24. whence-1.0.0/src/whence/formats/toml.py +37 -0
  25. whence-1.0.0/src/whence/formats/xml.py +90 -0
  26. whence-1.0.0/src/whence/formats/yaml.py +73 -0
  27. whence-1.0.0/src/whence/interpolate.py +180 -0
  28. whence-1.0.0/src/whence/keys.py +184 -0
  29. whence-1.0.0/src/whence/origin.py +166 -0
  30. whence-1.0.0/src/whence/profiles.py +93 -0
  31. whence-1.0.0/src/whence/py.typed +0 -0
  32. whence-1.0.0/src/whence/secret.py +148 -0
  33. whence-1.0.0/src/whence/sources/__init__.py +48 -0
  34. whence-1.0.0/src/whence/sources/argv.py +84 -0
  35. whence-1.0.0/src/whence/sources/dotenv.py +175 -0
  36. whence-1.0.0/src/whence/sources/env.py +128 -0
  37. whence-1.0.0/src/whence/sources/files.py +68 -0
  38. whence-1.0.0/src/whence/sources/mapping.py +51 -0
  39. whence-1.0.0/src/whence/sources/secrets.py +60 -0
  40. whence-1.0.0/src/whence/tree.py +122 -0
  41. whence-1.0.0/tests/conftest.py +32 -0
  42. whence-1.0.0/tests/test_whence_binding.py +276 -0
  43. whence-1.0.0/tests/test_whence_chain.py +66 -0
  44. whence-1.0.0/tests/test_whence_cli.py +76 -0
  45. whence-1.0.0/tests/test_whence_config.py +149 -0
  46. whence-1.0.0/tests/test_whence_container.py +134 -0
  47. whence-1.0.0/tests/test_whence_decorators.py +298 -0
  48. whence-1.0.0/tests/test_whence_discovery.py +194 -0
  49. whence-1.0.0/tests/test_whence_e2e.py +462 -0
  50. whence-1.0.0/tests/test_whence_formats.py +125 -0
  51. whence-1.0.0/tests/test_whence_interpolate.py +97 -0
  52. whence-1.0.0/tests/test_whence_keys.py +71 -0
  53. whence-1.0.0/tests/test_whence_origin.py +51 -0
  54. whence-1.0.0/tests/test_whence_platform.py +104 -0
  55. whence-1.0.0/tests/test_whence_precedence.py +88 -0
  56. whence-1.0.0/tests/test_whence_profiles.py +68 -0
  57. whence-1.0.0/tests/test_whence_secrets.py +103 -0
  58. whence-1.0.0/tests/test_whence_smoke.py +35 -0
  59. whence-1.0.0/tests/test_whence_sources.py +248 -0
  60. whence-1.0.0/tests/test_whence_tree.py +41 -0
@@ -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,2 @@
1
+ Fixture secrets for the container scenarios. These are fake, committed on
2
+ purpose, and exist so `docker compose` can mount a real `/run/secrets`.
@@ -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 |