Skip to content

API reference

whence

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.

>>> from whence import Config
>>> cfg = Config.from_mapping({"db": {"host": "localhost"}})
>>> cfg.get("db.host")
'localhost'

Loading is synchronous and happens once, before the application runs: there is no event loop to protect at that point, so there is nothing for an await to yield to. A source that reaches the network simply blocks the startup it is already part of.

The public API is everything listed in __all__; anything else is internal and may change without a major version bump.

Injected module-attribute

Injected = _Inject()

Annotation metadata: bind this parameter's own type from configuration.

Annotated[Db, Injected] binds Db from the subtree its @settings prefix names. Binding always happens, so a default on such a parameter would be unreachable -- @from_config rejects one at decoration rather than ignoring it.

MASK module-attribute

MASK = '********'

What a redacted value renders as.

Binder

Bases: Protocol

Turns a subtree of resolved configuration into a typed object.

Source code in src/whence/binding/__init__.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
class Binder(Protocol):
    """Turns a subtree of resolved configuration into a typed object."""

    def supports(self, target: type) -> bool:
        """Report whether this binder handles the target.

        Args:
            target: The schema class.

        Returns:
            True when it does.
        """
        ...

    def declared(self, target: type, prefix: KeyPath = ()) -> set[KeyPath]:
        """List the key paths the target declares.

        Args:
            target: The schema class.
            prefix: The path it sits at.

        Returns:
            Every declared key path.
        """
        ...

    def bind(
        self,
        target: type,
        values: Mapping[KeyPath, Tracked],
        prefix: KeyPath = (),
        problems: list[Any] | None = None,
    ) -> Any:
        """Construct the target.

        Args:
            target: The schema class.
            values: Flat resolved values.
            prefix: The subtree to read.
            problems: A list to append problems to.

        Returns:
            The instance, or ``None`` on failure.
        """
        ...

supports

supports(target: type) -> bool

Report whether this binder handles the target.

Parameters:

Name Type Description Default
target type

The schema class.

required

Returns:

Type Description
bool

True when it does.

Source code in src/whence/binding/__init__.py
 99
100
101
102
103
104
105
106
107
108
def supports(self, target: type) -> bool:
    """Report whether this binder handles the target.

    Args:
        target: The schema class.

    Returns:
        True when it does.
    """
    ...

declared

declared(
    target: type, prefix: KeyPath = ()
) -> set[KeyPath]

List the key paths the target declares.

Parameters:

Name Type Description Default
target type

The schema class.

required
prefix KeyPath

The path it sits at.

()

Returns:

Type Description
set[KeyPath]

Every declared key path.

Source code in src/whence/binding/__init__.py
110
111
112
113
114
115
116
117
118
119
120
def declared(self, target: type, prefix: KeyPath = ()) -> set[KeyPath]:
    """List the key paths the target declares.

    Args:
        target: The schema class.
        prefix: The path it sits at.

    Returns:
        Every declared key path.
    """
    ...

bind

bind(
    target: type,
    values: Mapping[KeyPath, Tracked],
    prefix: KeyPath = (),
    problems: list[Any] | None = None,
) -> Any

Construct the target.

Parameters:

Name Type Description Default
target type

The schema class.

required
values Mapping[KeyPath, Tracked]

Flat resolved values.

required
prefix KeyPath

The subtree to read.

()
problems list[Any] | None

A list to append problems to.

None

Returns:

Type Description
Any

The instance, or None on failure.

Source code in src/whence/binding/__init__.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
def bind(
    self,
    target: type,
    values: Mapping[KeyPath, Tracked],
    prefix: KeyPath = (),
    problems: list[Any] | None = None,
) -> Any:
    """Construct the target.

    Args:
        target: The schema class.
        values: Flat resolved values.
        prefix: The subtree to read.
        problems: A list to append problems to.

    Returns:
        The instance, or ``None`` on failure.
    """
    ...

Problem dataclass

One thing wrong with the configuration.

Attributes:

Name Type Description
key str

The dotted key.

value Any

The offending value, redacted before rendering.

origin Origin | None

Where the value came from.

reason str

What is wrong with it.

shadowed tuple[str, ...]

Entries this value overrode, for context.

Source code in src/whence/binding/__init__.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@dataclass(frozen=True, slots=True)
class Problem:
    """One thing wrong with the configuration.

    Attributes:
        key: The dotted key.
        value: The offending value, redacted before rendering.
        origin: Where the value came from.
        reason: What is wrong with it.
        shadowed: Entries this value overrode, for context.
    """

    key: str
    value: Any
    origin: Origin | None
    reason: str
    shadowed: tuple[str, ...] = ()

SourceChain

A mutable, ordered collection of named sources, highest precedence first.

Source code in src/whence/chain.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
class SourceChain:
    """A mutable, ordered collection of named sources, highest precedence first."""

    def __init__(self, sources: Sequence[Source] = ()) -> None:
        """Build a chain.

        Args:
            sources: Sources in precedence order, highest first.
        """
        self._sources: list[Source] = list(sources)

    def __iter__(self) -> Iterator[Source]:
        """Iterate sources highest precedence first."""
        return iter(self._sources)

    def __len__(self) -> int:
        """Return the number of sources."""
        return len(self._sources)

    def __contains__(self, name: object) -> bool:
        """Report whether a source with this name is present."""
        return any(self._name(s) == name for s in self._sources)

    @staticmethod
    def _name(source: Source) -> str:
        """Read a source's name."""
        return str(getattr(source, "name", type(source).__name__))

    def names(self) -> tuple[str, ...]:
        """List source names, highest precedence first.

        Returns:
            The names.
        """
        return tuple(self._name(s) for s in self._sources)

    def _index(self, name: str) -> int:
        """Find a source by name.

        Raises:
            ConfigError: If no source carries that name.
        """
        for i, source in enumerate(self._sources):
            if self._name(source) == name:
                return i
        msg = f"no source named {name!r}; the chain holds {', '.join(self.names()) or '<nothing>'}"
        raise ConfigError(msg)

    def add_first(self, source: Source) -> "SourceChain":
        """Insert a source at the highest precedence.

        Args:
            source: The source.

        Returns:
            This chain, for chaining.
        """
        self._sources.insert(0, source)
        return self

    def add_last(self, source: Source) -> "SourceChain":
        """Append a source at the lowest precedence.

        Args:
            source: The source.

        Returns:
            This chain, for chaining.
        """
        self._sources.append(source)
        return self

    def add_before(self, relative: str, source: Source) -> "SourceChain":
        """Insert a source immediately above a named one.

        Args:
            relative: The name to insert above.
            source: The source.

        Returns:
            This chain, for chaining.
        """
        self._sources.insert(self._index(relative), source)
        return self

    def add_after(self, relative: str, source: Source) -> "SourceChain":
        """Insert a source immediately below a named one.

        Args:
            relative: The name to insert below.
            source: The source.

        Returns:
            This chain, for chaining.
        """
        self._sources.insert(self._index(relative) + 1, source)
        return self

    def replace(self, name: str, source: Source) -> Source:
        """Swap a source in place, keeping its precedence.

        This is how a source is decorated rather than displaced -- wrapping the
        environment source to decrypt values, for instance.

        Args:
            name: The source to replace.
            source: The replacement.

        Returns:
            The source that was removed.
        """
        i = self._index(name)
        previous = self._sources[i]
        self._sources[i] = source
        return previous

    def remove(self, name: str) -> Source:
        """Drop a source by name.

        Args:
            name: The source to remove.

        Returns:
            The source that was removed.
        """
        return self._sources.pop(self._index(name))

    def insert_by_ordinal(self, source: Source, ordinal: int) -> "SourceChain":
        """Insert a source by integer rank, higher winning.

        Args:
            source: The source, which must carry an ``ordinal`` attribute for
                comparison against existing entries.
            ordinal: The rank.

        Returns:
            This chain, for chaining.
        """
        for i, existing in enumerate(self._sources):
            if int(getattr(existing, "ordinal", 0)) < ordinal:
                self._sources.insert(i, source)
                return self
        self._sources.append(source)
        return self

    def load(self) -> Resolved:
        """Load every source and merge the layers.

        Returns:
            The winning values, the shadow chain, and every layer consulted.
        """
        layers = [source.load() for source in self._sources]
        return resolve(layers)

__init__

__init__(sources: Sequence[Source] = ()) -> None

Build a chain.

Parameters:

Name Type Description Default
sources Sequence[Source]

Sources in precedence order, highest first.

()
Source code in src/whence/chain.py
27
28
29
30
31
32
33
def __init__(self, sources: Sequence[Source] = ()) -> None:
    """Build a chain.

    Args:
        sources: Sources in precedence order, highest first.
    """
    self._sources: list[Source] = list(sources)

__iter__

__iter__() -> Iterator[Source]

Iterate sources highest precedence first.

Source code in src/whence/chain.py
35
36
37
def __iter__(self) -> Iterator[Source]:
    """Iterate sources highest precedence first."""
    return iter(self._sources)

__len__

__len__() -> int

Return the number of sources.

Source code in src/whence/chain.py
39
40
41
def __len__(self) -> int:
    """Return the number of sources."""
    return len(self._sources)

__contains__

__contains__(name: object) -> bool

Report whether a source with this name is present.

Source code in src/whence/chain.py
43
44
45
def __contains__(self, name: object) -> bool:
    """Report whether a source with this name is present."""
    return any(self._name(s) == name for s in self._sources)

names

names() -> tuple[str, ...]

List source names, highest precedence first.

Returns:

Type Description
tuple[str, ...]

The names.

Source code in src/whence/chain.py
52
53
54
55
56
57
58
def names(self) -> tuple[str, ...]:
    """List source names, highest precedence first.

    Returns:
        The names.
    """
    return tuple(self._name(s) for s in self._sources)

add_first

add_first(source: Source) -> SourceChain

Insert a source at the highest precedence.

Parameters:

Name Type Description Default
source Source

The source.

required

Returns:

Type Description
SourceChain

This chain, for chaining.

Source code in src/whence/chain.py
72
73
74
75
76
77
78
79
80
81
82
def add_first(self, source: Source) -> "SourceChain":
    """Insert a source at the highest precedence.

    Args:
        source: The source.

    Returns:
        This chain, for chaining.
    """
    self._sources.insert(0, source)
    return self

add_last

add_last(source: Source) -> SourceChain

Append a source at the lowest precedence.

Parameters:

Name Type Description Default
source Source

The source.

required

Returns:

Type Description
SourceChain

This chain, for chaining.

Source code in src/whence/chain.py
84
85
86
87
88
89
90
91
92
93
94
def add_last(self, source: Source) -> "SourceChain":
    """Append a source at the lowest precedence.

    Args:
        source: The source.

    Returns:
        This chain, for chaining.
    """
    self._sources.append(source)
    return self

add_before

add_before(relative: str, source: Source) -> SourceChain

Insert a source immediately above a named one.

Parameters:

Name Type Description Default
relative str

The name to insert above.

required
source Source

The source.

required

Returns:

Type Description
SourceChain

This chain, for chaining.

Source code in src/whence/chain.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
def add_before(self, relative: str, source: Source) -> "SourceChain":
    """Insert a source immediately above a named one.

    Args:
        relative: The name to insert above.
        source: The source.

    Returns:
        This chain, for chaining.
    """
    self._sources.insert(self._index(relative), source)
    return self

add_after

add_after(relative: str, source: Source) -> SourceChain

Insert a source immediately below a named one.

Parameters:

Name Type Description Default
relative str

The name to insert below.

required
source Source

The source.

required

Returns:

Type Description
SourceChain

This chain, for chaining.

Source code in src/whence/chain.py
109
110
111
112
113
114
115
116
117
118
119
120
def add_after(self, relative: str, source: Source) -> "SourceChain":
    """Insert a source immediately below a named one.

    Args:
        relative: The name to insert below.
        source: The source.

    Returns:
        This chain, for chaining.
    """
    self._sources.insert(self._index(relative) + 1, source)
    return self

replace

replace(name: str, source: Source) -> Source

Swap a source in place, keeping its precedence.

This is how a source is decorated rather than displaced -- wrapping the environment source to decrypt values, for instance.

Parameters:

Name Type Description Default
name str

The source to replace.

required
source Source

The replacement.

required

Returns:

Type Description
Source

The source that was removed.

Source code in src/whence/chain.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
def replace(self, name: str, source: Source) -> Source:
    """Swap a source in place, keeping its precedence.

    This is how a source is decorated rather than displaced -- wrapping the
    environment source to decrypt values, for instance.

    Args:
        name: The source to replace.
        source: The replacement.

    Returns:
        The source that was removed.
    """
    i = self._index(name)
    previous = self._sources[i]
    self._sources[i] = source
    return previous

remove

remove(name: str) -> Source

Drop a source by name.

Parameters:

Name Type Description Default
name str

The source to remove.

required

Returns:

Type Description
Source

The source that was removed.

Source code in src/whence/chain.py
140
141
142
143
144
145
146
147
148
149
def remove(self, name: str) -> Source:
    """Drop a source by name.

    Args:
        name: The source to remove.

    Returns:
        The source that was removed.
    """
    return self._sources.pop(self._index(name))

insert_by_ordinal

insert_by_ordinal(
    source: Source, ordinal: int
) -> SourceChain

Insert a source by integer rank, higher winning.

Parameters:

Name Type Description Default
source Source

The source, which must carry an ordinal attribute for comparison against existing entries.

required
ordinal int

The rank.

required

Returns:

Type Description
SourceChain

This chain, for chaining.

Source code in src/whence/chain.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def insert_by_ordinal(self, source: Source, ordinal: int) -> "SourceChain":
    """Insert a source by integer rank, higher winning.

    Args:
        source: The source, which must carry an ``ordinal`` attribute for
            comparison against existing entries.
        ordinal: The rank.

    Returns:
        This chain, for chaining.
    """
    for i, existing in enumerate(self._sources):
        if int(getattr(existing, "ordinal", 0)) < ordinal:
            self._sources.insert(i, source)
            return self
    self._sources.append(source)
    return self

load

load() -> Resolved

Load every source and merge the layers.

Returns:

Type Description
Resolved

The winning values, the shadow chain, and every layer consulted.

Source code in src/whence/chain.py
169
170
171
172
173
174
175
176
def load(self) -> Resolved:
    """Load every source and merge the layers.

    Returns:
        The winning values, the shadow chain, and every layer consulted.
    """
    layers = [source.load() for source in self._sources]
    return resolve(layers)

Config

Resolved configuration, plus the provenance of everything in it.

A Config is immutable. Reloading builds a new one and swaps it, which is the only design that cannot expose a half-updated object: .NET's in-place IOptionsMonitor fires twice per save and Go viper's watcher carries a documented data race, and both problems are properties of mutating in place.

