didactic-settings 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- didactic_settings-0.1.0/.gitignore +48 -0
- didactic_settings-0.1.0/PKG-INFO +83 -0
- didactic_settings-0.1.0/README.md +63 -0
- didactic_settings-0.1.0/pyproject.toml +37 -0
- didactic_settings-0.1.0/src/didactic/settings/__init__.py +39 -0
- didactic_settings-0.1.0/src/didactic/settings/_settings.py +291 -0
- didactic_settings-0.1.0/src/didactic/settings/py.typed +0 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# dev-only working notes, design drafts, scratch
|
|
2
|
+
notes/
|
|
3
|
+
|
|
4
|
+
# python
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*$py.class
|
|
8
|
+
*.so
|
|
9
|
+
.Python
|
|
10
|
+
build/
|
|
11
|
+
dist/
|
|
12
|
+
*.egg-info/
|
|
13
|
+
.eggs/
|
|
14
|
+
*.egg
|
|
15
|
+
|
|
16
|
+
# virtualenvs
|
|
17
|
+
.venv/
|
|
18
|
+
venv/
|
|
19
|
+
env/
|
|
20
|
+
|
|
21
|
+
# uv
|
|
22
|
+
.uv/
|
|
23
|
+
|
|
24
|
+
# testing / coverage
|
|
25
|
+
.pytest_cache/
|
|
26
|
+
.coverage
|
|
27
|
+
.coverage.*
|
|
28
|
+
htmlcov/
|
|
29
|
+
.tox/
|
|
30
|
+
.nox/
|
|
31
|
+
|
|
32
|
+
# type-checkers / linters
|
|
33
|
+
.mypy_cache/
|
|
34
|
+
.ruff_cache/
|
|
35
|
+
.pyright/
|
|
36
|
+
|
|
37
|
+
# mkdocs build output
|
|
38
|
+
site/
|
|
39
|
+
|
|
40
|
+
# editors
|
|
41
|
+
.vscode/
|
|
42
|
+
.idea/
|
|
43
|
+
*.swp
|
|
44
|
+
*.swo
|
|
45
|
+
|
|
46
|
+
# os
|
|
47
|
+
.DS_Store
|
|
48
|
+
Thumbs.db
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: didactic-settings
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed application settings on top of didactic Models.
|
|
5
|
+
Author-email: Aaron Steven White <aaronstevenwhite@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: configuration,didactic,panproto,settings
|
|
8
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.14
|
|
15
|
+
Requires-Dist: didactic
|
|
16
|
+
Provides-Extra: toml
|
|
17
|
+
Provides-Extra: yaml
|
|
18
|
+
Requires-Dist: pyyaml>=6; extra == 'yaml'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# didactic-settings
|
|
22
|
+
|
|
23
|
+
Typed application settings on top of `dx.Model`. Contributes
|
|
24
|
+
`didactic.settings` to the namespace package.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install didactic-settings
|
|
30
|
+
pip install 'didactic-settings[yaml]' # adds YAML support
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The package depends on `didactic`. The optional `yaml` extra adds
|
|
34
|
+
PyYAML for `.yaml` / `.yml` config files.
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import didactic.api as dx
|
|
40
|
+
from didactic.settings import Settings, EnvSource, FileSource
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AppSettings(Settings):
|
|
44
|
+
debug: bool = False
|
|
45
|
+
db_url: str
|
|
46
|
+
port: int = 8080
|
|
47
|
+
|
|
48
|
+
__sources__ = (
|
|
49
|
+
FileSource(path="config.toml"),
|
|
50
|
+
EnvSource(prefix="APP_"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
cfg = AppSettings.load()
|
|
55
|
+
cfg.port # the resolved value
|
|
56
|
+
cfg.__provenance__["port"] # 'env' / 'file' / 'default' / 'override'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`Settings` inherits from `dx.Model`, so every Model feature works:
|
|
60
|
+
type checks, axioms, validators, JSON Schema export.
|
|
61
|
+
|
|
62
|
+
## Sources
|
|
63
|
+
|
|
64
|
+
| source | reads from |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `EnvSource(prefix="APP_")` | environment variables |
|
|
67
|
+
| `DotEnvSource(path=".env", prefix="APP_")` | dotenv file |
|
|
68
|
+
| `FileSource(path="config.toml")` | JSON, TOML, or YAML by suffix |
|
|
69
|
+
| `CliSource(args=ns)` | parsed `argparse.Namespace` or dict |
|
|
70
|
+
|
|
71
|
+
Sources are walked in declaration order; later sources override
|
|
72
|
+
earlier ones. Keyword overrides at `Settings.load(...)` win over
|
|
73
|
+
every source.
|
|
74
|
+
|
|
75
|
+
## Documentation
|
|
76
|
+
|
|
77
|
+
See [Guides > Settings](https://panproto.dev/didactic/guide/settings/)
|
|
78
|
+
for the full source documentation, coercion rules, and provenance
|
|
79
|
+
reporting.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# didactic-settings
|
|
2
|
+
|
|
3
|
+
Typed application settings on top of `dx.Model`. Contributes
|
|
4
|
+
`didactic.settings` to the namespace package.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
pip install didactic-settings
|
|
10
|
+
pip install 'didactic-settings[yaml]' # adds YAML support
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The package depends on `didactic`. The optional `yaml` extra adds
|
|
14
|
+
PyYAML for `.yaml` / `.yml` config files.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import didactic.api as dx
|
|
20
|
+
from didactic.settings import Settings, EnvSource, FileSource
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AppSettings(Settings):
|
|
24
|
+
debug: bool = False
|
|
25
|
+
db_url: str
|
|
26
|
+
port: int = 8080
|
|
27
|
+
|
|
28
|
+
__sources__ = (
|
|
29
|
+
FileSource(path="config.toml"),
|
|
30
|
+
EnvSource(prefix="APP_"),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
cfg = AppSettings.load()
|
|
35
|
+
cfg.port # the resolved value
|
|
36
|
+
cfg.__provenance__["port"] # 'env' / 'file' / 'default' / 'override'
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`Settings` inherits from `dx.Model`, so every Model feature works:
|
|
40
|
+
type checks, axioms, validators, JSON Schema export.
|
|
41
|
+
|
|
42
|
+
## Sources
|
|
43
|
+
|
|
44
|
+
| source | reads from |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `EnvSource(prefix="APP_")` | environment variables |
|
|
47
|
+
| `DotEnvSource(path=".env", prefix="APP_")` | dotenv file |
|
|
48
|
+
| `FileSource(path="config.toml")` | JSON, TOML, or YAML by suffix |
|
|
49
|
+
| `CliSource(args=ns)` | parsed `argparse.Namespace` or dict |
|
|
50
|
+
|
|
51
|
+
Sources are walked in declaration order; later sources override
|
|
52
|
+
earlier ones. Keyword overrides at `Settings.load(...)` win over
|
|
53
|
+
every source.
|
|
54
|
+
|
|
55
|
+
## Documentation
|
|
56
|
+
|
|
57
|
+
See [Guides > Settings](https://panproto.dev/didactic/guide/settings/)
|
|
58
|
+
for the full source documentation, coercion rules, and provenance
|
|
59
|
+
reporting.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "didactic-settings"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Typed application settings on top of didactic Models."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.14"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Aaron Steven White", email = "aaronstevenwhite@gmail.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["settings", "configuration", "didactic", "panproto"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"didactic",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
yaml = ["pyyaml>=6"]
|
|
30
|
+
toml = []
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.sdist]
|
|
33
|
+
include = ["src/didactic/settings"]
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
only-include = ["src/didactic/settings"]
|
|
37
|
+
sources = ["src"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""didactic-settings: typed application settings.
|
|
2
|
+
|
|
3
|
+
Top-level surface:
|
|
4
|
+
|
|
5
|
+
[Settings][didactic.settings.Settings]
|
|
6
|
+
Base class for application settings; subclasses declare fields
|
|
7
|
+
just like a [didactic.api.Model][didactic.api.Model].
|
|
8
|
+
[EnvSource][didactic.settings.EnvSource]
|
|
9
|
+
A source that reads from environment variables.
|
|
10
|
+
[DotEnvSource][didactic.settings.DotEnvSource]
|
|
11
|
+
A source that reads from a ``.env`` file.
|
|
12
|
+
[FileSource][didactic.settings.FileSource]
|
|
13
|
+
A source that reads from a TOML / YAML / JSON file.
|
|
14
|
+
[CliSource][didactic.settings.CliSource]
|
|
15
|
+
A source that reads from CLI arguments (``argparse``-shaped).
|
|
16
|
+
|
|
17
|
+
Sources merge by lens-style precedence: later sources override
|
|
18
|
+
earlier ones, and each field's value records which source supplied
|
|
19
|
+
it via ``settings.__provenance__``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from didactic.settings._settings import (
|
|
23
|
+
CliSource,
|
|
24
|
+
DotEnvSource,
|
|
25
|
+
EnvSource,
|
|
26
|
+
FileSource,
|
|
27
|
+
Settings,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"CliSource",
|
|
34
|
+
"DotEnvSource",
|
|
35
|
+
"EnvSource",
|
|
36
|
+
"FileSource",
|
|
37
|
+
"Settings",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# ``CliSource`` accepts either an ``argparse.Namespace`` or a Mapping,
|
|
2
|
+
# but pyright's ``Namespace`` stub ascribes a list-of-bytes shape to
|
|
3
|
+
# ``vars(ns)`` items and rejects the dict comprehension. ``ModelConfig``
|
|
4
|
+
# kwargs are constructed from a heterogeneous JSON dict and pyright
|
|
5
|
+
# can't narrow each kwarg-value to its own ``Literal`` parameter
|
|
6
|
+
# without per-key conditionals. Both are noise; tracked in
|
|
7
|
+
# panproto/didactic#1.
|
|
8
|
+
# pyright: reportArgumentType=false, reportCallIssue=false, reportReturnType=false, reportUnknownArgumentType=false, reportUnknownMemberType=false, reportUnknownVariableType=false
|
|
9
|
+
"""Settings sources and the ``Settings`` base class.
|
|
10
|
+
|
|
11
|
+
A ``Settings`` subclass declares fields like a regular
|
|
12
|
+
[didactic.api.Model][didactic.api.Model], plus a class-level
|
|
13
|
+
``__sources__`` tuple of sources to consult. ``Settings.load()``
|
|
14
|
+
walks the sources in order, collecting per-field overrides; later
|
|
15
|
+
sources win, and each field's resolved value records its provenance.
|
|
16
|
+
|
|
17
|
+
See Also
|
|
18
|
+
--------
|
|
19
|
+
didactic.Model : the base from which Settings inherits all field machinery.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import TYPE_CHECKING, ClassVar, Self
|
|
29
|
+
|
|
30
|
+
import didactic.api as dx
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
import argparse
|
|
34
|
+
from collections.abc import Mapping, Sequence
|
|
35
|
+
|
|
36
|
+
from didactic.fields._fields import FieldSpec
|
|
37
|
+
from didactic.types._typing import FieldValue, JsonObject
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class _Source:
|
|
42
|
+
"""Base class marker for settings sources."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class EnvSource(_Source):
|
|
49
|
+
"""Read settings from environment variables.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
prefix
|
|
54
|
+
Prefix applied to each field name to compute the env var.
|
|
55
|
+
``EnvSource(prefix="APP_")`` reads ``port`` from ``APP_PORT``.
|
|
56
|
+
name
|
|
57
|
+
Optional source name for provenance reporting.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
prefix: str = ""
|
|
61
|
+
name: str = "env"
|
|
62
|
+
|
|
63
|
+
def fetch(self, fields: Sequence[str]) -> JsonObject:
|
|
64
|
+
"""Return ``{field: env_value}`` for fields whose env var is set."""
|
|
65
|
+
out: JsonObject = {}
|
|
66
|
+
for fname in fields:
|
|
67
|
+
key = f"{self.prefix}{fname}".upper()
|
|
68
|
+
if key in os.environ:
|
|
69
|
+
out[fname] = os.environ[key]
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class DotEnvSource(_Source):
|
|
75
|
+
"""Read settings from a ``.env`` file.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
path
|
|
80
|
+
Path to the dotenv file.
|
|
81
|
+
prefix
|
|
82
|
+
Prefix applied to each field name.
|
|
83
|
+
name
|
|
84
|
+
Optional source name for provenance reporting.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
path: str = ".env"
|
|
88
|
+
prefix: str = ""
|
|
89
|
+
name: str = "dotenv"
|
|
90
|
+
|
|
91
|
+
def fetch(self, fields: Sequence[str]) -> JsonObject:
|
|
92
|
+
"""Return ``{field: value}`` from the file."""
|
|
93
|
+
path = Path(self.path)
|
|
94
|
+
if not path.exists():
|
|
95
|
+
return {}
|
|
96
|
+
env: dict[str, str] = {}
|
|
97
|
+
for raw_line in path.read_text().splitlines():
|
|
98
|
+
line = raw_line.strip()
|
|
99
|
+
if not line or line.startswith("#"):
|
|
100
|
+
continue
|
|
101
|
+
if "=" not in line:
|
|
102
|
+
continue
|
|
103
|
+
k, _, v = line.partition("=")
|
|
104
|
+
env[k.strip()] = v.strip().strip("'\"")
|
|
105
|
+
out: JsonObject = {}
|
|
106
|
+
for fname in fields:
|
|
107
|
+
key = f"{self.prefix}{fname}".upper()
|
|
108
|
+
if key in env:
|
|
109
|
+
out[fname] = env[key]
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class FileSource(_Source):
|
|
115
|
+
"""Read settings from a structured config file (JSON / TOML / YAML).
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
path
|
|
120
|
+
Path to the file. Format detected by suffix.
|
|
121
|
+
name
|
|
122
|
+
Optional source name for provenance reporting.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
path: str = "config.toml"
|
|
126
|
+
name: str = "file"
|
|
127
|
+
|
|
128
|
+
def fetch(self, fields: Sequence[str]) -> JsonObject:
|
|
129
|
+
"""Return ``{field: value}`` from the file."""
|
|
130
|
+
path = Path(self.path)
|
|
131
|
+
if not path.exists():
|
|
132
|
+
return {}
|
|
133
|
+
text = path.read_text()
|
|
134
|
+
suffix = path.suffix.lower()
|
|
135
|
+
if suffix == ".json":
|
|
136
|
+
data = json.loads(text)
|
|
137
|
+
elif suffix == ".toml":
|
|
138
|
+
import tomllib # noqa: PLC0415
|
|
139
|
+
|
|
140
|
+
data = tomllib.loads(text)
|
|
141
|
+
elif suffix in (".yaml", ".yml"):
|
|
142
|
+
try:
|
|
143
|
+
import yaml # noqa: PLC0415 # type: ignore[import-not-found]
|
|
144
|
+
except ImportError as exc: # pragma: no cover
|
|
145
|
+
msg = (
|
|
146
|
+
"FileSource cannot load YAML files without the optional "
|
|
147
|
+
"`yaml` extra; install didactic-settings[yaml]"
|
|
148
|
+
)
|
|
149
|
+
raise ImportError(msg) from exc
|
|
150
|
+
data = yaml.safe_load(text)
|
|
151
|
+
else:
|
|
152
|
+
msg = f"unsupported FileSource suffix: {suffix!r}"
|
|
153
|
+
raise ValueError(msg)
|
|
154
|
+
if not isinstance(data, dict):
|
|
155
|
+
kind = type(data).__name__
|
|
156
|
+
msg = f"FileSource expects a top-level mapping; got {kind}"
|
|
157
|
+
raise TypeError(msg)
|
|
158
|
+
return {k: v for k, v in data.items() if k in fields}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True, slots=True)
|
|
162
|
+
class CliSource(_Source):
|
|
163
|
+
"""Read settings from a parsed argparse ``Namespace`` (or dict).
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
args
|
|
168
|
+
A mapping (or argparse.Namespace) supplying field values.
|
|
169
|
+
name
|
|
170
|
+
Optional source name for provenance reporting.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
# ``argparse.Namespace`` and ``Mapping``s are both accepted at
|
|
174
|
+
# runtime: ``fetch`` calls ``vars(self.args)`` for objects with
|
|
175
|
+
# ``__dict__`` and ``dict(self.args)`` otherwise. The static type
|
|
176
|
+
# is widened accordingly.
|
|
177
|
+
args: argparse.Namespace | Mapping[str, FieldValue] | None = None
|
|
178
|
+
name: str = "cli"
|
|
179
|
+
|
|
180
|
+
def fetch(self, fields: Sequence[str]) -> JsonObject:
|
|
181
|
+
"""Return ``{field: value}`` from the args mapping."""
|
|
182
|
+
if self.args is None:
|
|
183
|
+
return {}
|
|
184
|
+
data = vars(self.args) if hasattr(self.args, "__dict__") else dict(self.args)
|
|
185
|
+
return {k: v for k, v in data.items() if k in fields and v is not None}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class Settings(dx.Model):
|
|
189
|
+
"""Base class for application settings.
|
|
190
|
+
|
|
191
|
+
Subclasses declare fields like any [didactic.api.Model][didactic.api.Model],
|
|
192
|
+
plus a class-level ``__sources__`` tuple. Call
|
|
193
|
+
[Settings.load][didactic.settings.Settings.load] to populate from
|
|
194
|
+
the configured sources.
|
|
195
|
+
|
|
196
|
+
Examples
|
|
197
|
+
--------
|
|
198
|
+
>>> import didactic.api as dx
|
|
199
|
+
>>> from didactic.settings import Settings, EnvSource
|
|
200
|
+
>>>
|
|
201
|
+
>>> class App(Settings):
|
|
202
|
+
... debug: bool = False
|
|
203
|
+
... port: int = 8080
|
|
204
|
+
...
|
|
205
|
+
... __sources__ = (EnvSource(prefix="APP_"),)
|
|
206
|
+
|
|
207
|
+
Attributes
|
|
208
|
+
----------
|
|
209
|
+
__provenance__
|
|
210
|
+
Per-instance dict mapping each field name to the name of the
|
|
211
|
+
source that supplied its value. Fields that fell through to
|
|
212
|
+
the declared default get ``"default"``.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
__sources__: ClassVar[tuple[_Source, ...]] = ()
|
|
216
|
+
|
|
217
|
+
# Per-instance attribute set by ``load`` via ``object.__setattr__``;
|
|
218
|
+
# the annotation here is for type-checkers only (the leading-
|
|
219
|
+
# underscore name is skipped by the metaclass's field walker).
|
|
220
|
+
__provenance__: dict[str, str]
|
|
221
|
+
|
|
222
|
+
@classmethod
|
|
223
|
+
def load(cls, **overrides: FieldValue) -> Self:
|
|
224
|
+
"""Construct a Settings instance by merging every source.
|
|
225
|
+
|
|
226
|
+
Parameters
|
|
227
|
+
----------
|
|
228
|
+
**overrides
|
|
229
|
+
Per-field overrides that take final precedence over every
|
|
230
|
+
registered source.
|
|
231
|
+
|
|
232
|
+
Returns
|
|
233
|
+
-------
|
|
234
|
+
Settings
|
|
235
|
+
The validated Settings instance, with ``__provenance__``
|
|
236
|
+
populated.
|
|
237
|
+
"""
|
|
238
|
+
field_names = tuple(cls.__field_specs__)
|
|
239
|
+
merged: dict[str, FieldValue] = {}
|
|
240
|
+
provenance: dict[str, str] = {}
|
|
241
|
+
|
|
242
|
+
for source in cls.__sources__:
|
|
243
|
+
chunk = source.fetch(field_names) # type: ignore[attr-defined]
|
|
244
|
+
for k, v in chunk.items():
|
|
245
|
+
merged[k] = _coerce_value(v, cls.__field_specs__[k])
|
|
246
|
+
provenance[k] = source.name
|
|
247
|
+
|
|
248
|
+
for k, v in overrides.items():
|
|
249
|
+
merged[k] = v
|
|
250
|
+
provenance[k] = "override"
|
|
251
|
+
|
|
252
|
+
# mark fields that fell through to the declared default
|
|
253
|
+
for fname in field_names:
|
|
254
|
+
provenance.setdefault(fname, "default")
|
|
255
|
+
|
|
256
|
+
instance = cls(**merged)
|
|
257
|
+
# bypass the frozen-by-design guard to attach provenance metadata
|
|
258
|
+
object.__setattr__(instance, "__provenance__", provenance)
|
|
259
|
+
return instance
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _coerce_value(raw: FieldValue, spec: FieldSpec) -> FieldValue:
|
|
263
|
+
"""Coerce string values from env / dotenv / cli into the spec's type.
|
|
264
|
+
|
|
265
|
+
Notes
|
|
266
|
+
-----
|
|
267
|
+
Environment variables and dotenv lines arrive as strings even when
|
|
268
|
+
the target field type is ``int`` / ``bool`` / ``float``. This
|
|
269
|
+
helper does the obvious coercions; richer parsing (JSON-shaped
|
|
270
|
+
values, comma-separated tuples, etc.) is the field-converter's
|
|
271
|
+
responsibility.
|
|
272
|
+
"""
|
|
273
|
+
if isinstance(raw, str):
|
|
274
|
+
annotation = spec.annotation
|
|
275
|
+
if annotation is bool:
|
|
276
|
+
lowered = raw.strip().lower()
|
|
277
|
+
return lowered in {"1", "true", "yes", "on"}
|
|
278
|
+
if annotation is int:
|
|
279
|
+
return int(raw)
|
|
280
|
+
if annotation is float:
|
|
281
|
+
return float(raw)
|
|
282
|
+
return raw
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
__all__ = [
|
|
286
|
+
"CliSource",
|
|
287
|
+
"DotEnvSource",
|
|
288
|
+
"EnvSource",
|
|
289
|
+
"FileSource",
|
|
290
|
+
"Settings",
|
|
291
|
+
]
|
|
File without changes
|