Application settings¶
talea.settings loads a concrete Talea Spec from explicit application
configuration sources. It is a boundary around the existing model and input
machinery, not a second model hierarchy. The resulting settings value is an
ordinary immutable Spec instance: it can use the usual typing, validation,
serialization, JSON Schema, and OpenAPI operations.
Import the domain explicitly:
import talea does not import Settings, TOML, filesystem, or environment
machinery. No settings symbol is exported from the root package.
When to use it¶
Use Settings when an application has one concrete configuration model and needs deterministic composition of:
- an explicit Python Mapping override;
- a fresh process-environment snapshot;
- one explicit flat local-secrets directory;
- one explicit TOML file;
- normal Spec defaults and factories.
Settings does not own command-line parsing, .env syntax, deployment
profiles, remote secret managers, framework startup, file watching, or mutable
global configuration. Those systems may produce a Mapping or arrange the
explicit files and environment that a Settings plan consumes.
Complete production example¶
"""Load a service configuration from TOML, environment, and a test override."""
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Annotated
from talea import Alias, Sensitive, Spec
from talea.settings import Settings
class DatabaseSettings(Spec):
host: str
port: int = 5432
password: Annotated[str, Sensitive()]
class HttpSettings(Spec):
bind: str = "127.0.0.1"
port: int = 8000
class FeatureSettings(Spec):
audit_log: bool = False
new_checkout: bool = False
class ServiceSettings(Spec):
database_config: Annotated[DatabaseSettings, Alias("database", legacy=("db",))]
http: HttpSettings
features: FeatureSettings
with TemporaryDirectory() as temporary:
root = Path(temporary)
config = root / "service.toml"
config.write_text(
"""
[database]
host = "db.internal"
port = 5432
[http]
bind = "0.0.0.0"
port = 8000
[features]
audit_log = true
new_checkout = false
""".strip(),
encoding="utf-8",
)
secrets = root / "secrets"
secrets.mkdir()
(secrets / "database__password").write_text("mounted-password\n", encoding="utf-8")
loader = Settings(
ServiceSettings,
prefix="SERVICE_",
toml=config,
secrets=secrets,
)
loaded = loader.load(
{"http": {"port": 9000}},
environment={"SERVICE_DATABASE__PORT": "6432"},
provenance=True,
)
assert loaded.value.database_config.host == "db.internal" # TOML
assert loaded.value.database_config.port == 6432 # environment
assert loaded.value.database_config.password == "mounted-password" # secret file
assert loaded.value.http.bind == "0.0.0.0" # TOML sibling survives
assert loaded.value.http.port == 9000 # explicit override
assert loaded.value.features.audit_log is True
assert loaded.provenance[("database_config", "host")] == "toml"
assert loaded.provenance[("database_config", "port")] == "environment"
assert loaded.provenance[("database_config", "password")] == "secret"
assert loaded.provenance[("http", "port")] == "override"
assert "mounted-password" not in repr(loaded.value)
The retained Settings(ServiceSettings, ...) value is an immutable source
plan. It stores the model, normalized source names, paths, prefix, case policy,
and resource policy. It never stores an environment snapshot, file contents,
secret values, a previous merge, or a loaded model.
Deterministic precedence¶
The only source order is:
- explicit override Mapping;
- environment;
- secrets directory;
- TOML;
- Spec defaults.
Precedence is applied to canonical field leaves. A higher source replaces only
the leaf it supplies. If TOML supplies database.host and database.port, and
the environment supplies only database.port, the TOML host survives. An
empty higher nested Mapping contributes no leaves and erases nothing.
Aliases do not create a second precedence system. Within one source, supplying
both a current and historical name is always alias_conflict, even when the
values compare equal. Across two sources, the higher source wins after both
names resolve to the same canonical leaf.
Defaults are not copied into the source map. The final merged Mapping is sent
to Spec.from_mapping(), so omitted static defaults and factories retain their
normal owner. A factory runs exactly once only when construction actually needs
it; plan creation and source inspection never call it.
Environment¶
Every load() copies its environment input once. With
environment=None, the source is os.environ. A caller may pass a finite
Mapping[str, str] for tests and controlled embedding. Settings never calls
os.reload_environ(), never mutates os.environ, and never retains a live
os._Environ object.
The plan accepts an explicit prefix; its default is the empty string. Prefixes are never derived from a class name or module:
Nested object fields use the fixed __ delimiter:
Only Spec, dataclass, TypedDict, and optional forms of those object shapes are flattened. There is no list index, mapping key, wildcard, escape, or query grammar. Containers, tagged unions, and recursive back-edges are textual leaves.
case_sensitive=False is the default and uses Unicode casefold() on both
compiled names and supplied keys, independent of platform behavior. Any
collision after normalization rejects the plan or load; differently cased
keys never gain an implicit order. case_sensitive=True requires the exact
declared alias/field spelling after the exact supplied prefix.
Unknown environment names are ignored. This permits a process to have unrelated variables without treating its entire environment as the settings schema. Known names are compiled once and looked up through one normalized snapshot rather than scanning fields for each key.
Textual conversion¶
Environment variables and secret files contain text. Conversion belongs only
to this settings boundary; Spec.from_mapping() remains strict and does not
begin accepting strings for integers, booleans, dates, or other Python values.
After textual syntax is decoded, normal Mapping input still owns constraints,
Representations, nested conversion, hooks, errors, and final construction.
The exact scalar policy is:
| Schema | Accepted text |
|---|---|
str |
exact text, including whitespace and the word null |
int |
JSON-style base-10 integer syntax, with no leading zero or surrounding whitespace |
float |
finite JSON-style decimal/exponent syntax |
bool |
exactly true or false |
Decimal |
finite decimal text |
UUID |
standard UUID text |
date, datetime, time |
their standard-library ISO formats |
timedelta |
Talea's ISO 8601 duration subset, such as P2DT3H4M5.25S |
bytes |
canonical padded RFC 4648 base64 |
| IP address/network/interface | standard ipaddress constructor text |
| path types | the corresponding pathlib constructor text |
Enum and Literal |
their exact JSON scalar representation; string members also use exact text |
For T | None, the exact token null means None; other text follows T.
For a plain str, null remains the four-character string. General unions
use JSON syntax to identify a unique JSON-shaped branch. If multiple branches
share a shape, Settings does not invent coercion priority; the decoded JSON
value proceeds to ordinary Talea union validation.
Lists, sets, frozensets, tuples, mappings, recursive objects, tagged unions,
and other structural leaves use JSON text. There is no CSV-like delimiter
language. JSON-native values remain strict: list[int] accepts [1, 2], not
["1", "2"]. Standard JSON string representations inside containers, such
as UUID or base64 bytes, are decoded from the same canonical schema truth.
A Representation field is decoded according to Representation.input, then
the normal Mapping boundary calls its loader exactly once. Output-only
Representations cannot be loaded from Settings because the model itself has
no Mapping input direction for that position.
TOML¶
Pass one explicit file with toml=.... Settings performs no parent search,
home-directory search, filename convention, profile selection, or multi-file
merge. A configured missing file raises FileNotFoundError; a directory raises
IsADirectoryError; invalid UTF-8 and malformed input raise value-free
ValueErrors. The malformed error retains only its source kind and safe
line/column, never the complete TOML document retained by the stdlib decoder.
TOML is parsed only by the standard library tomllib. Its integers, floats,
booleans, arrays, dates, times, and nested tables remain typed Python values
and proceed directly to strict Mapping validation. They are not converted to
strings and reparsed. Current and historical aliases are recognized at each
nested object segment with the configured case policy.
The byte limit is checked with a bounded read before tomllib sees the input.
Concurrent application or deployment replacement of the file is not a
filesystem-transaction guarantee; one load owns the bounded bytes it read and
publishes either a complete snapshot or no value.
Local secrets directory¶
Pass one explicit directory with secrets=.... The directory is flat:
one file name is one source name, and __ expresses nested object fields.
The environment prefix does not apply to secret filenames. Unknown files are
ignored as values but count toward the file limit; subdirectories are not
walked.
Files are strict UTF-8 text. Settings removes exactly one terminal \n, or
one terminal \r\n, to support common mounted-secret files. It does not call
.strip(): leading/trailing spaces and additional lines remain data.
Symlinked files are allowed when their resolved target remains inside the resolved explicit root. This supports Kubernetes atomic-writer layouts while rejecting a secret filename that escapes to an arbitrary target. The explicit root itself may be a symlink. Broken links fail the load, directories are not recursed, and each selected target is read with a byte bound. Applications and platforms still own concurrent filesystem mutation and deployment-level permissions.
All validation failures from a secret-backed load are returned as a redacted
ValidationError, even if an application forgot to mark the target field
Sensitive. The returned error retains no secret content or callback cause.
Sensitive remains important for successful snapshots, environment/TOML/
override errors, repr behavior, and every ordinary Talea operation.
Kubernetes and container mounts¶
"""Model a Kubernetes-style environment and atomic-writer secret mount."""
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Annotated
from talea import Sensitive, Spec
from talea.settings import Settings
class Database(Spec):
host: str
port: int
password: Annotated[str, Sensitive()]
class Deployment(Spec):
database: Database
replicas: int = 2
with TemporaryDirectory() as temporary:
mount = Path(temporary) / "mounted-secrets"
version = mount / "..2026_09_01"
version.mkdir(parents=True)
(version / "database__password").write_text("first-secret\n", encoding="utf-8")
(mount / "..data").symlink_to(version.name, target_is_directory=True)
(mount / "database__password").symlink_to("..data/database__password")
loader = Settings(Deployment, prefix="APP_", secrets=mount)
config_map = {
"APP_DATABASE__HOST": "database.default.svc",
"APP_DATABASE__PORT": "5432",
"APP_REPLICAS": "3",
}
first = loader.load(environment=config_map)
assert first.database.password == "first-secret"
assert first.database.host == "database.default.svc"
assert first.replicas == 3
# A later load is explicit and complete. Existing snapshots never change.
(version / "database__password").write_text("second-secret\n", encoding="utf-8")
second = loader.load(environment=config_map)
assert first is not second
assert first.database.password == "first-secret"
assert second.database.password == "second-secret"
assert "second-secret" not in repr(second)
The second load() is the reload operation. It rereads every configured
source and returns a new complete instance. The first instance remains
unchanged. There is no watcher, background task, lazy field lookup,
reload-in-place method, or global current-settings singleton.
Provenance¶
load(provenance=True) returns SettingsResult[T]; otherwise it returns T
directly. The result holds the snapshot and an immutable Mapping from canonical
field paths to one of override, environment, secret, toml, or
default.
Provenance contains no values, raw Mapping, environment dictionary, file
bytes, secret contents, source object, exact environment variable, or file
path. This deliberately makes the safe baseline less detailed than a deployment
debug trace. Settings.info separately exposes callback-free plan facts,
including the model, prefix, delimiter, case policy, source order, and compiled
environment names; it exposes no configured paths or contents.
Errors¶
Settings keeps failure ownership explicit:
| Failure | Exception |
|---|---|
| invalid model, prefix, case flag, policy, or partial root | TypeError / ValueError |
| source-name collision at plan creation | ValueError |
| current/historical or case collision within one source | ValidationError with alias_conflict |
| missing/unreadable/wrong-kind file path | normal OSError subtype |
| malformed TOML or invalid file UTF-8 | value-free ValueError |
| invalid setting value or missing field | ordinary ValidationError |
| source or final input budget exhaustion | ResourceLimitError |
Validation locations use current Talea external field paths. They never replace those paths with environment variable names or filesystem paths. Acquisition exceptions may contain the explicit path supplied by the application; they never contain source values.
Resource limits¶
SettingsPolicy owns dimensions introduced by source acquisition:
SettingsPolicy(
max_environment_entries=10_000,
max_override_entries=100_000,
max_override_depth=64,
max_override_key_bytes=64 * 1024,
max_source_names=10_000,
max_toml_bytes=8 * 1024 * 1024,
max_secret_files=256, # bounds all entries enumerated from the directory
max_secret_file_bytes=1024 * 1024,
max_source_bytes=16 * 1024 * 1024,
input_policy=ResourcePolicy(),
)
The override limits bound the settings-owned copy and source-resolution pass.
The final input_policy remains the existing owner of validation Mapping
depth, node, and error budgets. A caller may pass None for an individual
source limit only when another boundary enforces it; cyclic override mappings
are always rejected.
The environment entry limit counts values as they are iterated rather than trusting a custom Mapping's reported length. TOML and secret limits use bounded reads; the aggregate byte counter covers TOML, selected secret contents, every environment name, and matched environment values. Ignored environment values are not read into that byte budget. Override Mapping methods and concurrently mutated custom mappings remain trusted application work, while the settings copy and final detached structure have separate finite policies.
On platforms with descriptor-relative file opening, secret symlinks may point within the configured directory (including Kubernetes atomic-writer layouts), and authorization is bound to the file descriptor used for reading. Platforms without that facility accept direct regular files and reject secret symlinks.
Testing, CLI, and dotenv interoperation¶
"""Use deterministic settings sources in tests without touching os.environ."""
import os
from types import MappingProxyType
from talea import Spec
from talea.settings import Settings
class TestSettings(Spec):
endpoint: str
timeout: float = 2.0
retries: int = 3
before = dict(os.environ)
loader = Settings(TestSettings, prefix="TEST_")
environment = MappingProxyType(
{
"TEST_ENDPOINT": "https://staging.example.test",
"TEST_TIMEOUT": "1.5",
}
)
settings = loader.load({"retries": 0}, environment=environment)
assert settings.endpoint == "https://staging.example.test"
assert settings.timeout == 1.5
assert settings.retries == 0
assert dict(os.environ) == before
# A CLI parser or dotenv library can provide an ordinary Mapping override.
# Talea does not parse argv or .env syntax and does not mutate the environment.
cli_override = {"timeout": 5.0}
from_cli = loader.load(cli_override, environment=environment)
assert from_cli.timeout == 5.0
assert settings.timeout == 1.5
A CLI library should parse arguments first and pass its Python Mapping as the
highest-priority override. A dotenv library may parse .env syntax in
application code and provide a Mapping in the same way. That makes the Mapping
an explicit override—it does not make Talea an owner of CLI or dotenv parsing,
file discovery, interpolation, mutation, or precedence.
Generics, dynamic Specs, derivation, and recursion¶
The root must be a concrete complete Spec. A fully specialized generic such as
ServiceSettings[int], a complete derive_spec() result, and a concrete
create_spec() result work through their ordinary Spec execution. An open
generic origin and a presence-aware partial derived Spec reject at plan
creation. Settings performs no runtime TypeVar inference.
Recursive Spec, dataclass, and TypedDict schemas are finite. Environment and
secret projection descends declared object paths until the first canonical
back-edge; that back-edge becomes one JSON textual leaf. It never generates an
arbitrary maximum nesting depth. TOML and Mapping overrides can carry recursive
structure normally under ResourcePolicy.
Tagged unions are also JSON textual leaves, or may arrive as nested values from TOML/override sources. Settings does not create a branch query grammar. The ordinary discriminator owner selects and validates the branch.
Typing and performance¶
Settings(AppConfig).load() is statically AppConfig.
load(provenance=True) is SettingsResult[AppConfig]. Public source and policy
types contain no Any. Python 3.14 and the configured Python 3.15 lane check
the same API; Settings does not require a 3.15-only syntax contract.
task benchmark_settings measures cold plans, repeated loads, 10/50/100-field
environment and TOML models, nested fields, case policy, aliases, precedence,
secret directories, provenance, failures, resource rejection, retention,
concurrency, and ordinary Talea canaries. It also keeps two manual baselines:
the historical narrow loader and an audited equivalent-semantics loader that
shares only the final canonical Spec.from_mapping boundary.
The historical approximately 2.49 µs manual result is a useful direct-access
lower bound, not an equivalent Settings comparison. It omits complete source
snapshot validation, acquisition limits, case folding, accepted-name conflict
detection, canonical Mapping materialization, ResourcePolicy, and Spec
construction. Earlier approximately 20.85 µs and 20.35 µs Talea results used
the historical ten-integer environment workload against that weaker baseline.
On the Campaign 28H development machine, the same Talea workload measured approximately 6.3–7.0 µs minimum after convergence, while the audited manual executor measured approximately 7.1 µs. The representative mixed ten-field model measured approximately 10.0 µs versus 8.3 µs. These are reproducible development-checkout measurements rather than cross-machine promises.
The ten-integer Talea path decomposed to about 0.17 µs for the detached source
snapshot, 0.40 µs for case-normalized names, 1.43 µs for textual decoding,
0.07 µs for canonical assignment, 1.97 µs for final Spec.from_mapping, and
the remaining control and materialization work. The retained plan now preselects
primitive decoders, single-environment loads materialize resolved leaves
without general multi-source merge bookkeeping, and schema-proven leaf sources
use a leaf merge. Generic structured decoding, final model construction,
source limits, and full snapshot scanning remain intentional canonical owners.
A direct-probe environment path would miss whole-source type checks, entry
limits, and accepted-name conflict detection, so source-size cost remains
linear. Provenance remains pay-for-play and costs additional canonical-path
projection and immutable publication only when requested. Cold plan work is
reported separately from warm loads and filesystem I/O.
The benchmark makes no universal fastest-settings-library claim.
Security boundaries and non-goals¶
Settings bounds Talea-owned source acquisition, rejects name ambiguity, keeps source values out of provenance, redacts secret-backed validation failures, and publishes no partial snapshot. It cannot make process environment mutation atomic across unrelated application threads, provide filesystem transaction semantics, sandbox custom Mapping methods or callbacks, or stop a platform administrator from reading process/filesystem state.
There is deliberately no .env parser, YAML/INI/JSON configuration file,
remote secret manager, network or database source, callback source registry,
plugin system, source merge directive, deployment profile, automatic CLI,
watcher, live mutable settings object, context variable, or global singleton.
Adding such concerns would require a separate demonstrated owner rather than a
generic provider framework inside this one.