Source code in src/whence/config.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
class Config:
    """Resolved configuration, plus the provenance of everything in it.

    A ``Config`` is immutable. Reloading builds a new one and swaps it, which is
    the only design that cannot expose a half-updated object: .NET's in-place
    ``IOptionsMonitor`` fires twice per save and Go viper's watcher carries a
    documented data race, and both problems are properties of mutating in place.
    """

    def __init__(
        self,
        resolved: Resolved,
        *,
        plan: DiscoveryPlan | None = None,
        profiles: Sequence[str] = (),
        discovery: Discovery | None = None,
    ) -> None:
        """Wrap an already-resolved merge.

        Args:
            resolved: The merged values and shadow records.
            plan: The discovery plan that produced the file sources.
            profiles: The active profiles, in increasing precedence.
            discovery: The discovery settings used.
        """
        self._resolved = resolved
        self.plan = plan
        self.profiles = tuple(profiles)
        self.discovery = discovery

    # ------------------------------------------------------------------ build

    @classmethod
    def load(
        cls,
        app: str | None = None,
        *,
        discovery: Discovery | None = None,
        profiles: Sequence[str] | None = None,
        groups: Mapping[str, Sequence[str]] | None = None,
        overrides: Mapping[str, Any] | None = None,
        argv: Sequence[str] | None = None,
        defaults: Mapping[str, Any] | None = None,
        environ: Mapping[str, str] | None = None,
        aliases: Mapping[str, str] | None = None,
        cwd: Path | None = None,
        platform: Platform | None = None,
        expand: bool = True,
    ) -> "Config":
        """Discover, load and merge every source.

        Args:
            app: The application name; shorthand for ``Discovery(app=...)``.
            discovery: Full discovery settings, if ``app`` is not enough.
            profiles: Active profiles; falls back to the profiles variable.
            groups: Profile group definitions.
            overrides: Values that outrank every source.
            argv: Command-line arguments to scan for ``--set key=value``. Pass
                ``()`` to disable, or leave as ``None`` to read ``sys.argv``.
            defaults: Values every source outranks.
            environ: The environment to read; defaults to ``os.environ``.
            aliases: An explicit map from variable name to dotted key, for the
                cases convention does not cover. An explicit, greppable table
                beats implicit name mangling.
            cwd: The directory discovery starts from.
            platform: Override the platform, for cross-platform tests.
            expand: Whether to expand ``${...}`` placeholders.

        Returns:
            The resolved configuration.

        Raises:
            ConfigError: If discovery or any source fails.
        """
        settings = discovery or Discovery(app=app or "app")
        env = os.environ if environ is None else environ
        active = active_profiles(profiles, environ=env, var=settings.profiles_env, groups=groups)
        plan = settings.plan(profiles=active, environ=env, cwd=cwd, platform=platform)
        base_dir = Path.cwd() if cwd is None else cwd

        chain = SourceChain()
        if overrides:
            chain.add_last(MappingSource(overrides, name="overrides"))
        if argv is None or argv:
            chain.add_last(ArgvSource(argv))
        chain.add_last(
            EnvSource(
                settings.env_prefix,
                environ=env,
                aliases=aliases,
                # These say where to look, not what to load.
                exclude=(settings.config_var, settings.profiles_env),
            )
        )
        for path in settings.dotenv:
            base = path if path.is_absolute() else base_dir / path
            for profile in reversed(active):
                chain.add_last(
                    DotEnvSource(
                        base.with_name(f"{base.name}.{profile}"),
                        settings.env_prefix,
                        name=f"dotenv:{base.name}.{profile}",
                    )
                )
            chain.add_last(DotEnvSource(base, settings.env_prefix, name=f"dotenv:{base.name}"))
        if settings.secrets_dir is not None:
            chain.add_last(SecretsDirSource(settings.secrets_dir))
        for source in settings.file_sources(plan):
            chain.add_last(source)
        if settings.table:
            chain.add_last(PyProjectSource(base_dir / "pyproject.toml", settings.table))
        if defaults:
            chain.add_last(MappingSource(defaults, name="defaults", locator="<defaults>"))

        merged = chain.load()
        if expand:
            merged = replace(merged, values=interpolate(merged.values, environ=env))
        return cls(merged, plan=plan, profiles=active, discovery=settings)

    @classmethod
    def from_mapping(cls, data: Mapping[str, Any], *, name: str = "overrides") -> "Config":
        """Build a configuration from a single mapping, for tests and defaults.

        Args:
            data: Nested or flat configuration values.
            name: The layer name.

        Returns:
            The resolved configuration.
        """
        source = MappingSource(data, name=name)
        return cls(resolve([Layer(name, source._entries, found=bool(data))]))

    # ------------------------------------------------------------------- read

    @property
    def values(self) -> Mapping[KeyPath, Tracked]:
        """The winning tracked value for every key.

        Returns:
            Flat key paths to tracked values.
        """
        return self._resolved.values

    def _tracked(self, key: str) -> Tracked | None:
        """Look a key up by its canonical path."""
        return self._resolved.values.get(canonical(key))

    def __contains__(self, key: str) -> bool:
        """Report whether a key has a value."""
        return canonical(key) in self._resolved.values

    def __getitem__(self, key: str) -> Any:
        """Return a value, raising if it is absent."""
        return self.require(key)

    def get(self, key: str, default: Any = None, *, type_: Any = None) -> Any:
        """Return a value, or a default.

        The second positional argument is the default, as it is on ``dict`` and
        ``os.environ``: ``cfg.get("db.port", 5432)`` means what it looks like.
        Coercion is the named option, because asking for it is the rarer case.

        Args:
            key: A dotted key.
            default: Returned when the key is absent. Never coerced -- it is
                already a Python value.
            type_: Coerce to this type when given.

        Returns:
            The value, coerced if asked.
        """
        tracked = self._tracked(key)
        if tracked is None:
            return default
        if type_ is None:
            return tracked.value
        return coerce(tracked.value, type_)

    def require(self, key: str, *, type_: Any = None) -> Any:
        """Return a value, raising a helpful error if it is absent.

        Args:
            key: A dotted key.
            type_: Coerce to this type when given.

        Returns:
            The value.

        Raises:
            MissingKeyError: If nothing supplies the key.
        """
        value = self.get(key, _MISSING, type_=type_)
        if value is _MISSING:
            consulted = ", ".join(layer.name for layer in self._resolved.layers)
            msg = f"{key!r} is not set. Consulted: {consulted or '<no sources>'}"
            raise MissingKeyError(msg)
        return value

    def origin(self, key: str) -> Origin | None:
        """Return where a value came from.

        Args:
            key: A dotted key.

        Returns:
            Its origin, or ``None`` if the key is absent.
        """
        tracked = self._tracked(key)
        return None if tracked is None else tracked.origin

    # --------------------------------------------------------------- diagnose

    def explain(self, key: str) -> str:
        """Describe where a value came from and what it overrode.

        Every source is listed, including the ones that had nothing to say,
        because absence is information: "the environment variable is not set" is
        usually the answer someone is looking for.

        Args:
            key: A dotted key.

        Returns:
            A multi-line report.
        """
        path = canonical(key)
        dotted = join(path)
        winner = self._resolved.values.get(path)
        if winner is None:
            consulted = [
                f"      {layer.name:<30} - {'no value' if layer.found else 'not found'}"
                for layer in self._resolved.layers
            ]
            return "\n".join([f"{dotted} is not set", "  consulted:", *consulted])

        lines = [
            f"{dotted} = {sanitize(dotted, winner.value)!r}",
            f"  <- {winner.origin}",
        ]
        losers = dict(self._resolved.shadowed.get(path, ()))
        below: list[str] = []
        seen_winner = False
        for layer in self._resolved.layers:
            if not seen_winner and layer.entries.get(path) is winner:
                seen_winner = True
                continue
            tracked = losers.get(layer.name)
            if tracked is not None:
                below.append(f"      {layer.name:<30} = {sanitize(dotted, tracked.value)!r}")
            else:
                below.append(
                    f"      {layer.name:<30} - {'not set' if layer.found else 'not found'}"
                )
        # A single-source configuration has nothing under the winner, and a bare
        # "shadowed:" with no list under it reads as a truncated report.
        if below:
            lines.append("  shadowed:")
            lines.extend(below)
        return "\n".join(lines)

    def discovery_report(self) -> str:
        """Describe how configuration files were searched for.

        Returns:
            A multi-line report, or a note that discovery was not run.
        """
        if self.plan is None or self.discovery is None:
            return "no discovery was run for this configuration"
        head = (
            f"app={self.discovery.app}  prefix={self.discovery.env_prefix}  "
            f"profiles=[{', '.join(self.profiles)}]  mode={self.discovery.mode}"
        )
        return f"{head}\n{self.plan.render()}"

    def dump(self, *, reveal: bool = False) -> dict[str, Any]:
        """Return every value, redacted unless asked otherwise.

        Args:
            reveal: Skip redaction. Only ever pass this deliberately.

        Returns:
            Dotted keys to values.
        """
        out: dict[str, Any] = {}
        for path, tracked in sorted(self._resolved.values.items()):
            dotted = join(path)
            out[dotted] = tracked.value if reveal else sanitize(dotted, tracked.value)
        return out

    def as_dict(self) -> dict[str, Any]:
        """Return the resolved values as a plain nested dictionary.

        Provenance is dropped, which is the point: this is the shape to hand to
        something that only wants the data, such as a validator whence has no
        binder for.

        Returns:
            Nested plain Python values.
        """
        return unflatten({path: tracked.value for path, tracked in self._resolved.values.items()})

    def origins(self) -> dict[str, Origin]:
        """Return the origin of every value.

        Returns:
            Dotted keys to origins.
        """
        return {join(path): tracked.origin for path, tracked in self._resolved.values.items()}

    # ------------------------------------------------------------------ shape

    def bind(self, target: type, *, prefix: str | KeyPath = (), strict: bool | None = None) -> Any:
        """Bind a subtree onto a typed schema.

        Args:
            target: A dataclass, or a pydantic model when pydantic is installed.
            prefix: The subtree to read; defaults to the schema's own
                ``@settings`` prefix when it has one.
            strict: Whether a key nothing declared is an error. Defaults to
                whatever ``@settings`` recorded on the class.

        Returns:
            An instance of ``target``.

        Raises:
            BindError: If anything failed, listing every problem at once.
        """
        declared = getattr(target, "__whence_prefix__", None)
        chosen: str | KeyPath | Sequence[str] = prefix
        if prefix == () and declared is not None:
            chosen = declared
        # An empty prefix means the root, not an empty key segment: `@settings()`
        # with no argument binds the whole tree.
        if not isinstance(chosen, str):
            path: KeyPath = tuple(chosen)
        elif chosen:
            path = canonical(chosen)
        else:
            path = ()
        if strict is None:
            strict = bool(getattr(target, "__whence_strict__", True))
        return _binding.bind(
            target,
            self._resolved.values,
            prefix=path,
            shadowed=self._resolved.shadowed,
            strict=strict,
        )

    def with_fallback(self, other: "Config") -> "Config":
        """Layer another configuration underneath this one.

        This is how a library ships defaults that an application can override.
        HOCON calls it ``withFallback``, and figment separates it from ``merge``
        because "replace" and "fill in the gaps" are genuinely different
        operations. whence has both: the source chain merges, and this fills
        gaps -- a key already set here keeps its value and its origin.

        Args:
            other: The configuration to fall back to.

        Returns:
            A new configuration.
        """
        merged = resolve([*self._resolved.layers, *other._resolved.layers])
        return Config(merged, plan=self.plan, profiles=self.profiles, discovery=self.discovery)

    def __repr__(self) -> str:
        """Summarise without printing any values."""
        return (
            f"Config({len(self._resolved.values)} keys from "
            f"{len(self._resolved.layers)} sources, profiles={list(self.profiles)})"
        )

values property

values: Mapping[KeyPath, Tracked]

The winning tracked value for every key.

Returns:

Type Description
Mapping[KeyPath, Tracked]

Flat key paths to tracked values.

__init__

__init__(
    resolved: Resolved,
    *,
    plan: DiscoveryPlan | None = None,
    profiles: Sequence[str] = (),
    discovery: Discovery | None = None,
) -> None

Wrap an already-resolved merge.

Parameters:

Name Type Description Default
resolved Resolved

The merged values and shadow records.

required
plan DiscoveryPlan | None

The discovery plan that produced the file sources.

None
profiles Sequence[str]

The active profiles, in increasing precedence.

()
discovery Discovery | None

The discovery settings used.

None
Source code in src/whence/config.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def __init__(
    self,
    resolved: Resolved,
    *,
    plan: DiscoveryPlan | None = None,
    profiles: Sequence[str] = (),
    discovery: Discovery | None = None,
) -> None:
    """Wrap an already-resolved merge.

    Args:
        resolved: The merged values and shadow records.
        plan: The discovery plan that produced the file sources.
        profiles: The active profiles, in increasing precedence.
        discovery: The discovery settings used.
    """
    self._resolved = resolved
    self.plan = plan
    self.profiles = tuple(profiles)
    self.discovery = discovery

load classmethod

load(
    app: str | None = None,
    *,
    discovery: Discovery | None = None,
    profiles: Sequence[str] | None = None,
    groups: Mapping[str, Sequence[str]] | None = None,
    overrides: Mapping[str, Any] | None = None,
    argv: Sequence[str] | None = None,
    defaults: Mapping[str, Any] | None = None,
    environ: Mapping[str, str] | None = None,
    aliases: Mapping[str, str] | None = None,
    cwd: Path | None = None,
    platform: Platform | None = None,
    expand: bool = True,
) -> Config

Discover, load and merge every source.

Parameters:

Name Type Description Default
app str | None

The application name; shorthand for Discovery(app=...).

None
discovery Discovery | None

Full discovery settings, if app is not enough.

None
profiles Sequence[str] | None

Active profiles; falls back to the profiles variable.

None
groups Mapping[str, Sequence[str]] | None

Profile group definitions.

None
overrides Mapping[str, Any] | None

Values that outrank every source.

None
argv Sequence[str] | None

Command-line arguments to scan for --set key=value. Pass () to disable, or leave as None to read sys.argv.

None
defaults Mapping[str, Any] | None

Values every source outranks.

None
environ Mapping[str, str] | None

The environment to read; defaults to os.environ.

None
aliases Mapping[str, str] | None

An explicit map from variable name to dotted key, for the cases convention does not cover. An explicit, greppable table beats implicit name mangling.

None
cwd Path | None

The directory discovery starts from.

None
platform Platform | None

Override the platform, for cross-platform tests.

None
expand bool

Whether to expand ${...} placeholders.

True

Returns:

Type Description
Config

The resolved configuration.

Raises:

Type Description
ConfigError

If discovery or any source fails.

Source code in src/whence/config.py
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
@classmethod
def load(
    cls,
    app: str | None = None,
    *,
    discovery: Discovery | None = None,
    profiles: Sequence[str] | None = None,
    groups: Mapping[str, Sequence[str]] | None = None,
    overrides: Mapping[str, Any] | None = None,
    argv: Sequence[str] | None = None,
    defaults: Mapping[str, Any] | None = None,
    environ: Mapping[str, str] | None = None,
    aliases: Mapping[str, str] | None = None,
    cwd: Path | None = None,
    platform: Platform | None = None,
    expand: bool = True,
) -> "Config":
    """Discover, load and merge every source.

    Args:
        app: The application name; shorthand for ``Discovery(app=...)``.
        discovery: Full discovery settings, if ``app`` is not enough.
        profiles: Active profiles; falls back to the profiles variable.
        groups: Profile group definitions.
        overrides: Values that outrank every source.
        argv: Command-line arguments to scan for ``--set key=value``. Pass
            ``()`` to disable, or leave as ``None`` to read ``sys.argv``.
        defaults: Values every source outranks.
        environ: The environment to read; defaults to ``os.environ``.
        aliases: An explicit map from variable name to dotted key, for the
            cases convention does not cover. An explicit, greppable table
            beats implicit name mangling.
        cwd: The directory discovery starts from.
        platform: Override the platform, for cross-platform tests.
        expand: Whether to expand ``${...}`` placeholders.

    Returns:
        The resolved configuration.

    Raises:
        ConfigError: If discovery or any source fails.
    """
    settings = discovery or Discovery(app=app or "app")
    env = os.environ if environ is None else environ
    active = active_profiles(profiles, environ=env, var=settings.profiles_env, groups=groups)
    plan = settings.plan(profiles=active, environ=env, cwd=cwd, platform=platform)
    base_dir = Path.cwd() if cwd is None else cwd

    chain = SourceChain()
    if overrides:
        chain.add_last(MappingSource(overrides, name="overrides"))
    if argv is None or argv:
        chain.add_last(ArgvSource(argv))
    chain.add_last(
        EnvSource(
            settings.env_prefix,
            environ=env,
            aliases=aliases,
            # These say where to look, not what to load.
            exclude=(settings.config_var, settings.profiles_env),
        )
    )
    for path in settings.dotenv:
        base = path if path.is_absolute() else base_dir / path
        for profile in reversed(active):
            chain.add_last(
                DotEnvSource(
                    base.with_name(f"{base.name}.{profile}"),
                    settings.env_prefix,
                    name=f"dotenv:{base.name}.{profile}",
                )
            )
        chain.add_last(DotEnvSource(base, settings.env_prefix, name=f"dotenv:{base.name}"))
    if settings.secrets_dir is not None:
        chain.add_last(SecretsDirSource(settings.secrets_dir))
    for source in settings.file_sources(plan):
        chain.add_last(source)
    if settings.table:
        chain.add_last(PyProjectSource(base_dir / "pyproject.toml", settings.table))
    if defaults:
        chain.add_last(MappingSource(defaults, name="defaults", locator="<defaults>"))

    merged = chain.load()
    if expand:
        merged = replace(merged, values=interpolate(merged.values, environ=env))
    return cls(merged, plan=plan, profiles=active, discovery=settings)

from_mapping classmethod

from_mapping(
    data: Mapping[str, Any], *, name: str = "overrides"
) -> Config

Build a configuration from a single mapping, for tests and defaults.

Parameters:

Name Type Description Default
data Mapping[str, Any]

Nested or flat configuration values.

required
name str

The layer name.

'overrides'

Returns:

Type Description
Config

The resolved configuration.

Source code in src/whence/config.py
151
152
153
154
155
156
157
158
159
160
161
162
163
@classmethod
def from_mapping(cls, data: Mapping[str, Any], *, name: str = "overrides") -> "Config":
    """Build a configuration from a single mapping, for tests and defaults.

    Args:
        data: Nested or flat configuration values.
        name: The layer name.

    Returns:
        The resolved configuration.
    """
    source = MappingSource(data, name=name)
    return cls(resolve([Layer(name, source._entries, found=bool(data))]))

__contains__

__contains__(key: str) -> bool

Report whether a key has a value.

Source code in src/whence/config.py
180
181
182
def __contains__(self, key: str) -> bool:
    """Report whether a key has a value."""
    return canonical(key) in self._resolved.values

__getitem__

__getitem__(key: str) -> Any

Return a value, raising if it is absent.

