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.
@@ -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