Source code in src/whence/config.py
184
185
186
def __getitem__(self, key: str) -> Any:
    """Return a value, raising if it is absent."""
    return self.require(key)

get

get(
    key: str, default: Any = None, *, type_: Any = None
) -> Any

Return a value, or a default.

The second positional argument is the default, as it is on dict and os.environ: cfg.get("db.port", 5432) means what it looks like. Coercion is the named option, because asking for it is the rarer case.

Parameters:

Name Type Description Default
key str

A dotted key.

required
default Any

Returned when the key is absent. Never coerced -- it is already a Python value.

None
type_ Any

Coerce to this type when given.

None

Returns:

Type Description
Any

The value, coerced if asked.

Source code in src/whence/config.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
def get(self, key: str, default: Any = None, *, type_: Any = None) -> Any:
    """Return a value, or a default.

    The second positional argument is the default, as it is on ``dict`` and
    ``os.environ``: ``cfg.get("db.port", 5432)`` means what it looks like.
    Coercion is the named option, because asking for it is the rarer case.

    Args:
        key: A dotted key.
        default: Returned when the key is absent. Never coerced -- it is
            already a Python value.
        type_: Coerce to this type when given.

    Returns:
        The value, coerced if asked.
    """
    tracked = self._tracked(key)
    if tracked is None:
        return default
    if type_ is None:
        return tracked.value
    return coerce(tracked.value, type_)

require

require(key: str, *, type_: Any = None) -> Any

Return a value, raising a helpful error if it is absent.

Parameters:

Name Type Description Default
key str

A dotted key.

required
type_ Any

Coerce to this type when given.

None

Returns:

Type Description
Any

The value.

Raises:

Type Description
MissingKeyError

If nothing supplies the key.

Source code in src/whence/config.py
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
def require(self, key: str, *, type_: Any = None) -> Any:
    """Return a value, raising a helpful error if it is absent.

    Args:
        key: A dotted key.
        type_: Coerce to this type when given.

    Returns:
        The value.

    Raises:
        MissingKeyError: If nothing supplies the key.
    """
    value = self.get(key, _MISSING, type_=type_)
    if value is _MISSING:
        consulted = ", ".join(layer.name for layer in self._resolved.layers)
        msg = f"{key!r} is not set. Consulted: {consulted or '<no sources>'}"
        raise MissingKeyError(msg)
    return value

origin

origin(key: str) -> Origin | None

Return where a value came from.

Parameters:

Name Type Description Default
key str

A dotted key.

required

Returns:

Type Description
Origin | None

Its origin, or None if the key is absent.

Source code in src/whence/config.py
231
232
233
234
235
236
237
238
239
240
241
def origin(self, key: str) -> Origin | None:
    """Return where a value came from.

    Args:
        key: A dotted key.

    Returns:
        Its origin, or ``None`` if the key is absent.
    """
    tracked = self._tracked(key)
    return None if tracked is None else tracked.origin

explain

explain(key: str) -> str

Describe where a value came from and what it overrode.

Every source is listed, including the ones that had nothing to say, because absence is information: "the environment variable is not set" is usually the answer someone is looking for.

Parameters:

Name Type Description Default
key str

A dotted key.

required

Returns:

Type Description
str

A multi-line report.

Source code in src/whence/config.py
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
def explain(self, key: str) -> str:
    """Describe where a value came from and what it overrode.

    Every source is listed, including the ones that had nothing to say,
    because absence is information: "the environment variable is not set" is
    usually the answer someone is looking for.

    Args:
        key: A dotted key.

    Returns:
        A multi-line report.
    """
    path = canonical(key)
    dotted = join(path)
    winner = self._resolved.values.get(path)
    if winner is None:
        consulted = [
            f"      {layer.name:<30} - {'no value' if layer.found else 'not found'}"
            for layer in self._resolved.layers
        ]
        return "\n".join([f"{dotted} is not set", "  consulted:", *consulted])

    lines = [
        f"{dotted} = {sanitize(dotted, winner.value)!r}",
        f"  <- {winner.origin}",
    ]
    losers = dict(self._resolved.shadowed.get(path, ()))
    below: list[str] = []
    seen_winner = False
    for layer in self._resolved.layers:
        if not seen_winner and layer.entries.get(path) is winner:
            seen_winner = True
            continue
        tracked = losers.get(layer.name)
        if tracked is not None:
            below.append(f"      {layer.name:<30} = {sanitize(dotted, tracked.value)!r}")
        else:
            below.append(
                f"      {layer.name:<30} - {'not set' if layer.found else 'not found'}"
            )
    # A single-source configuration has nothing under the winner, and a bare
    # "shadowed:" with no list under it reads as a truncated report.
    if below:
        lines.append("  shadowed:")
        lines.extend(below)
    return "\n".join(lines)

discovery_report

discovery_report() -> str

Describe how configuration files were searched for.

Returns:

Type Description
str

A multi-line report, or a note that discovery was not run.

Source code in src/whence/config.py
293
294
295
296
297
298
299
300
301
302
303
304
305
def discovery_report(self) -> str:
    """Describe how configuration files were searched for.

    Returns:
        A multi-line report, or a note that discovery was not run.
    """
    if self.plan is None or self.discovery is None:
        return "no discovery was run for this configuration"
    head = (
        f"app={self.discovery.app}  prefix={self.discovery.env_prefix}  "
        f"profiles=[{', '.join(self.profiles)}]  mode={self.discovery.mode}"
    )
    return f"{head}\n{self.plan.render()}"

dump

dump(*, reveal: bool = False) -> dict[str, Any]

Return every value, redacted unless asked otherwise.

Parameters:

Name Type Description Default
reveal bool

Skip redaction. Only ever pass this deliberately.

False

Returns:

Type Description
dict[str, Any]

Dotted keys to values.

Source code in src/whence/config.py
307
308
309
310
311
312
313
314
315
316
317
318
319
320
def dump(self, *, reveal: bool = False) -> dict[str, Any]:
    """Return every value, redacted unless asked otherwise.

    Args:
        reveal: Skip redaction. Only ever pass this deliberately.

    Returns:
        Dotted keys to values.
    """
    out: dict[str, Any] = {}
    for path, tracked in sorted(self._resolved.values.items()):
        dotted = join(path)
        out[dotted] = tracked.value if reveal else sanitize(dotted, tracked.value)
    return out

as_dict

as_dict() -> dict[str, Any]

Return the resolved values as a plain nested dictionary.

Provenance is dropped, which is the point: this is the shape to hand to something that only wants the data, such as a validator whence has no binder for.

Returns:

Type Description
dict[str, Any]

Nested plain Python values.

Source code in src/whence/config.py
322
323
324
325
326
327
328
329
330
331
332
def as_dict(self) -> dict[str, Any]:
    """Return the resolved values as a plain nested dictionary.

    Provenance is dropped, which is the point: this is the shape to hand to
    something that only wants the data, such as a validator whence has no
    binder for.

    Returns:
        Nested plain Python values.
    """
    return unflatten({path: tracked.value for path, tracked in self._resolved.values.items()})

origins

origins() -> dict[str, Origin]

Return the origin of every value.

Returns:

Type Description
dict[str, Origin]

Dotted keys to origins.

Source code in src/whence/config.py
334
335
336
337
338
339
340
def origins(self) -> dict[str, Origin]:
    """Return the origin of every value.

    Returns:
        Dotted keys to origins.
    """
    return {join(path): tracked.origin for path, tracked in self._resolved.values.items()}

bind

bind(
    target: type,
    *,
    prefix: str | KeyPath = (),
    strict: bool | None = None,
) -> Any

Bind a subtree onto a typed schema.

Parameters:

Name Type Description Default
target type

A dataclass, or a pydantic model when pydantic is installed.

required
prefix str | KeyPath

The subtree to read; defaults to the schema's own @settings prefix when it has one.

()
strict bool | None

Whether a key nothing declared is an error. Defaults to whatever @settings recorded on the class.

None

Returns:

Type Description
Any

An instance of target.

Raises:

Type Description
BindError

If anything failed, listing every problem at once.

Source code in src/whence/config.py
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
def bind(self, target: type, *, prefix: str | KeyPath = (), strict: bool | None = None) -> Any:
    """Bind a subtree onto a typed schema.

    Args:
        target: A dataclass, or a pydantic model when pydantic is installed.
        prefix: The subtree to read; defaults to the schema's own
            ``@settings`` prefix when it has one.
        strict: Whether a key nothing declared is an error. Defaults to
            whatever ``@settings`` recorded on the class.

    Returns:
        An instance of ``target``.

    Raises:
        BindError: If anything failed, listing every problem at once.
    """
    declared = getattr(target, "__whence_prefix__", None)
    chosen: str | KeyPath | Sequence[str] = prefix
    if prefix == () and declared is not None:
        chosen = declared
    # An empty prefix means the root, not an empty key segment: `@settings()`
    # with no argument binds the whole tree.
    if not isinstance(chosen, str):
        path: KeyPath = tuple(chosen)
    elif chosen:
        path = canonical(chosen)
    else:
        path = ()
    if strict is None:
        strict = bool(getattr(target, "__whence_strict__", True))
    return _binding.bind(
        target,
        self._resolved.values,
        prefix=path,
        shadowed=self._resolved.shadowed,
        strict=strict,
    )

with_fallback

with_fallback(other: Config) -> Config

Layer another configuration underneath this one.

This is how a library ships defaults that an application can override. HOCON calls it withFallback, and figment separates it from merge because "replace" and "fill in the gaps" are genuinely different operations. whence has both: the source chain merges, and this fills gaps -- a key already set here keeps its value and its origin.

Parameters:

Name Type Description Default
other Config

The configuration to fall back to.

required

Returns:

Type Description
Config

A new configuration.

Source code in src/whence/config.py
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
def with_fallback(self, other: "Config") -> "Config":
    """Layer another configuration underneath this one.

    This is how a library ships defaults that an application can override.
    HOCON calls it ``withFallback``, and figment separates it from ``merge``
    because "replace" and "fill in the gaps" are genuinely different
    operations. whence has both: the source chain merges, and this fills
    gaps -- a key already set here keeps its value and its origin.

    Args:
        other: The configuration to fall back to.

    Returns:
        A new configuration.
    """
    merged = resolve([*self._resolved.layers, *other._resolved.layers])
    return Config(merged, plan=self.plan, profiles=self.profiles, discovery=self.discovery)

__repr__

__repr__() -> str

Summarise without printing any values.

Source code in src/whence/config.py
400
401
402
403
404
405
def __repr__(self) -> str:
    """Summarise without printing any values."""
    return (
        f"Config({len(self._resolved.values)} keys from "
        f"{len(self._resolved.layers)} sources, profiles={list(self.profiles)})"
    )

Value dataclass

Annotation metadata: fill this parameter from one configuration key.

Written inside :data:~typing.Annotated, so the parameter keeps both its real type and its real default::

retries: Annotated[int, Value("http.retries")] = 3

A marked parameter with no default is required: a missing key raises :class:~whence.MissingKeyError rather than passing None on.

Attributes:

Name Type Description
key str

The dotted key to read.

Source code in src/whence/decorators.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
@dataclass(frozen=True, slots=True)
class Value:
    """Annotation metadata: fill this parameter from one configuration key.

    Written inside :data:`~typing.Annotated`, so the parameter keeps both its
    real type and its real default::

        retries: Annotated[int, Value("http.retries")] = 3

    A marked parameter with no default is required: a missing key raises
    :class:`~whence.MissingKeyError` rather than passing ``None`` on.

    Attributes:
        key: The dotted key to read.
    """

    key: str

Discovery dataclass

Declarative configuration discovery.

Attributes:

Name Type Description
app str

The application name. Every other default derives from it.

file Path | None

An explicit configuration file. Wins outright, and must exist.

path tuple[Path, ...]

Search roots, highest precedence first.

formats tuple[str, ...]

Suffixes to look for, in preference order. Restricting this is how a project bans a format: a stray app.yaml is then simply never discovered, and the plan says so.

prefix str | None

The environment variable prefix. None derives MYAPP_.

env_var str | None

The variable naming an explicit config file. None derives MYAPP_CONFIG.

profiles_var str | None

The variable listing active profiles. None derives MYAPP_PROFILES.

pyproject_table str | None

The dotted table to read from pyproject.toml. None derives tool.myapp. Set to "" to skip step 5.

user_config bool

Whether to search the per-user configuration directory.

search_parents bool

Walk search roots upward, for monorepos.

boundary tuple[str, ...]

Marker names that stop the upward walk.

mode Literal['layer', 'first']

"layer" keeps every root as its own source; "first" stops at the first root that matches.

on_missing Literal['ok', 'error']

"error" requires that discovery find at least one file.

dotenv tuple[Path, ...]

.env files to read, highest precedence first.

secrets_dir Path | None

A key-per-file secrets directory, or None to skip it.

Source code in src/whence/discovery.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
@dataclass(frozen=True, slots=True)
class Discovery:
    """Declarative configuration discovery.

    Attributes:
        app: The application name. Every other default derives from it.
        file: An explicit configuration file. Wins outright, and must exist.
        path: Search roots, highest precedence first.
        formats: Suffixes to look for, in preference order. Restricting this is
            how a project bans a format: a stray ``app.yaml`` is then simply
            never discovered, and the plan says so.
        prefix: The environment variable prefix. ``None`` derives ``MYAPP_``.
        env_var: The variable naming an explicit config file. ``None`` derives
            ``MYAPP_CONFIG``.
        profiles_var: The variable listing active profiles. ``None`` derives
            ``MYAPP_PROFILES``.
        pyproject_table: The dotted table to read from ``pyproject.toml``.
            ``None`` derives ``tool.myapp``. Set to ``""`` to skip step 5.
        user_config: Whether to search the per-user configuration directory.
        search_parents: Walk search roots upward, for monorepos.
        boundary: Marker names that stop the upward walk.
        mode: ``"layer"`` keeps every root as its own source; ``"first"`` stops
            at the first root that matches.
        on_missing: ``"error"`` requires that discovery find at least one file.
        dotenv: ``.env`` files to read, highest precedence first.
        secrets_dir: A key-per-file secrets directory, or ``None`` to skip it.
    """

    app: str
    file: Path | None = None
    path: tuple[Path, ...] = (Path(),)
    formats: tuple[str, ...] = DEFAULT_FORMATS
    prefix: str | None = None
    env_var: str | None = None
    profiles_var: str | None = None
    pyproject_table: str | None = None
    user_config: bool = True
    search_parents: bool = False
    boundary: tuple[str, ...] = (".git", "pyproject.toml")
    mode: Literal["layer", "first"] = "layer"
    on_missing: Literal["ok", "error"] = "ok"
    dotenv: tuple[Path, ...] = (Path(".env"),)
    secrets_dir: Path | None = field(default=Path("/run/secrets"))

    def __post_init__(self) -> None:
        """Validate the prefix before anything can depend on it."""
        validate_prefix(self.env_prefix)

    @property
    def env_prefix(self) -> str:
        """The environment prefix, derived from ``app`` when unset.

        Returns:
            ``"MYAPP_"``.
        """
        return default_prefix(self.app) if self.prefix is None else self.prefix

    @property
    def config_var(self) -> str:
        """The variable naming an explicit config file.

        Returns:
            ``"MYAPP_CONFIG"``.
        """
        return self.env_var or f"{self.app.upper()}_CONFIG"

    @property
    def profiles_env(self) -> str:
        """The variable listing active profiles.

        Returns:
            ``"MYAPP_PROFILES"``.
        """
        return self.profiles_var or f"{self.app.upper()}_PROFILES"

    @property
    def table(self) -> str:
        """The ``pyproject.toml`` table to read.

        Returns:
            ``"tool.myapp"``, or ``""`` when step 5 is disabled.
        """
        return f"tool.{self.app}" if self.pyproject_table is None else self.pyproject_table

    def with_(self, **changes: object) -> "Discovery":
        """Return a copy with fields replaced.

        Args:
            **changes: Fields to override.

        Returns:
            A new discovery object.
        """
        return replace(self, **changes)  # type: ignore[arg-type]

    def roots(self, cwd: Path | None = None) -> tuple[Path, ...]:
        """Resolve the search roots, expanding the upward walk.

        With ``search_parents``, each relative root is also looked for in every
        parent directory up to and including the first one holding a
        ``boundary`` marker. In a uv workspace, where a test run may start in
        ``libs/<member>/`` while the configuration sits at the repository root,
        this is the difference between working and not.

        Args:
            cwd: The directory to start from; defaults to the process's.

        Returns:
            Roots, nearest first.
        """
        base = Path.cwd() if cwd is None else cwd
        out: list[Path] = []
        for root in self.path:
            if root.is_absolute() or not self.search_parents:
                out.append(root if root.is_absolute() else base / root)
                continue
            for parent in (base, *base.parents):
                out.append(parent / root)
                if any((parent / marker).exists() for marker in self.boundary):
                    break
        return tuple(out)

    def stems(self, profiles: Sequence[str] = ()) -> tuple[tuple[str, str | None], ...]:
        """List the file stems to look for, highest precedence first.

        Args:
            profiles: Active profiles, in increasing precedence.

        Returns:
            ``(stem, profile)`` pairs. Later profiles outrank earlier ones, and
            every profile outranks the base file.
        """
        out: list[tuple[str, str | None]] = [
            (f"{self.app}.{profile}", profile) for profile in reversed(profiles)
        ]
        out.append((self.app, None))
        return tuple(out)

    def _match(self, root: Path, stem: str) -> Path | None:
        """Find the one file in ``root`` matching ``stem``.

        Raises:
            AmbiguousConfigError: If two suffixes both match in this root.
        """
        hits: list[Path] = []
        seen: set[object] = set()
        for suffix in self.formats:
            candidate = root / f"{stem}.{suffix}"
            if not candidate.is_file():
                continue
            key = same_file_key(candidate)
            if key in seen:
                continue
            seen.add(key)
            hits.append(candidate)
        if len(hits) > 1:
            msg = (
                f"{root} holds more than one {stem} configuration file "
                f"({', '.join(p.name for p in hits)}); "
                "remove one, or narrow `formats` to say which wins"
            )
            raise AmbiguousConfigError(msg)
        return hits[0] if hits else None

    def _scan(
        self, index: int, root: Path, label: str, stems: Sequence[tuple[str, str | None]]
    ) -> tuple[Step, list[tuple[Path, str | None]]]:
        """Search one directory for every stem, and record what it found.

        Args:
            index: The step number to record.
            root: The directory to search.
            label: How the step is described in the report.
            stems: ``(stem, profile)`` pairs, highest precedence first.

        Returns:
            The step, and the files it contributed.
        """
        hits: list[tuple[Path, str | None]] = []
        for stem, profile in stems:
            found = self._match(root, stem)
            if found is not None:
                hits.append((found, profile))
        if hits:
            return Step(index, label, tuple(path for path, _ in hits)), hits
        return Step(index, label, note="no match"), []

    def plan(
        self,
        *,
        profiles: Sequence[str] = (),
        environ: Mapping[str, str] | None = None,
        cwd: Path | None = None,
        platform: Platform | None = None,
    ) -> DiscoveryPlan:
        """Run the five-step chain and report exactly what happened.

        Args:
            profiles: Active profiles, in increasing precedence.
            environ: The environment to consult.
            cwd: The directory to start from.
            platform: Override the platform, for cross-platform tests.

        Returns:
            The plan: every step, and every file found.

        Raises:
            MissingConfigError: If an explicitly named file is absent, or
                ``on_missing="error"`` and nothing at all was found.
            AmbiguousConfigError: If one root holds two matching files.
        """
        env = environ if environ is not None else {}
        steps: list[Step] = []
        files: list[tuple[Path, str | None]] = []
        stems = self.stems(profiles)

        # 1 - an explicit file, which must exist.
        if self.file is not None:
            if not self.file.is_file():
                msg = f"configuration file {self.file} was named explicitly but does not exist"
                raise MissingConfigError(msg)
            files.append((self.file, None))
            steps.append(Step(1, "file=", (self.file,)))
        else:
            steps.append(Step(1, "file=", note="not given"))

        # 2 - the operator's environment override, which must also exist.
        named = env.get(self.config_var)
        if named:
            candidate = Path(named)
            if not candidate.is_file():
                msg = f"${self.config_var} points at {named!r}, which does not exist"
                raise MissingConfigError(msg)
            files.append((candidate, None))
            steps.append(Step(2, f"${self.config_var}", (candidate,)))
        else:
            steps.append(Step(2, f"${self.config_var}", note="not set"))

        # 3 - the project's own search roots.
        for root in self.roots(cwd):
            text = str(root)
            step, hits = self._scan(3, root, text if text.endswith("/") else f"{text}/", stems)
            steps.append(step)
            files.extend(hits)
            if hits and self.mode == "first":
                break

        # 4 - the developer's machine.
        if self.user_config:
            for pure in user_config_dirs(self.app, platform=platform, environ=env):
                directory = Path(str(pure))
                step, hits = self._scan(4, directory, str(directory), stems)
                steps.append(step)
                files.extend(hits)
        else:
            steps.append(Step(4, "user config dir", note="disabled"))

        # 5 - the repository's own default, handled by PyProjectSource.
        if self.table:
            steps.append(Step(5, f"pyproject.toml [{self.table}]", note="read separately"))
        else:
            steps.append(Step(5, "pyproject.toml", note="disabled"))

        if not files and self.on_missing == "error":
            msg = f"no configuration file found for {self.app!r}. Searched:\n" + "\n".join(
                f"  {s.label}" for s in steps
            )
            raise MissingConfigError(msg)
        return DiscoveryPlan(tuple(steps), tuple(files))

    def file_sources(self, plan: DiscoveryPlan) -> list[FileSource]:
        """Turn a plan into file sources, highest precedence first.

        Args:
            plan: A plan produced by :meth:`plan`.

        Returns:
            One source per discovered file.
        """
        return [FileSource(path, profile=profile) for path, profile in plan.files]

env_prefix property

env_prefix: str

The environment prefix, derived from app when unset.

Returns:

Type Description
str

"MYAPP_".

config_var property

config_var: str

The variable naming an explicit config file.

Returns:

Type Description
str

"MYAPP_CONFIG".

profiles_env property

profiles_env: str

The variable listing active profiles.

Returns:

Type Description
str

"MYAPP_PROFILES".

table property

table: str

The pyproject.toml table to read.

Returns:

Type Description
str

"tool.myapp", or "" when step 5 is disabled.

__post_init__

__post_init__() -> None

Validate the prefix before anything can depend on it.

Source code in src/whence/discovery.py
140
141
142
def __post_init__(self) -> None:
    """Validate the prefix before anything can depend on it."""
    validate_prefix(self.env_prefix)

with_

with_(**changes: object) -> Discovery

Return a copy with fields replaced.

Parameters:

Name Type Description Default
**changes object

Fields to override.

{}

Returns:

Type Description
Discovery

A new discovery object.

Source code in src/whence/discovery.py
180
181
182
183
184
185
186
187
188
189
def with_(self, **changes: object) -> "Discovery":
    """Return a copy with fields replaced.

    Args:
        **changes: Fields to override.

    Returns:
        A new discovery object.
    """
    return replace(self, **changes)  # type: ignore[arg-type]

roots

roots(cwd: Path | None = None) -> tuple[Path, ...]

Resolve the search roots, expanding the upward walk.

With search_parents, each relative root is also looked for in every parent directory up to and including the first one holding a boundary marker. In a uv workspace, where a test run may start in libs/<member>/ while the configuration sits at the repository root, this is the difference between working and not.

Parameters:

Name Type Description Default
cwd Path | None

The directory to start from; defaults to the process's.

None

Returns:

Type Description
tuple[Path, ...]

Roots, nearest first.

Source code in src/whence/discovery.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
def roots(self, cwd: Path | None = None) -> tuple[Path, ...]:
    """Resolve the search roots, expanding the upward walk.

    With ``search_parents``, each relative root is also looked for in every
    parent directory up to and including the first one holding a
    ``boundary`` marker. In a uv workspace, where a test run may start in
    ``libs/<member>/`` while the configuration sits at the repository root,
    this is the difference between working and not.

    Args:
        cwd: The directory to start from; defaults to the process's.

    Returns:
        Roots, nearest first.
    """
    base = Path.cwd() if cwd is None else cwd
    out: list[Path] = []
    for root in self.path:
        if root.is_absolute() or not self.search_parents:
            out.append(root if root.is_absolute() else base / root)
            continue
        for parent in (base, *base.parents):
            out.append(parent / root)
            if any((parent / marker).exists() for marker in self.boundary):
                break
    return tuple(out)

stems

stems(
    profiles: Sequence[str] = (),
) -> tuple[tuple[str, str | None], ...]

List the file stems to look for, highest precedence first.

Parameters:

Name Type Description Default
profiles Sequence[str]

Active profiles, in increasing precedence.

()

Returns:

Type Description
tuple[str, str | None]

(stem, profile) pairs. Later profiles outrank earlier ones, and

...

every profile outranks the base file.

Source code in src/whence/discovery.py
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
def stems(self, profiles: Sequence[str] = ()) -> tuple[tuple[str, str | None], ...]:
    """List the file stems to look for, highest precedence first.

    Args:
        profiles: Active profiles, in increasing precedence.

    Returns:
        ``(stem, profile)`` pairs. Later profiles outrank earlier ones, and
        every profile outranks the base file.
    """
    out: list[tuple[str, str | None]] = [
        (f"{self.app}.{profile}", profile) for profile in reversed(profiles)
    ]
    out.append((self.app, None))
    return tuple(out)

plan

plan(
    *,
    profiles: Sequence[str] = (),
    environ: Mapping[str, str] | None = None,
    cwd: Path | None = None,
    platform: Platform | None = None,
) -> DiscoveryPlan

Run the five-step chain and report exactly what happened.

Parameters:

Name Type Description Default
profiles Sequence[str]

Active profiles, in increasing precedence.

()
environ Mapping[str, str] | None

The environment to consult.

None
cwd Path | None

The directory to start from.

None
platform Platform | None

Override the platform, for cross-platform tests.

None

Returns:

Type Description
DiscoveryPlan

The plan: every step, and every file found.

Raises:

Type Description
MissingConfigError

If an explicitly named file is absent, or on_missing="error" and nothing at all was found.

AmbiguousConfigError

If one root holds two matching files.

Source code in src/whence/discovery.py
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
def plan(
    self,
    *,
    profiles: Sequence[str] = (),
    environ: Mapping[str, str] | None = None,
    cwd: Path | None = None,
    platform: Platform | None = None,
) -> DiscoveryPlan:
    """Run the five-step chain and report exactly what happened.

    Args:
        profiles: Active profiles, in increasing precedence.
        environ: The environment to consult.
        cwd: The directory to start from.
        platform: Override the platform, for cross-platform tests.

    Returns:
        The plan: every step, and every file found.

    Raises:
        MissingConfigError: If an explicitly named file is absent, or
            ``on_missing="error"`` and nothing at all was found.
        AmbiguousConfigError: If one root holds two matching files.
    """
    env = environ if environ is not None else {}
    steps: list[Step] = []
    files: list[tuple[Path, str | None]] = []
    stems = self.stems(profiles)

    # 1 - an explicit file, which must exist.
    if self.file is not None:
        if not self.file.is_file():
            msg = f"configuration file {self.file} was named explicitly but does not exist"
            raise MissingConfigError(msg)
        files.append((self.file, None))
        steps.append(Step(1, "file=", (self.file,)))
    else:
        steps.append(Step(1, "file=", note="not given"))

    # 2 - the operator's environment override, which must also exist.
    named = env.get(self.config_var)
    if named:
        candidate = Path(named)
        if not candidate.is_file():
            msg = f"${self.config_var} points at {named!r}, which does not exist"
            raise MissingConfigError(msg)
        files.append((candidate, None))
        steps.append(Step(2, f"${self.config_var}", (candidate,)))
    else:
        steps.append(Step(2, f"${self.config_var}", note="not set"))

    # 3 - the project's own search roots.
    for root in self.roots(cwd):
        text = str(root)
        step, hits = self._scan(3, root, text if text.endswith("/") else f"{text}/", stems)
        steps.append(step)
        files.extend(hits)
        if hits and self.mode == "first":
            break

    # 4 - the developer's machine.
    if self.user_config:
        for pure in user_config_dirs(self.app, platform=platform, environ=env):
            directory = Path(str(pure))
            step, hits = self._scan(4, directory, str(directory), stems)
            steps.append(step)
            files.extend(hits)
    else:
        steps.append(Step(4, "user config dir", note="disabled"))

    # 5 - the repository's own default, handled by PyProjectSource.
    if self.table:
        steps.append(Step(5, f"pyproject.toml [{self.table}]", note="read separately"))
    else:
        steps.append(Step(5, "pyproject.toml", note="disabled"))

    if not files and self.on_missing == "error":
        msg = f"no configuration file found for {self.app!r}. Searched:\n" + "\n".join(
            f"  {s.label}" for s in steps
        )
        raise MissingConfigError(msg)
    return DiscoveryPlan(tuple(steps), tuple(files))

file_sources

file_sources(plan: DiscoveryPlan) -> list[FileSource]

Turn a plan into file sources, highest precedence first.

Parameters:

Name Type Description Default
plan DiscoveryPlan

A plan produced by :meth:plan.

required

Returns:

Type Description
list[FileSource]

One source per discovered file.

Source code in src/whence/discovery.py
366
367
368
369
370
371
372
373
374
375
def file_sources(self, plan: DiscoveryPlan) -> list[FileSource]:
    """Turn a plan into file sources, highest precedence first.

    Args:
        plan: A plan produced by :meth:`plan`.

    Returns:
        One source per discovered file.
    """
    return [FileSource(path, profile=profile) for path, profile in plan.files]

DiscoveryPlan dataclass

The outcome of running discovery: what was searched, and what was found.

Attributes:

Name Type Description
steps tuple[Step, ...]

Every step, in order, including the ones that found nothing.

files tuple[tuple[Path, str | None], ...]

Every discovered file, highest precedence first.

Source code in src/whence/discovery.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@dataclass(frozen=True, slots=True)
class DiscoveryPlan:
    """The outcome of running discovery: what was searched, and what was found.

    Attributes:
        steps: Every step, in order, including the ones that found nothing.
        files: Every discovered file, highest precedence first.
    """

    steps: tuple[Step, ...]
    files: tuple[tuple[Path, str | None], ...]

    def render(self) -> str:
        """Format the plan the way ``whence explain --discovery`` prints it.

        Returns:
            A multi-line report.
        """
        lines = []
        for step in self.steps:
            head = f"  {step.index} {step.label:<30}"
            if step.found:
                lines.append(f"{head} {', '.join(p.name for p in step.paths)}")
            else:
                lines.append(f"{head} - {step.note or 'not found'}")
        return "\n".join(lines)

render

render() -> str

Format the plan the way whence explain --discovery prints it.

Returns:

Type Description
str

A multi-line report.

Source code in src/whence/discovery.py
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def render(self) -> str:
    """Format the plan the way ``whence explain --discovery`` prints it.

    Returns:
        A multi-line report.
    """
    lines = []
    for step in self.steps:
        head = f"  {step.index} {step.label:<30}"
        if step.found:
            lines.append(f"{head} {', '.join(p.name for p in step.paths)}")
        else:
            lines.append(f"{head} - {step.note or 'not found'}")
    return "\n".join(lines)

PyProjectSource

Reads a dotted table out of pyproject.toml.

The lowest file layer: a project's own committed defaults, which every other source is entitled to override.

Parameters:

Name Type Description Default
path Path | str

The pyproject.toml to read.

'pyproject.toml'
table str

The dotted table name, such as "tool.myapp".

''
name str

The layer name.

'pyproject'
Source code in src/whence/discovery.py
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
class PyProjectSource:
    """Reads a dotted table out of ``pyproject.toml``.

    The lowest file layer: a project's own committed defaults, which every other
    source is entitled to override.

    Args:
        path: The ``pyproject.toml`` to read.
        table: The dotted table name, such as ``"tool.myapp"``.
        name: The layer name.
    """

    def __init__(
        self, path: Path | str = "pyproject.toml", table: str = "", *, name: str = "pyproject"
    ) -> None:
        """Build a pyproject source."""
        self.path = Path(path)
        self.table = table
        self.name = name

    def load(self) -> Layer:
        """Read the table, if the file and the table both exist.

        Returns:
            The layer, with ``found=False`` when either is absent.

        Raises:
            FormatError: If ``pyproject.toml`` does not parse.
        """
        if not self.table or not self.path.is_file():
            return Layer(self.name, {}, found=False)
        text = self.path.read_text(encoding="utf-8-sig")
        try:
            data: object = tomllib.loads(text)
        except tomllib.TOMLDecodeError as exc:
            msg = f"{self.path}: invalid TOML: {exc}"
            raise FormatError(msg) from exc
        for segment in self.table.split("."):
            if not isinstance(data, Mapping) or segment not in data:
                return Layer(self.name, {}, found=False)
            data = data[segment]
        if not isinstance(data, Mapping):
            msg = f"{self.path}: [{self.table}] must be a table"
            raise ConfigError(msg)
        origin = Origin("file", f"{self.path} [{self.table}]")
        entries: dict[KeyPath, Tracked] = {
            path: Tracked(value, origin) for path, value in flatten(data).items()
        }
        return Layer(self.name, entries, found=bool(entries))

__init__

__init__(
    path: Path | str = "pyproject.toml",
    table: str = "",
    *,
    name: str = "pyproject",
) -> None

Build a pyproject source.

Source code in src/whence/discovery.py
390
391
392
393
394
395
396
def __init__(
    self, path: Path | str = "pyproject.toml", table: str = "", *, name: str = "pyproject"
) -> None:
    """Build a pyproject source."""
    self.path = Path(path)
    self.table = table
    self.name = name

load

load() -> Layer

Read the table, if the file and the table both exist.

Returns:

Type Description
Layer

The layer, with found=False when either is absent.

Raises:

Type Description
FormatError

If pyproject.toml does not parse.

Source code in src/whence/discovery.py
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
def load(self) -> Layer:
    """Read the table, if the file and the table both exist.

    Returns:
        The layer, with ``found=False`` when either is absent.

    Raises:
        FormatError: If ``pyproject.toml`` does not parse.
    """
    if not self.table or not self.path.is_file():
        return Layer(self.name, {}, found=False)
    text = self.path.read_text(encoding="utf-8-sig")
    try:
        data: object = tomllib.loads(text)
    except tomllib.TOMLDecodeError as exc:
        msg = f"{self.path}: invalid TOML: {exc}"
        raise FormatError(msg) from exc
    for segment in self.table.split("."):
        if not isinstance(data, Mapping) or segment not in data:
            return Layer(self.name, {}, found=False)
        data = data[segment]
    if not isinstance(data, Mapping):
        msg = f"{self.path}: [{self.table}] must be a table"
        raise ConfigError(msg)
    origin = Origin("file", f"{self.path} [{self.table}]")
    entries: dict[KeyPath, Tracked] = {
        path: Tracked(value, origin) for path, value in flatten(data).items()
    }
    return Layer(self.name, entries, found=bool(entries))

Step dataclass

One step of the discovery chain, and what it found.

Attributes:

Name Type Description
index int

The step number, 1 through 5.

label str

A human-readable description of where it looked.

paths tuple[Path, ...]

Files it contributed, highest precedence first.

note str

Why it contributed nothing, when it did not.

Source code in src/whence/discovery.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@dataclass(frozen=True, slots=True)
class Step:
    """One step of the discovery chain, and what it found.

    Attributes:
        index: The step number, 1 through 5.
        label: A human-readable description of where it looked.
        paths: Files it contributed, highest precedence first.
        note: Why it contributed nothing, when it did not.
    """

    index: int
    label: str
    paths: tuple[Path, ...] = ()
    note: str = ""

    @property
    def found(self) -> bool:
        """Whether this step contributed anything.

        Returns:
            True when at least one file was found.
        """
        return bool(self.paths)

found property

found: bool

Whether this step contributed anything.

Returns:

Type Description
bool

True when at least one file was found.

AmbiguousConfigError

Bases: ConfigError

Two files in one search root both satisfy the same logical name.

Raised rather than resolved by preference, because silently picking one is the failure mode that took Spring Boot years to specify.

Source code in src/whence/errors.py
35
36
37
38
39
40
class AmbiguousConfigError(ConfigError):
    """Two files in one search root both satisfy the same logical name.

    Raised rather than resolved by preference, because silently picking one is
    the failure mode that took Spring Boot years to specify.
    """

BindError

Bases: ConfigError

One or more values could not be bound to the target schema.

Source code in src/whence/errors.py
63
64
class BindError(ConfigError):
    """One or more values could not be bound to the target schema."""

ConfigError

Bases: WhenceError

The configuration itself is wrong: bad discovery, bad wiring, bad source.

Source code in src/whence/errors.py
27
28
class ConfigError(WhenceError):
    """The configuration itself is wrong: bad discovery, bad wiring, bad source."""

FormatError

Bases: ConfigError

A configuration file could not be parsed.

Source code in src/whence/errors.py
43
44
class FormatError(ConfigError):
    """A configuration file could not be parsed."""

InterpolationError

Bases: ConfigError

A ${...} placeholder could not be resolved.

Source code in src/whence/errors.py
55
56
class InterpolationError(ConfigError):
    """A ``${...}`` placeholder could not be resolved."""

MissingConfigError

Bases: ConfigError

A configuration file that was named explicitly does not exist.

Source code in src/whence/errors.py
31
32
class MissingConfigError(ConfigError):
    """A configuration file that was named explicitly does not exist."""

MissingKeyError

Bases: WhenceError, KeyError

A required key is absent from every source.

Source code in src/whence/errors.py
47
48
49
50
51
52
class MissingKeyError(WhenceError, KeyError):
    """A required key is absent from every source."""

    def __str__(self) -> str:
        """Render without ``KeyError``'s surrounding quotes."""
        return str(self.args[0]) if self.args else ""

__str__

__str__() -> str

Render without KeyError's surrounding quotes.

Source code in src/whence/errors.py
50
51
52
def __str__(self) -> str:
    """Render without ``KeyError``'s surrounding quotes."""
    return str(self.args[0]) if self.args else ""

SecretError

Bases: WhenceError

A secret was read outside an explicit unlock_secrets() scope.

Source code in src/whence/errors.py
67
68
class SecretError(WhenceError):
    """A secret was read outside an explicit ``unlock_secrets()`` scope."""

UnboundKeyError

Bases: ConfigError

Configuration supplied a key that the target schema does not declare.

Source code in src/whence/errors.py
59
60
class UnboundKeyError(ConfigError):
    """Configuration supplied a key that the target schema does not declare."""

WhenceError

Bases: Exception

Base class for every error whence raises.

Source code in src/whence/errors.py
23
24
class WhenceError(Exception):
    """Base class for every error whence raises."""

Origin dataclass

Where a single configuration value came from.

Attributes:

Name Type Description
source str

The name of the source that produced it, such as "env" or "file". Matches the name the value's source carries in the chain.

locator str

The concrete location within that source: a file path, an environment variable name, or "<defaults>".

line int | None

One-based line number, when the parser reports positions.

column int | None

One-based column number, when the parser reports positions.

profile str | None

The profile whose overlay contributed the value, if any.

parent Origin | None

The origin this one was derived from -- the variable behind a _FILE indirection, or the template behind an interpolated value.

Source code in src/whence/origin.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@dataclass(frozen=True, slots=True)
class Origin:
    """Where a single configuration value came from.

    Attributes:
        source: The name of the source that produced it, such as ``"env"`` or
            ``"file"``. Matches the name the value's source carries in the chain.
        locator: The concrete location within that source: a file path, an
            environment variable name, or ``"<defaults>"``.
        line: One-based line number, when the parser reports positions.
        column: One-based column number, when the parser reports positions.
        profile: The profile whose overlay contributed the value, if any.
        parent: The origin this one was derived from -- the variable behind a
            ``_FILE`` indirection, or the template behind an interpolated value.
    """

    source: str
    locator: str
    line: int | None = None
    column: int | None = None
    profile: str | None = None
    parent: "Origin | None" = None

    def __str__(self) -> str:
        """Render as ``locator:line:column [profile=...]``."""
        text = self.locator
        if self.line is not None:
            text = f"{text}:{self.line}"
            if self.column is not None:
                text = f"{text}:{self.column}"
        if self.profile is not None:
            text = f"{text} [profile={self.profile}]"
        if self.parent is not None:
            text = f"{text} <- {self.parent}"
        return text

    def derived(self, **changes: Any) -> "Origin":
        """Return a copy that records this origin as its parent.

        Args:
            **changes: Fields to override on the copy.

        Returns:
            A new origin whose ``parent`` is this one.
        """
        return replace(self, parent=self, **changes)

__str__

__str__() -> str

Render as locator:line:column [profile=...].

Source code in src/whence/origin.py
38
39
40
41
42
43
44
45
46
47
48
49
def __str__(self) -> str:
    """Render as ``locator:line:column [profile=...]``."""
    text = self.locator
    if self.line is not None:
        text = f"{text}:{self.line}"
        if self.column is not None:
            text = f"{text}:{self.column}"
    if self.profile is not None:
        text = f"{text} [profile={self.profile}]"
    if self.parent is not None:
        text = f"{text} <- {self.parent}"
    return text

derived

derived(**changes: Any) -> Origin

Return a copy that records this origin as its parent.

Parameters:

Name Type Description Default
**changes Any

Fields to override on the copy.

{}

Returns:

Type Description
Origin

A new origin whose parent is this one.

Source code in src/whence/origin.py
51
52
53
54
55
56
57
58
59
60
def derived(self, **changes: Any) -> "Origin":
    """Return a copy that records this origin as its parent.

    Args:
        **changes: Fields to override on the copy.

    Returns:
        A new origin whose ``parent`` is this one.
    """
    return replace(self, parent=self, **changes)

RelativePath

Bases: Path

A path resolved against the file that declared it, not the process cwd.

cert = "./ca.pem" inside deploy/app.yaml means deploy/ca.pem, wherever the program happens to be run from. That is only expressible because the value remembers where it came from; figment calls the same idea RelativePathBuf and nothing in Python offers it.

An absolute value is left alone, and a value from a source with no file behind it -- an environment variable, say -- is resolved against the cwd, because there is nothing better to resolve it against.

Source code in src/whence/origin.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
class RelativePath(Path):
    """A path resolved against the file that declared it, not the process cwd.

    ``cert = "./ca.pem"`` inside ``deploy/app.yaml`` means ``deploy/ca.pem``,
    wherever the program happens to be run from. That is only expressible
    because the value remembers where it came from; figment calls the same idea
    ``RelativePathBuf`` and nothing in Python offers it.

    An absolute value is left alone, and a value from a source with no file
    behind it -- an environment variable, say -- is resolved against the cwd,
    because there is nothing better to resolve it against.
    """

    __slots__ = ()

    @classmethod
    def resolve_against(cls, value: object, origin: "Origin | None") -> Path:
        """Resolve a path value against its origin's directory.

        Args:
            value: The raw path value.
            origin: Where the value came from.

        Returns:
            An absolute path where the origin names a file, and the value
            unchanged otherwise.
        """
        path = Path(str(value))
        if path.is_absolute() or origin is None or origin.source != "file":
            return path
        base = Path(origin.locator.split(" [")[0]).parent
        return base / path

resolve_against classmethod

resolve_against(
    value: object, origin: Origin | None
) -> Path

Resolve a path value against its origin's directory.

Parameters:

Name Type Description Default
value object

The raw path value.

required
origin Origin | None

Where the value came from.

required

Returns:

Type Description
Path

An absolute path where the origin names a file, and the value

Path

unchanged otherwise.

Source code in src/whence/origin.py
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
@classmethod
def resolve_against(cls, value: object, origin: "Origin | None") -> Path:
    """Resolve a path value against its origin's directory.

    Args:
        value: The raw path value.
        origin: Where the value came from.

    Returns:
        An absolute path where the origin names a file, and the value
        unchanged otherwise.
    """
    path = Path(str(value))
    if path.is_absolute() or origin is None or origin.source != "file":
        return path
    base = Path(origin.locator.split(" [")[0]).parent
    return base / path

Tracked dataclass

A value paired with its origin.

Equality and hashing delegate to the wrapped value on purpose. That is what makes provenance retrofittable instead of viral: every consumer that compares, hashes or formats a value keeps working unchanged, and only code that explicitly asks for .origin pays any attention to it. A sidecar {key: origin} map loses provenance at every transformation, and a str subclass loses it at the first f-string.

Attributes:

Name Type Description
value Any

The underlying value.

origin Origin

Where it came from.

Source code in src/whence/origin.py
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
@dataclass(frozen=True, slots=True)
class Tracked:
    """A value paired with its origin.

    Equality and hashing delegate to the wrapped value on purpose. That is what
    makes provenance retrofittable instead of viral: every consumer that
    compares, hashes or formats a value keeps working unchanged, and only code
    that explicitly asks for ``.origin`` pays any attention to it. A sidecar
    ``{key: origin}`` map loses provenance at every transformation, and a ``str``
    subclass loses it at the first f-string.

    Attributes:
        value: The underlying value.
        origin: Where it came from.
    """

    value: Any
    origin: Origin = field(compare=False, hash=False)

    def __eq__(self, other: object) -> bool:
        """Compare against the wrapped value, tracked or not."""
        return bool(self.value == unwrap(other))

    def __hash__(self) -> int:
        """Hash as the wrapped value does."""
        return hash(self.value)

    def __str__(self) -> str:
        """Render as the wrapped value does."""
        return str(self.value)

    def __repr__(self) -> str:
        """Show the value and its origin."""
        return f"Tracked({self.value!r} <- {self.origin})"

    def __bool__(self) -> bool:
        """Report the truthiness of the wrapped value."""
        return bool(self.value)

__eq__

__eq__(other: object) -> bool

Compare against the wrapped value, tracked or not.

Source code in src/whence/origin.py
82
83
84
def __eq__(self, other: object) -> bool:
    """Compare against the wrapped value, tracked or not."""
    return bool(self.value == unwrap(other))

__hash__

__hash__() -> int

Hash as the wrapped value does.

Source code in src/whence/origin.py
86
87
88
def __hash__(self) -> int:
    """Hash as the wrapped value does."""
    return hash(self.value)

__str__

__str__() -> str

Render as the wrapped value does.

Source code in src/whence/origin.py
90
91
92
def __str__(self) -> str:
    """Render as the wrapped value does."""
    return str(self.value)

__repr__

__repr__() -> str

Show the value and its origin.

Source code in src/whence/origin.py
94
95
96
def __repr__(self) -> str:
    """Show the value and its origin."""
    return f"Tracked({self.value!r} <- {self.origin})"

__bool__

__bool__() -> bool

Report the truthiness of the wrapped value.

Source code in src/whence/origin.py
 98
 99
100
def __bool__(self) -> bool:
    """Report the truthiness of the wrapped value."""
    return bool(self.value)

Secret

A string that will not reveal itself by accident.

repr and str both render the mask, so a secret cannot reach a log line, a traceback or an f-string through inattention. Reading the real value needs :func:unlock_secrets, which makes the intent explicit and greppable.

Source code in src/whence/secret.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
class Secret:
    """A string that will not reveal itself by accident.

    ``repr`` and ``str`` both render the mask, so a secret cannot reach a log
    line, a traceback or an f-string through inattention. Reading the real value
    needs :func:`unlock_secrets`, which makes the intent explicit and greppable.
    """

    __slots__ = ("_value",)

    def __init__(self, value: str) -> None:
        """Wrap a string.

        Args:
            value: The sensitive value.
        """
        self._value = value

    def reveal(self) -> str:
        """Return the underlying string.

        Returns:
            The real value.

        Raises:
            SecretError: If called outside an ``unlock_secrets()`` scope.
        """
        if not _UNLOCKED.get():
            msg = (
                "reading a Secret requires an explicit scope: "
                "`with unlock_secrets(): ...` around the call"
            )
            raise SecretError(msg)
        return self._value

    def __repr__(self) -> str:
        """Render the mask, never the value."""
        return f"Secret({MASK})"

    def __str__(self) -> str:
        """Render the mask, never the value."""
        return MASK

    def __eq__(self, other: object) -> bool:
        """Compare two secrets without revealing either."""
        if isinstance(other, Secret):
            return self._value == other._value
        return NotImplemented

    def __hash__(self) -> int:
        """Hash the underlying value."""
        return hash(self._value)

    def __bool__(self) -> bool:
        """Report whether the underlying string is non-empty."""
        return bool(self._value)

__init__

__init__(value: str) -> None

Wrap a string.

Parameters:

Name Type Description Default
value str

The sensitive value.

required
Source code in src/whence/secret.py
51
52
53
54
55
56
57
def __init__(self, value: str) -> None:
    """Wrap a string.

    Args:
        value: The sensitive value.
    """
    self._value = value

reveal

reveal() -> str

Return the underlying string.

Returns:

Type Description
str

The real value.

Raises:

Type Description
SecretError

If called outside an unlock_secrets() scope.

Source code in src/whence/secret.py
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
def reveal(self) -> str:
    """Return the underlying string.

    Returns:
        The real value.

    Raises:
        SecretError: If called outside an ``unlock_secrets()`` scope.
    """
    if not _UNLOCKED.get():
        msg = (
            "reading a Secret requires an explicit scope: "
            "`with unlock_secrets(): ...` around the call"
        )
        raise SecretError(msg)
    return self._value

__repr__

__repr__() -> str

Render the mask, never the value.

Source code in src/whence/secret.py
76
77
78
def __repr__(self) -> str:
    """Render the mask, never the value."""
    return f"Secret({MASK})"

__str__

__str__() -> str

Render the mask, never the value.

Source code in src/whence/secret.py
80
81
82
def __str__(self) -> str:
    """Render the mask, never the value."""
    return MASK

__eq__

__eq__(other: object) -> bool

Compare two secrets without revealing either.

Source code in src/whence/secret.py
84
85
86
87
88
def __eq__(self, other: object) -> bool:
    """Compare two secrets without revealing either."""
    if isinstance(other, Secret):
        return self._value == other._value
    return NotImplemented

__hash__

__hash__() -> int

Hash the underlying value.

Source code in src/whence/secret.py
90
91
92
def __hash__(self) -> int:
    """Hash the underlying value."""
    return hash(self._value)

__bool__

__bool__() -> bool

Report whether the underlying string is non-empty.

Source code in src/whence/secret.py
94
95
96
def __bool__(self) -> bool:
    """Report whether the underlying string is non-empty."""
    return bool(self._value)

ArgvSource

Reads --set key=value pairs out of an argument list.

Parameters:

Name Type Description Default
argv Sequence[str] | None

The arguments to scan; defaults to sys.argv[1:]. Passing an explicit list is what makes a test hermetic.

None
name str

The layer name.

'cli'
flag str

The flag to recognise, if --set collides with an existing one.

FLAG
Source code in src/whence/sources/argv.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
class ArgvSource:
    """Reads ``--set key=value`` pairs out of an argument list.

    Args:
        argv: The arguments to scan; defaults to ``sys.argv[1:]``. Passing an
            explicit list is what makes a test hermetic.
        name: The layer name.
        flag: The flag to recognise, if ``--set`` collides with an existing one.
    """

    def __init__(
        self,
        argv: Sequence[str] | None = None,
        *,
        name: str = "cli",
        flag: str = FLAG,
    ) -> None:
        """Build a command-line source."""
        self.name = name
        self.flag = flag
        self.argv = list(sys.argv[1:] if argv is None else argv)

    def _pairs(self) -> list[tuple[str, str, int]]:
        """Extract ``(key, value, argv index)`` triples, both flag spellings."""
        out: list[tuple[str, str, int]] = []
        i = 0
        while i < len(self.argv):
            arg = self.argv[i]
            if arg == self.flag:
                if i + 1 >= len(self.argv):
                    msg = f"{self.flag} needs an argument, as in `{self.flag} db.host=localhost`"
                    raise ConfigError(msg)
                body, index = self.argv[i + 1], i + 1
                i += 2
            elif arg.startswith(f"{self.flag}="):
                body, index = arg[len(self.flag) + 1 :], i
                i += 1
            else:
                i += 1
                continue
            key, sep, value = body.partition("=")
            if not sep or not key.strip():
                msg = f"{self.flag} expects key=value, as in `{self.flag} db.host=localhost`"
                raise ConfigError(msg)
            out.append((key.strip(), value, index))
        return out

    def load(self) -> Layer:
        """Collect every ``--set`` pair.

        Returns:
            The layer, with ``found=False`` when no pair was given.

        Raises:
            ConfigError: If a pair is malformed.
        """
        entries: dict[KeyPath, Tracked] = {}
        for key, value, index in self._pairs():
            entries[canonical(key)] = Tracked(
                value, Origin(self.name, f"{self.flag} {key}", line=index + 1)
            )
        return Layer(self.name, entries, found=bool(entries))

__init__

__init__(
    argv: Sequence[str] | None = None,
    *,
    name: str = "cli",
    flag: str = FLAG,
) -> None

Build a command-line source.

Source code in src/whence/sources/argv.py
33
34
35
36
37
38
39
40
41
42
43
def __init__(
    self,
    argv: Sequence[str] | None = None,
    *,
    name: str = "cli",
    flag: str = FLAG,
) -> None:
    """Build a command-line source."""
    self.name = name
    self.flag = flag
    self.argv = list(sys.argv[1:] if argv is None else argv)

load

load() -> Layer

Collect every --set pair.

Returns:

Type Description
Layer

The layer, with found=False when no pair was given.

Raises:

Type Description
ConfigError

If a pair is malformed.

Source code in src/whence/sources/argv.py
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
def load(self) -> Layer:
    """Collect every ``--set`` pair.

    Returns:
        The layer, with ``found=False`` when no pair was given.

    Raises:
        ConfigError: If a pair is malformed.
    """
    entries: dict[KeyPath, Tracked] = {}
    for key, value, index in self._pairs():
        entries[canonical(key)] = Tracked(
            value, Origin(self.name, f"{self.flag} {key}", line=index + 1)
        )
    return Layer(self.name, entries, found=bool(entries))

DotEnvSource

Reads a .env file and maps its variables like the environment.

Parameters:

Name Type Description Default
path Path | str

The file to read.

required
prefix str

The environment prefix, applied exactly as EnvSource does so that .env and the real environment stay interchangeable.

''
name str

The layer name.

'dotenv'
aliases Mapping[str, str] | None

Explicit variable-to-key overrides.

None
Source code in src/whence/sources/dotenv.py
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
class DotEnvSource:
    """Reads a ``.env`` file and maps its variables like the environment.

    Args:
        path: The file to read.
        prefix: The environment prefix, applied exactly as ``EnvSource`` does so
            that ``.env`` and the real environment stay interchangeable.
        name: The layer name.
        aliases: Explicit variable-to-key overrides.
    """

    def __init__(
        self,
        path: Path | str,
        prefix: str = "",
        *,
        name: str = "dotenv",
        aliases: Mapping[str, str] | None = None,
    ) -> None:
        """Build a ``.env`` source."""
        self.name = name
        self.path = Path(path)
        self.prefix = validate_prefix(prefix)
        self._aliases = dict(aliases or {})

    def load(self) -> Layer:
        """Read the file, if it exists.

        Returns:
            The layer, with ``found=False`` when the file is absent.

        Raises:
            FormatError: If the file exists but cannot be parsed.
        """
        if not self.path.is_file():
            return Layer(self.name, {}, found=False)
        text = self.path.read_text(encoding="utf-8-sig")
        entries: dict[KeyPath, Tracked] = {}
        for raw_name, tracked in parse_dotenv(text, str(self.path)).items():
            alias = self._aliases.get(raw_name)
            path = canonical(alias) if alias else key_from_env(raw_name, self.prefix)
            if path is not None:
                entries[path] = tracked
        return Layer(self.name, entries, found=True)

__init__

__init__(
    path: Path | str,
    prefix: str = "",
    *,
    name: str = "dotenv",
    aliases: Mapping[str, str] | None = None,
) -> None

Build a .env source.

Source code in src/whence/sources/dotenv.py
143
144
145
146
147
148
149
150
151
152
153
154
155
def __init__(
    self,
    path: Path | str,
    prefix: str = "",
    *,
    name: str = "dotenv",
    aliases: Mapping[str, str] | None = None,
) -> None:
    """Build a ``.env`` source."""
    self.name = name
    self.path = Path(path)
    self.prefix = validate_prefix(prefix)
    self._aliases = dict(aliases or {})

load

load() -> Layer

Read the file, if it exists.

Returns:

Type Description
Layer

The layer, with found=False when the file is absent.

Raises:

Type Description
FormatError

If the file exists but cannot be parsed.

Source code in src/whence/sources/dotenv.py
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
def load(self) -> Layer:
    """Read the file, if it exists.

    Returns:
        The layer, with ``found=False`` when the file is absent.

    Raises:
        FormatError: If the file exists but cannot be parsed.
    """
    if not self.path.is_file():
        return Layer(self.name, {}, found=False)
    text = self.path.read_text(encoding="utf-8-sig")
    entries: dict[KeyPath, Tracked] = {}
    for raw_name, tracked in parse_dotenv(text, str(self.path)).items():
        alias = self._aliases.get(raw_name)
        path = canonical(alias) if alias else key_from_env(raw_name, self.prefix)
        if path is not None:
            entries[path] = tracked
    return Layer(self.name, entries, found=True)

EnvSource

Reads configuration from environment variables.

Parameters:

Name Type Description Default
prefix str

The literal prefix every variable carries. Applied to the environment only -- files are never prefixed, matching Spring's setEnvironmentPrefix.

''
environ Mapping[str, str] | None

The environment to read; defaults to os.environ. Passing an explicit mapping is what makes a test hermetic.

None
name str

The layer name.

'env'
aliases Mapping[str, str] | None

An explicit map from variable name to dotted key, for the cases convention does not cover (DATABASE_URL -> db.url). An explicit, greppable table beats implicit name mangling, which is the lesson from node-config's custom-environment-variables.json.

None
read_files bool

Whether to honour the _FILE convention.

True
exclude Collection[str]

Variables that control how configuration is found rather than carrying any of it -- $MYAPP_CONFIG and $MYAPP_PROFILES. Without this they would be read twice: once by discovery, and again as data under a key named config, which a schema that forbids unknown keys then rejects.

()
Source code in src/whence/sources/env.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
class EnvSource:
    """Reads configuration from environment variables.

    Args:
        prefix: The literal prefix every variable carries. Applied to the
            environment only -- files are never prefixed, matching Spring's
            ``setEnvironmentPrefix``.
        environ: The environment to read; defaults to ``os.environ``. Passing an
            explicit mapping is what makes a test hermetic.
        name: The layer name.
        aliases: An explicit map from variable name to dotted key, for the cases
            convention does not cover (``DATABASE_URL`` -> ``db.url``). An
            explicit, greppable table beats implicit name mangling, which is the
            lesson from node-config's ``custom-environment-variables.json``.
        read_files: Whether to honour the ``_FILE`` convention.
        exclude: Variables that control *how* configuration is found rather than
            carrying any of it -- ``$MYAPP_CONFIG`` and ``$MYAPP_PROFILES``.
            Without this they would be read twice: once by discovery, and again
            as data under a key named ``config``, which a schema that forbids
            unknown keys then rejects.
    """

    def __init__(
        self,
        prefix: str = "",
        *,
        environ: Mapping[str, str] | None = None,
        name: str = "env",
        aliases: Mapping[str, str] | None = None,
        read_files: bool = True,
        exclude: Collection[str] = (),
    ) -> None:
        """Build an environment source."""
        self.name = name
        self.exclude = frozenset(exclude)
        self.prefix = validate_prefix(prefix)
        # `environ or os.environ` would quietly reinstate the real environment
        # for a test that deliberately passed an empty one.
        self._environ = os.environ if environ is None else environ
        self._aliases = dict(aliases or {})
        self._read_files = read_files

    def name_for(self, path: KeyPath) -> str:
        """Return the one variable name that maps to a key path.

        Args:
            path: The canonical key path.

        Returns:
            ``"MYAPP_DB__HOST"``.
        """
        return env_name(path, self.prefix)

    def load(self) -> Layer:
        """Read every matching variable, resolving ``_FILE`` indirection.

        Returns:
            The layer.

        Raises:
            ConfigError: If both ``VAR`` and ``VAR_FILE`` are set, or a
                ``_FILE`` target cannot be read.
        """
        entries: dict[KeyPath, Tracked] = {}
        for raw_name, raw_value in sorted(self._environ.items()):
            if raw_name in self.exclude:
                continue
            alias = self._aliases.get(raw_name)
            if alias is not None:
                entries[canonical(alias)] = Tracked(raw_value, Origin(self.name, raw_name))
                continue
            is_file = self._read_files and raw_name.endswith(FILE_SUFFIX)
            lookup = raw_name[: -len(FILE_SUFFIX)] if is_file else raw_name
            path = key_from_env(lookup, self.prefix)
            if path is None:
                continue
            if not is_file:
                entries[path] = Tracked(raw_value, Origin(self.name, raw_name))
                continue
            # Setting both is always a mistake, and silently preferring one is
            # how a rotated secret goes unnoticed. Docker's own helper errors.
            if lookup in self._environ:
                msg = f"both {lookup} and {raw_name} are set; use exactly one to supply this value"
                raise ConfigError(msg)
            entries[path] = Tracked(self._read(raw_value, raw_name), Origin(self.name, raw_name))
        return Layer(self.name, entries, found=bool(entries))

    def _read(self, target: str, var: str) -> str:
        """Read a ``_FILE`` target, stripping the trailing newline."""
        path = Path(target)
        try:
            text = path.read_text(encoding="utf-8")
        except OSError as exc:
            msg = f"{var} points at {target!r}, which could not be read: {exc.strerror}"
            raise ConfigError(msg) from exc
        # `$(< file)` strips trailing newlines, and every editor adds one.
        return text.rstrip("\r\n")

__init__

__init__(
    prefix: str = "",
    *,
    environ: Mapping[str, str] | None = None,
    name: str = "env",
    aliases: Mapping[str, str] | None = None,
    read_files: bool = True,
    exclude: Collection[str] = (),
) -> None

Build an environment source.

Source code in src/whence/sources/env.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def __init__(
    self,
    prefix: str = "",
    *,
    environ: Mapping[str, str] | None = None,
    name: str = "env",
    aliases: Mapping[str, str] | None = None,
    read_files: bool = True,
    exclude: Collection[str] = (),
) -> None:
    """Build an environment source."""
    self.name = name
    self.exclude = frozenset(exclude)
    self.prefix = validate_prefix(prefix)
    # `environ or os.environ` would quietly reinstate the real environment
    # for a test that deliberately passed an empty one.
    self._environ = os.environ if environ is None else environ
    self._aliases = dict(aliases or {})
    self._read_files = read_files

name_for

name_for(path: KeyPath) -> str

Return the one variable name that maps to a key path.

Parameters:

Name Type Description Default
path KeyPath

The canonical key path.

required

Returns:

Type Description
str

"MYAPP_DB__HOST".

Source code in src/whence/sources/env.py
74
75
76
77
78
79
80
81
82
83
def name_for(self, path: KeyPath) -> str:
    """Return the one variable name that maps to a key path.

    Args:
        path: The canonical key path.

    Returns:
        ``"MYAPP_DB__HOST"``.
    """
    return env_name(path, self.prefix)

load

load() -> Layer

Read every matching variable, resolving _FILE indirection.

Returns:

Type Description
Layer

The layer.

Raises:

Type Description
ConfigError

If both VAR and VAR_FILE are set, or a _FILE target cannot be read.

Source code in src/whence/sources/env.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
def load(self) -> Layer:
    """Read every matching variable, resolving ``_FILE`` indirection.

    Returns:
        The layer.

    Raises:
        ConfigError: If both ``VAR`` and ``VAR_FILE`` are set, or a
            ``_FILE`` target cannot be read.
    """
    entries: dict[KeyPath, Tracked] = {}
    for raw_name, raw_value in sorted(self._environ.items()):
        if raw_name in self.exclude:
            continue
        alias = self._aliases.get(raw_name)
        if alias is not None:
            entries[canonical(alias)] = Tracked(raw_value, Origin(self.name, raw_name))
            continue
        is_file = self._read_files and raw_name.endswith(FILE_SUFFIX)
        lookup = raw_name[: -len(FILE_SUFFIX)] if is_file else raw_name
        path = key_from_env(lookup, self.prefix)
        if path is None:
            continue
        if not is_file:
            entries[path] = Tracked(raw_value, Origin(self.name, raw_name))
            continue
        # Setting both is always a mistake, and silently preferring one is
        # how a rotated secret goes unnoticed. Docker's own helper errors.
        if lookup in self._environ:
            msg = f"both {lookup} and {raw_name} are set; use exactly one to supply this value"
            raise ConfigError(msg)
        entries[path] = Tracked(self._read(raw_value, raw_name), Origin(self.name, raw_name))
    return Layer(self.name, entries, found=bool(entries))

FileSource

Loads one configuration file.

Parameters:

Name Type Description Default
path Path | str

The file to read.

required
name str | None

The layer name; defaults to the path itself, which is what makes explain readable when several files are layered.

None
optional bool

When false, a missing file raises. An explicitly named file that does not exist is always a mistake; a discovered one is not.

True
profile str | None

The profile this file belongs to, recorded in every origin it produces.

None
loaders Mapping[str, Loader] | None

A suffix-to-loader table; defaults to the built-ins.

None
Source code in src/whence/sources/files.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
class FileSource:
    """Loads one configuration file.

    Args:
        path: The file to read.
        name: The layer name; defaults to the path itself, which is what makes
            ``explain`` readable when several files are layered.
        optional: When false, a missing file raises. An explicitly named file
            that does not exist is always a mistake; a discovered one is not.
        profile: The profile this file belongs to, recorded in every origin it
            produces.
        loaders: A suffix-to-loader table; defaults to the built-ins.
    """

    def __init__(
        self,
        path: Path | str,
        *,
        name: str | None = None,
        optional: bool = True,
        profile: str | None = None,
        loaders: Mapping[str, Loader] | None = None,
    ) -> None:
        """Build a file source."""
        self.path = Path(path)
        self.name = name or str(path)
        self.optional = optional
        self.profile = profile
        self._loaders = loaders

    def load(self) -> Layer:
        """Read and parse the file.

        Returns:
            The layer, with ``found=False`` when an optional file is absent.

        Raises:
            MissingConfigError: If a required file does not exist.
            FormatError: If the file cannot be parsed.
        """
        if not self.path.is_file():
            if self.optional:
                return Layer(self.name, {}, found=False)
            msg = f"configuration file {self.path} does not exist"
            raise MissingConfigError(msg)
        text = self.path.read_text(encoding="utf-8-sig")
        parse = loader_for(self.path.suffix, self._loaders)
        entries = parse(text, str(self.path))
        if self.profile is not None:
            entries = {
                path: Tracked(value.value, replace(value.origin, profile=self.profile))
                for path, value in entries.items()
            }
        return Layer(self.name, entries, found=True)

__init__

__init__(
    path: Path | str,
    *,
    name: str | None = None,
    optional: bool = True,
    profile: str | None = None,
    loaders: Mapping[str, Loader] | None = None,
) -> None

Build a file source.

Source code in src/whence/sources/files.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def __init__(
    self,
    path: Path | str,
    *,
    name: str | None = None,
    optional: bool = True,
    profile: str | None = None,
    loaders: Mapping[str, Loader] | None = None,
) -> None:
    """Build a file source."""
    self.path = Path(path)
    self.name = name or str(path)
    self.optional = optional
    self.profile = profile
    self._loaders = loaders

load

load() -> Layer

Read and parse the file.

Returns:

Type Description
Layer

The layer, with found=False when an optional file is absent.

Raises:

Type Description
MissingConfigError

If a required file does not exist.

FormatError

If the file cannot be parsed.

Source code in src/whence/sources/files.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def load(self) -> Layer:
    """Read and parse the file.

    Returns:
        The layer, with ``found=False`` when an optional file is absent.

    Raises:
        MissingConfigError: If a required file does not exist.
        FormatError: If the file cannot be parsed.
    """
    if not self.path.is_file():
        if self.optional:
            return Layer(self.name, {}, found=False)
        msg = f"configuration file {self.path} does not exist"
        raise MissingConfigError(msg)
    text = self.path.read_text(encoding="utf-8-sig")
    parse = loader_for(self.path.suffix, self._loaders)
    entries = parse(text, str(self.path))
    if self.profile is not None:
        entries = {
            path: Tracked(value.value, replace(value.origin, profile=self.profile))
            for path, value in entries.items()
        }
    return Layer(self.name, entries, found=True)

MappingSource

A layer built from a Python mapping.

Used for the two ends of the chain -- explicit overrides at the top and schema defaults at the bottom. Defaults are a source rather than a property of the target object on purpose: it means get, dump and explain all agree about what the configuration says. Spring's equivalent split, where @Value cannot see a bean's own defaults, is a genuine bug.

Source code in src/whence/sources/mapping.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class MappingSource:
    """A layer built from a Python mapping.

    Used for the two ends of the chain -- explicit overrides at the top and
    schema defaults at the bottom. Defaults are a source rather than a property
    of the target object on purpose: it means ``get``, ``dump`` and ``explain``
    all agree about what the configuration says. Spring's equivalent split,
    where ``@Value`` cannot see a bean's own defaults, is a genuine bug.
    """

    def __init__(
        self,
        data: Mapping[str, object] | Mapping[KeyPath, object],
        *,
        name: str = "overrides",
        locator: str | None = None,
    ) -> None:
        """Build a source from a flat or nested mapping.

        Args:
            data: Either nested (``{"db": {"host": "h"}}``) or already flat
                (``{("db", "host"): "h"}``).
            name: The layer name, used verbatim by ``explain``.
            locator: What origins should report; defaults to ``<name>``.
        """
        self.name = name
        self._origin = Origin(name, locator or f"<{name}>")
        if all(isinstance(key, tuple) for key in data):
            flat: dict[KeyPath, object] = {canonical(key): value for key, value in data.items()}
        else:
            flat = flatten({str(k): v for k, v in data.items()})
        self._entries = {path: Tracked(value, self._origin) for path, value in flat.items()}

    def load(self) -> Layer:
        """Return the mapping as a layer.

        Returns:
            The layer, marked found when the mapping was non-empty.
        """
        return Layer(self.name, self._entries, found=bool(self._entries))

__init__

__init__(
    data: Mapping[str, object] | Mapping[KeyPath, object],
    *,
    name: str = "overrides",
    locator: str | None = None,
) -> None

Build a source from a flat or nested mapping.

Parameters:

Name Type Description Default
data Mapping[str, object] | Mapping[KeyPath, object]

Either nested ({"db": {"host": "h"}}) or already flat ({("db", "host"): "h"}).

required
name str

The layer name, used verbatim by explain.

'overrides'
locator str | None

What origins should report; defaults to <name>.

None
Source code in src/whence/sources/mapping.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def __init__(
    self,
    data: Mapping[str, object] | Mapping[KeyPath, object],
    *,
    name: str = "overrides",
    locator: str | None = None,
) -> None:
    """Build a source from a flat or nested mapping.

    Args:
        data: Either nested (``{"db": {"host": "h"}}``) or already flat
            (``{("db", "host"): "h"}``).
        name: The layer name, used verbatim by ``explain``.
        locator: What origins should report; defaults to ``<name>``.
    """
    self.name = name
    self._origin = Origin(name, locator or f"<{name}>")
    if all(isinstance(key, tuple) for key in data):
        flat: dict[KeyPath, object] = {canonical(key): value for key, value in data.items()}
    else:
        flat = flatten({str(k): v for k, v in data.items()})
    self._entries = {path: Tracked(value, self._origin) for path, value in flat.items()}

load

load() -> Layer

Return the mapping as a layer.

Returns:

Type Description
Layer

The layer, marked found when the mapping was non-empty.

Source code in src/whence/sources/mapping.py
45
46
47
48
49
50
51
def load(self) -> Layer:
    """Return the mapping as a layer.

    Returns:
        The layer, marked found when the mapping was non-empty.
    """
    return Layer(self.name, self._entries, found=bool(self._entries))

SecretsDirSource

Reads one file per key from a directory.

A file named db.password or db__password becomes the key db.password; both spellings work because Kubernetes secret keys cannot always contain dots.

Parameters:

Name Type Description Default
path Path | str

The directory to read.

'/run/secrets'
name str

The layer name.

'secrets-dir'
Source code in src/whence/sources/secrets.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class SecretsDirSource:
    """Reads one file per key from a directory.

    A file named ``db.password`` or ``db__password`` becomes the key
    ``db.password``; both spellings work because Kubernetes secret keys cannot
    always contain dots.

    Args:
        path: The directory to read.
        name: The layer name.
    """

    def __init__(self, path: Path | str = "/run/secrets", *, name: str = "secrets-dir") -> None:
        """Build a secrets-directory source."""
        self.path = Path(path)
        self.name = name

    def load(self) -> Layer:
        """Read every regular file in the directory.

        Returns:
            The layer, with ``found=False`` when the directory is absent.
        """
        if not self.path.is_dir():
            return Layer(self.name, {}, found=False)
        entries: dict[KeyPath, Tracked] = {}
        for child in sorted(self.path.iterdir()):
            # Kubernetes projects secrets through a `..data` symlink and hides
            # the real files under `..2024_01_01_00_00_00.123456789/`.
            if child.name.startswith(".") or not child.is_file():
                continue
            text = child.read_text(encoding="utf-8")
            key = canonical(child.name.replace("__", "."))
            entries[key] = Tracked(text.rstrip("\r\n"), Origin(self.name, str(child)))
        return Layer(self.name, entries, found=bool(entries))

__init__

__init__(
    path: Path | str = "/run/secrets",
    *,
    name: str = "secrets-dir",
) -> None

Build a secrets-directory source.

Source code in src/whence/sources/secrets.py
38
39
40
41
def __init__(self, path: Path | str = "/run/secrets", *, name: str = "secrets-dir") -> None:
    """Build a secrets-directory source."""
    self.path = Path(path)
    self.name = name

load

load() -> Layer

Read every regular file in the directory.

Returns:

Type Description
Layer

The layer, with found=False when the directory is absent.

Source code in src/whence/sources/secrets.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def load(self) -> Layer:
    """Read every regular file in the directory.

    Returns:
        The layer, with ``found=False`` when the directory is absent.
    """
    if not self.path.is_dir():
        return Layer(self.name, {}, found=False)
    entries: dict[KeyPath, Tracked] = {}
    for child in sorted(self.path.iterdir()):
        # Kubernetes projects secrets through a `..data` symlink and hides
        # the real files under `..2024_01_01_00_00_00.123456789/`.
        if child.name.startswith(".") or not child.is_file():
            continue
        text = child.read_text(encoding="utf-8")
        key = canonical(child.name.replace("__", "."))
        entries[key] = Tracked(text.rstrip("\r\n"), Origin(self.name, str(child)))
    return Layer(self.name, entries, found=bool(entries))

Source

Bases: Protocol

Something that can contribute one layer of configuration.

Source code in src/whence/sources/__init__.py
36
37
38
39
40
41
42
43
44
45
46
47
48
@runtime_checkable
class Source(Protocol):
    """Something that can contribute one layer of configuration."""

    name: str

    def load(self) -> Layer:
        """Read everything this source has to offer.

        Returns:
            One layer, possibly empty, named after this source.
        """
        ...

load

load() -> Layer

Read everything this source has to offer.

Returns:

Type Description
Layer

One layer, possibly empty, named after this source.

Source code in src/whence/sources/__init__.py
42
43
44
45
46
47
48
def load(self) -> Layer:
    """Read everything this source has to offer.

    Returns:
        One layer, possibly empty, named after this source.
    """
    ...

Layer dataclass

Everything one source contributed, under its own name.

Attributes:

Name Type Description
name str

The source's name in the chain, used verbatim by explain.

entries Mapping[KeyPath, Tracked]

Flat canonical key paths to tracked values.

found bool

Whether the source produced anything. A source that was consulted and found nothing still appears in explain, because absence is information.

Source code in src/whence/tree.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@dataclass(frozen=True, slots=True)
class Layer:
    """Everything one source contributed, under its own name.

    Attributes:
        name: The source's name in the chain, used verbatim by ``explain``.
        entries: Flat canonical key paths to tracked values.
        found: Whether the source produced anything. A source that was consulted
            and found nothing still appears in ``explain``, because absence is
            information.
    """

    name: str
    entries: Mapping[KeyPath, Tracked] = field(default_factory=dict)
    found: bool = True

Resolved dataclass

The outcome of merging layers, with the losers kept.

Attributes:

Name Type Description
values Mapping[KeyPath, Tracked]

The winning value for every key.

shadowed Mapping[KeyPath, tuple[tuple[str, Tracked], ...]]

Per key, the (layer name, value) pairs that lost, in precedence order.

layers tuple[Layer, ...]

Every layer consulted, highest precedence first, including the ones that contributed nothing.

Source code in src/whence/tree.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@dataclass(frozen=True, slots=True)
class Resolved:
    """The outcome of merging layers, with the losers kept.

    Attributes:
        values: The winning value for every key.
        shadowed: Per key, the ``(layer name, value)`` pairs that lost, in
            precedence order.
        layers: Every layer consulted, highest precedence first, including the
            ones that contributed nothing.
    """

    values: Mapping[KeyPath, Tracked]
    shadowed: Mapping[KeyPath, tuple[tuple[str, Tracked], ...]]
    layers: tuple[Layer, ...]

    def keys(self) -> Iterable[KeyPath]:
        """Return every key that has a winning value.

        Returns:
            The resolved key paths.
        """
        return self.values.keys()

    def dotted(self) -> dict[str, Tracked]:
        """Return the resolved values keyed by dotted name.

        Returns:
            A mapping from ``"db.host"`` to its tracked value.
        """
        return {join(path): value for path, value in self.values.items()}

keys

keys() -> Iterable[KeyPath]

Return every key that has a winning value.

Returns:

Type Description
Iterable[KeyPath]

The resolved key paths.

Source code in src/whence/tree.py
60
61
62
63
64
65
66
def keys(self) -> Iterable[KeyPath]:
    """Return every key that has a winning value.

    Returns:
        The resolved key paths.
    """
    return self.values.keys()

dotted

dotted() -> dict[str, Tracked]

Return the resolved values keyed by dotted name.

Returns:

Type Description
dict[str, Tracked]

A mapping from "db.host" to its tracked value.

Source code in src/whence/tree.py
68
69
70
71
72
73
74
def dotted(self) -> dict[str, Tracked]:
    """Return the resolved values keyed by dotted name.

    Returns:
        A mapping from ``"db.host"`` to its tracked value.
    """
    return {join(path): value for path, value in self.values.items()}

bind

bind(
    target: type,
    values: Mapping[KeyPath, Tracked],
    *,
    prefix: KeyPath = (),
    shadowed: Mapping[
        KeyPath, tuple[tuple[str, Tracked], ...]
    ]
    | None = None,
    strict: bool = True,
) -> Any

Bind resolved configuration onto a schema, reporting every problem.

Parameters:

Name Type Description Default
target type

The schema class.

required
values Mapping[KeyPath, Tracked]

Flat resolved values.

required
prefix KeyPath

The subtree to bind.

()
shadowed Mapping[KeyPath, tuple[tuple[str, Tracked], ...]] | None

Shadow records, used to enrich error messages.

None
strict bool

Whether a key nothing declared is an error.

True

Returns:

Type Description
Any

An instance of target.

Raises:

Type Description
BindError

If anything failed, with every problem in one message.

Source code in src/whence/binding/__init__.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
def bind(
    target: type,
    values: Mapping[KeyPath, Tracked],
    *,
    prefix: KeyPath = (),
    shadowed: Mapping[KeyPath, tuple[tuple[str, Tracked], ...]] | None = None,
    strict: bool = True,
) -> Any:
    """Bind resolved configuration onto a schema, reporting every problem.

    Args:
        target: The schema class.
        values: Flat resolved values.
        prefix: The subtree to bind.
        shadowed: Shadow records, used to enrich error messages.
        strict: Whether a key nothing declared is an error.

    Returns:
        An instance of ``target``.

    Raises:
        BindError: If anything failed, with every problem in one message.
    """
    binder = choose_binder(target)
    problems: list[Problem] = []
    instance = binder.bind(target, values, prefix, problems)

    if strict:
        declared = binder.declared(target, prefix)
        depth = len(prefix)
        for path, tracked in values.items():
            if path[:depth] != prefix or len(path) <= depth or path in declared:
                continue
            hint = suggest(path, declared)
            reason = "no such setting"
            if hint:
                reason = f"{reason} - did you mean {hint[0]!r}?"
            problems.append(Problem(join(path), tracked.value, tracked.origin, reason))

    if problems:
        if shadowed:
            problems = [replace(p, shadowed=_shadow_text(p.key, shadowed)) for p in problems]
        raise BindError(render_problems(problems))
    return instance

render_problems

render_problems(problems: Sequence[Problem]) -> str

Format problems the way whence reports them.

The four-field Property / Value / Origin / Reason block plus an Action line is Spring Boot's failure-analysis layout, which is the best in the field and costs nothing to adopt.

Parameters:

Name Type Description Default
problems Sequence[Problem]

The problems to render.

required

Returns:

Type Description
str

A multi-line message.

Source code in src/whence/binding/__init__.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def render_problems(problems: Sequence[Problem]) -> str:
    """Format problems the way whence reports them.

    The four-field ``Property / Value / Origin / Reason`` block plus an
    ``Action`` line is Spring Boot's failure-analysis layout, which is the best
    in the field and costs nothing to adopt.

    Args:
        problems: The problems to render.

    Returns:
        A multi-line message.
    """
    count = len(problems)
    lines = [f"{count} error{'s' if count != 1 else ''}", ""]
    for problem in problems:
        lines.append(f"  Property: {problem.key}")
        if problem.value is not None:
            lines.append(f"     Value: {sanitize(problem.key, problem.value)!r}")
        if problem.origin is not None:
            lines.append(f"    Origin: {problem.origin}")
        lines.append(f"    Reason: {problem.reason}")
        lines.extend(f"  Shadowed: {entry}" for entry in problem.shadowed)
        lines.append("")
    lines.append("Action: correct the configuration, or run `whence explain <key>`.")
    return "\n".join(lines)

configure

configure(config: Config | None) -> None

Set the ambient configuration for this context.

Parameters:

Name Type Description Default
config Config | None

The configuration, or None to clear it.

required
Source code in src/whence/decorators.py
102
103
104
105
106
107
108
def configure(config: Config | None) -> None:
    """Set the ambient configuration for this context.

    Args:
        config: The configuration, or ``None`` to clear it.
    """
    _CURRENT.set(config)

current_config

current_config(config: Config) -> Generator[Config]

Use a configuration for the duration of a block.

Parameters:

Name Type Description Default
config Config

The configuration to install.

required

Yields:

Type Description
Generator[Config]

The same configuration.

Source code in src/whence/decorators.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
@contextmanager
def current_config(config: Config) -> Generator[Config]:
    """Use a configuration for the duration of a block.

    Args:
        config: The configuration to install.

    Yields:
        The same configuration.
    """
    token = _CURRENT.set(config)
    try:
        yield config
    finally:
        _CURRENT.reset(token)

from_config

from_config(fn: Callable[P, R]) -> Callable[..., R]

Fill parameters annotated :data:Injected or :class:Value from configuration.

The wrapper is typed Callable[..., R] rather than Callable[P, R], and that is the one real cost of putting the markers in Annotated. A marked parameter with no default is required in the signature a type checker reads, so connect() would be an error at every call site even though filling it is the entire point. Nothing in the type system can say "these particular keyword parameters are now optional", so the return type says the honest thing instead: after decoration, the arguments are whichever subset the caller chooses to pass. The return type is still checked, and the runtime signature is untouched -- inspect.signature and every framework that reads it see the parameters exactly as written.

Parameters:

Name Type Description Default
fn Callable[P, R]

The function to wrap; sync or async.

required

Returns:

Type Description
Callable[..., R]

The wrapped function, accepting any subset of its own arguments.

Raises:

Type Description
ConfigError

If a marked parameter is not keyword-only, carries two markers, or combines Injected with an unreachable default.

Source code in src/whence/decorators.py
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
def from_config[**P, R](fn: Callable[P, R]) -> Callable[..., R]:
    """Fill parameters annotated :data:`Injected` or :class:`Value` from configuration.

    The wrapper is typed ``Callable[..., R]`` rather than ``Callable[P, R]``,
    and that is the one real cost of putting the markers in ``Annotated``. A
    marked parameter with no default is *required* in the signature a type
    checker reads, so ``connect()`` would be an error at every call site even
    though filling it is the entire point. Nothing in the type system can say
    "these particular keyword parameters are now optional", so the return type
    says the honest thing instead: after decoration, the arguments are whichever
    subset the caller chooses to pass. The return type is still checked, and the
    runtime signature is untouched -- ``inspect.signature`` and every framework
    that reads it see the parameters exactly as written.

    Args:
        fn: The function to wrap; sync or async.

    Returns:
        The wrapped function, accepting any subset of its own arguments.

    Raises:
        ConfigError: If a marked parameter is not keyword-only, carries two
            markers, or combines ``Injected`` with an unreachable default.
    """
    plan = _plan(fn)

    def fill(kwargs: dict[str, Any]) -> None:
        # Nothing left to fill means nothing to look up: a caller who passes every
        # marked argument -- a test, typically -- needs no ambient configuration at
        # all, and demanding one would make `@from_config` contagious.
        pending = [entry for entry in plan if entry[0] not in kwargs]
        if not pending:
            return
        config = _ambient()
        for name, marker, annotation, default in pending:
            if isinstance(marker, Value):
                kwargs[name] = (
                    config.require(marker.key, type_=annotation)
                    if default is _NO_DEFAULT
                    else config.get(marker.key, default, type_=annotation)
                )
            else:
                kwargs[name] = config.bind(annotation)

    if inspect.iscoroutinefunction(fn):

        @functools.wraps(fn)
        async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
            fill(cast("dict[str, Any]", kwargs))
            return await fn(*args, **kwargs)

        return cast("Callable[..., R]", async_wrapper)

    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        fill(cast("dict[str, Any]", kwargs))
        return fn(*args, **kwargs)

    return wrapper

load_settings

load_settings(
    cls: type[T],
    config: Config | None = None,
    *,
    strict: bool | None = None,
) -> T

Bind a @settings class from a configuration.

The typed spelling of config.bind(cls), and the only one: @settings attaches no methods to the class it decorates, because an attribute added by a decorator is invisible to a type checker.

Parameters:

Name Type Description Default
cls type[T]

A class decorated with :func:settings.

required
config Config | None

The configuration; defaults to the ambient one.

None
strict bool | None

Override the strictness @settings recorded on the class.

None

Returns:

Type Description
T

An instance of cls.

Source code in src/whence/decorators.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
def load_settings[T](
    cls: type[T], config: Config | None = None, *, strict: bool | None = None
) -> T:
    """Bind a ``@settings`` class from a configuration.

    The typed spelling of ``config.bind(cls)``, and the only one: ``@settings``
    attaches no methods to the class it decorates, because an attribute added by
    a decorator is invisible to a type checker.

    Args:
        cls: A class decorated with :func:`settings`.
        config: The configuration; defaults to the ambient one.
        strict: Override the strictness ``@settings`` recorded on the class.

    Returns:
        An instance of ``cls``.
    """
    target = config if config is not None else _ambient()
    return cast("T", target.bind(cls, strict=strict))

settings

settings(prefix: type[T]) -> type[T]
settings(
    prefix: str = "", *, strict: bool = True
) -> Callable[[type[T]], type[T]]
settings(prefix: Any = '', *, strict: bool = True) -> Any

Mark a class as binding to a subtree of configuration.

Usable bare or called, like :func:~dataclasses.dataclass: @settings binds the whole tree, @settings("db") binds one subtree.

Parameters:

Name Type Description Default
prefix Any

The dotted subtree, such as "db".

''
strict bool

Whether a key under prefix that this class does not declare is an error. True is the right default -- a typo'd key is the most common configuration bug and is otherwise invisible -- but a class that deliberately covers only part of its subtree sets it False.

True

Returns:

Type Description
Any

The decorated class when used bare, otherwise a class decorator.

Source code in src/whence/decorators.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def settings(prefix: Any = "", *, strict: bool = True) -> Any:
    """Mark a class as binding to a subtree of configuration.

    Usable bare or called, like :func:`~dataclasses.dataclass`: ``@settings``
    binds the whole tree, ``@settings("db")`` binds one subtree.

    Args:
        prefix: The dotted subtree, such as ``"db"``.
        strict: Whether a key under ``prefix`` that this class does not declare
            is an error. True is the right default -- a typo'd key is the most
            common configuration bug and is otherwise invisible -- but a class
            that deliberately covers only part of its subtree sets it False.

    Returns:
        The decorated class when used bare, otherwise a class decorator.
    """

    def decorate(cls: type[Any], subtree: str) -> type[Any]:
        # Class-level metadata, read by `Config.bind`. The same shape the
        # standard library uses for `__dataclass_fields__`.
        cls.__whence_prefix__ = subtree
        cls.__whence_strict__ = strict
        return cls

    if isinstance(prefix, type):
        return decorate(prefix, "")
    return lambda cls: decorate(cls, prefix)

canonical

canonical(key: str | Iterable[str]) -> KeyPath

Normalise a dotted key or a sequence of segments into a key path.

Parameters:

Name Type Description Default
key str | Iterable[str]

"db.max-retries", "DB.MaxRetries" or ("db", "max_retries").

required

Returns:

Type Description
KeyPath

The canonical path, lowercase with - folded to _.

Raises:

Type Description
ConfigError

If the key is empty or contains an empty segment.

Source code in src/whence/keys.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def canonical(key: str | Iterable[str]) -> KeyPath:
    """Normalise a dotted key or a sequence of segments into a key path.

    Args:
        key: ``"db.max-retries"``, ``"DB.MaxRetries"`` or ``("db", "max_retries")``.

    Returns:
        The canonical path, lowercase with ``-`` folded to ``_``.

    Raises:
        ConfigError: If the key is empty or contains an empty segment.
    """
    parts = key.split(".") if isinstance(key, str) else list(key)
    if not parts:
        msg = "empty configuration key"
        raise ConfigError(msg)
    out: list[str] = []
    for part in parts:
        segment = part.strip().replace("-", "_").lower()
        if not segment:
            msg = f"empty segment in configuration key {key!r}"
            raise ConfigError(msg)
        out.append(segment)
    return tuple(out)

env_name

env_name(path: KeyPath, prefix: str = '') -> str

Render the one environment variable name that maps to a key path.

Parameters:

Name Type Description Default
path KeyPath

The canonical key path.

required
prefix str

The environment prefix, already validated.

''

Returns:

Type Description
str

"MYAPP_DB__MAX_RETRIES".

Source code in src/whence/keys.py
113
114
115
116
117
118
119
120
121
122
123
def env_name(path: KeyPath, prefix: str = "") -> str:
    """Render the one environment variable name that maps to a key path.

    Args:
        path: The canonical key path.
        prefix: The environment prefix, already validated.

    Returns:
        ``"MYAPP_DB__MAX_RETRIES"``.
    """
    return prefix + NESTING.join(segment.upper() for segment in path)

key_from_env

key_from_env(name: str, prefix: str = '') -> KeyPath | None

Map an environment variable name back to a key path.

Parameters:

Name Type Description Default
name str

The variable name as it appears in the environment.

required
prefix str

The environment prefix, already validated.

''

Returns:

Type Description
KeyPath | None

The key path, or None if the name does not carry the prefix or has

KeyPath | None

an empty segment.

Source code in src/whence/keys.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
def key_from_env(name: str, prefix: str = "") -> KeyPath | None:
    """Map an environment variable name back to a key path.

    Args:
        name: The variable name as it appears in the environment.
        prefix: The environment prefix, already validated.

    Returns:
        The key path, or ``None`` if the name does not carry the prefix or has
        an empty segment.
    """
    if prefix and not name.startswith(prefix):
        return None
    body = name[len(prefix) :]
    if not body:
        return None
    segments = body.split(NESTING)
    if any(not segment for segment in segments):
        return None
    return tuple(segment.lower() for segment in segments)

origin_of

origin_of(obj: object) -> Origin | None

Return the origin of a value, or None if it carries none.

Parameters:

Name Type Description Default
obj object

Any value.

required

Returns:

Type Description
Origin | None

The origin, when obj is :class:Tracked.

Source code in src/whence/origin.py
123
124
125
126
127
128
129
130
131
132
def origin_of(obj: object) -> Origin | None:
    """Return the origin of a value, or ``None`` if it carries none.

    Args:
        obj: Any value.

    Returns:
        The origin, when ``obj`` is :class:`Tracked`.
    """
    return obj.origin if isinstance(obj, Tracked) else None

unwrap

unwrap(obj: object) -> Any

Strip tracking from a value, recursively through containers.

Parameters:

Name Type Description Default
obj object

A tracked value, a plain value, or a container of either.

required

Returns:

Type Description
Any

The same shape with every :class:Tracked replaced by its value.

Source code in src/whence/origin.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def unwrap(obj: object) -> Any:
    """Strip tracking from a value, recursively through containers.

    Args:
        obj: A tracked value, a plain value, or a container of either.

    Returns:
        The same shape with every :class:`Tracked` replaced by its value.
    """
    if isinstance(obj, Tracked):
        return unwrap(obj.value)
    if isinstance(obj, dict):
        return {k: unwrap(v) for k, v in obj.items()}
    if isinstance(obj, list):
        return [unwrap(v) for v in obj]
    if isinstance(obj, tuple):
        return tuple(unwrap(v) for v in obj)
    return obj

active_profiles

active_profiles(
    explicit: Sequence[str] | None = None,
    *,
    environ: Mapping[str, str] | None = None,
    var: str = "",
    groups: Mapping[str, Sequence[str]] | None = None,
    default: Sequence[str] = (),
) -> tuple[str, ...]

Decide which profiles are active.

Parameters:

Name Type Description Default
explicit Sequence[str] | None

Profiles passed in code; wins over the environment.

None
environ Mapping[str, str] | None

The environment to consult.

None
var str

The variable holding a comma-separated list.

''
groups Mapping[str, Sequence[str]] | None

Group definitions to expand.

None
default Sequence[str]

Profiles to use when nothing else says.

()

Returns:

Type Description
tuple[str, ...]

Active profiles in increasing precedence, so the last one wins.

Source code in src/whence/profiles.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def active_profiles(
    explicit: Sequence[str] | None = None,
    *,
    environ: Mapping[str, str] | None = None,
    var: str = "",
    groups: Mapping[str, Sequence[str]] | None = None,
    default: Sequence[str] = (),
) -> tuple[str, ...]:
    """Decide which profiles are active.

    Args:
        explicit: Profiles passed in code; wins over the environment.
        environ: The environment to consult.
        var: The variable holding a comma-separated list.
        groups: Group definitions to expand.
        default: Profiles to use when nothing else says.

    Returns:
        Active profiles in increasing precedence, so the last one wins.
    """
    if explicit is not None:
        chosen: Sequence[str] = explicit
    elif var and environ and (raw := environ.get(var, "").strip()):
        chosen = raw.split(",")
    else:
        chosen = default
    return expand_groups(chosen, groups)

expand_groups

expand_groups(
    profiles: Sequence[str],
    groups: Mapping[str, Sequence[str]] | None = None,
    _depth: int = 0,
) -> tuple[str, ...]

Expand profile groups into the profiles they stand for.

groups={"staging": ["cloud", "readonly-db"]} turns ["staging"] into ["cloud", "readonly-db", "staging"]. Spring added groups in 2.4 to replace chained include directives, and they are considerably easier to read than the chain they replaced.

Parameters:

Name Type Description Default
profiles Sequence[str]

The requested profiles, in increasing precedence.

required
groups Mapping[str, Sequence[str]] | None

Group definitions.

None
_depth int

Recursion guard.

0

Returns:

Type Description
tuple[str, ...]

The expanded profiles, deduplicated, in increasing precedence.

Raises:

Type Description
ConfigError

If groups reference each other in a cycle.

Source code in src/whence/profiles.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def expand_groups(
    profiles: Sequence[str],
    groups: Mapping[str, Sequence[str]] | None = None,
    _depth: int = 0,
) -> tuple[str, ...]:
    """Expand profile groups into the profiles they stand for.

    ``groups={"staging": ["cloud", "readonly-db"]}`` turns ``["staging"]`` into
    ``["cloud", "readonly-db", "staging"]``. Spring added groups in 2.4 to
    replace chained ``include`` directives, and they are considerably easier to
    read than the chain they replaced.

    Args:
        profiles: The requested profiles, in increasing precedence.
        groups: Group definitions.
        _depth: Recursion guard.

    Returns:
        The expanded profiles, deduplicated, in increasing precedence.

    Raises:
        ConfigError: If groups reference each other in a cycle.
    """
    if _depth > MAX_GROUP_DEPTH:
        msg = f"profile groups nest more than {MAX_GROUP_DEPTH} deep; check for a cycle"
        raise ConfigError(msg)
    table = {k.strip().lower(): v for k, v in (groups or {}).items()}
    out: list[str] = []
    for raw in profiles:
        name = raw.strip().lower()
        if not name:
            continue
        members = table.get(name)
        if members:
            out.extend(expand_groups(list(members), table, _depth + 1))
        out.append(name)
    # dict preserves first-insertion order, which is the dedup this needs.
    return tuple(dict.fromkeys(out))

is_sensitive

is_sensitive(
    key: str, patterns: Sequence[str] = SENSITIVE
) -> bool

Report whether a key name looks like it holds a secret.

Parameters:

Name Type Description Default
key str

A dotted key name.

required
patterns Sequence[str]

Glob patterns to match against, case-insensitively.

SENSITIVE

Returns:

Type Description
bool

True when any pattern matches.

Source code in src/whence/secret.py
116
117
118
119
120
121
122
123
124
125
126
127
def is_sensitive(key: str, patterns: Sequence[str] = SENSITIVE) -> bool:
    """Report whether a key name looks like it holds a secret.

    Args:
        key: A dotted key name.
        patterns: Glob patterns to match against, case-insensitively.

    Returns:
        True when any pattern matches.
    """
    lowered = key.lower()
    return any(fnmatch.fnmatchcase(lowered, pattern) for pattern in patterns)

sanitize

sanitize(
    key: str,
    value: Any,
    patterns: Sequence[str] = SENSITIVE,
) -> Any

Redact a value if its key or its type says it is sensitive.

A :class:Secret is masked whatever it is called, and a value is masked when its key matches. Both directions matter: convict masks by declared sensitivity, Spring masks by key pattern, and each misses what the other catches.

Parameters:

Name Type Description Default
key str

The dotted key name.

required
value Any

The value.

required
patterns Sequence[str]

Key patterns to treat as sensitive.

SENSITIVE

Returns:

Type Description
Any

Either the value or :data:MASK.

Source code in src/whence/secret.py
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
def sanitize(key: str, value: Any, patterns: Sequence[str] = SENSITIVE) -> Any:
    """Redact a value if its key or its type says it is sensitive.

    A :class:`Secret` is masked whatever it is called, and a value is masked
    when its key matches. Both directions matter: convict masks by declared
    sensitivity, Spring masks by key pattern, and each misses what the other
    catches.

    Args:
        key: The dotted key name.
        value: The value.
        patterns: Key patterns to treat as sensitive.

    Returns:
        Either the value or :data:`MASK`.
    """
    if isinstance(value, Secret) or is_sensitive(key, patterns):
        return MASK
    return value

unlock_secrets

unlock_secrets() -> Generator[None]

Permit :meth:Secret.reveal for the duration of the block.

The flag lives in a :class:~contextvars.ContextVar, so it does not leak across tasks and a concurrent request cannot ride on another's unlock.

Yields:

Type Description
Generator[None]

Nothing.

Source code in src/whence/secret.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
@contextmanager
def unlock_secrets() -> Generator[None]:
    """Permit :meth:`Secret.reveal` for the duration of the block.

    The flag lives in a :class:`~contextvars.ContextVar`, so it does not leak
    across tasks and a concurrent request cannot ride on another's unlock.

    Yields:
        Nothing.
    """
    token = _UNLOCKED.set(True)
    try:
        yield
    finally:
        _UNLOCKED.reset(token)