monoconf 0.1.0__py3-none-any.whl

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.
monoconf/fields.py ADDED
@@ -0,0 +1,171 @@
1
+ """The `Field` wrapper and the name derivations every source shares.
2
+
3
+ `Field` adds three CLI-specific keys to pydantic's own. Everything else here
4
+ turns a field's dotted path and type into the names the CLI and environment use,
5
+ so that exactly one implementation of each derivation exists.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+ from datetime import date, datetime, time, timedelta
13
+ from decimal import Decimal
14
+ from pathlib import Path, PurePath
15
+ from typing import Any
16
+ from uuid import UUID
17
+
18
+ from pydantic import Field as _pydantic_field
19
+ from pydantic.fields import FieldInfo
20
+
21
+ __all__ = [
22
+ "CliMeta",
23
+ "Field",
24
+ "cli_meta",
25
+ "env_name_for",
26
+ "flag_for",
27
+ "metavar_for",
28
+ "metavar_from_name",
29
+ "singularise",
30
+ ]
31
+
32
+ _EXTRA_KEY = "monoconf"
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class CliMeta:
37
+ """The CLI-specific keys a maintainer passed to `Field`."""
38
+
39
+ short: str | None = None
40
+ metavar: str | None = None
41
+ flag: str | None = None
42
+
43
+
44
+ def Field(
45
+ default: Any = ...,
46
+ *,
47
+ short: str | None = None,
48
+ metavar: str | None = None,
49
+ flag: str | None = None,
50
+ **pydantic_kwargs: Any,
51
+ ) -> Any:
52
+ """`pydantic.Field` plus `short`, `metavar` and `flag`.
53
+
54
+ `short` is an opt-in single-character flag, never derived. `metavar` and `flag`
55
+ override the derived placeholder and long flag name; `flag` exists for list
56
+ fields whose plural does not singularise regularly.
57
+
58
+ `description` is pydantic's own key and doubles as the CLI help text.
59
+ """
60
+ extra = {
61
+ key: value
62
+ for key, value in (("short", short), ("metavar", metavar), ("flag", flag))
63
+ if value is not None
64
+ }
65
+ if extra:
66
+ # json_schema_extra is the one place on FieldInfo that survives pydantic's
67
+ # reconstruction of the field. The cost is a "monoconf" key in the model's
68
+ # JSON schema, which is a fair trade for not needing a parallel registry.
69
+ existing = pydantic_kwargs.pop("json_schema_extra", None)
70
+ merged: dict[str, Any] = dict(existing) if isinstance(existing, dict) else {}
71
+ merged[_EXTRA_KEY] = extra
72
+ pydantic_kwargs["json_schema_extra"] = merged
73
+ return _pydantic_field(default, **pydantic_kwargs)
74
+
75
+
76
+ def cli_meta(info: FieldInfo) -> CliMeta:
77
+ """Read back what `Field` stored, for a field declared either way."""
78
+ extra = info.json_schema_extra
79
+ if not isinstance(extra, dict):
80
+ return CliMeta()
81
+ stored = extra.get(_EXTRA_KEY)
82
+ if not isinstance(stored, dict):
83
+ return CliMeta()
84
+ return CliMeta(
85
+ short=_opt_str(stored.get("short")),
86
+ metavar=_opt_str(stored.get("metavar")),
87
+ flag=_opt_str(stored.get("flag")),
88
+ )
89
+
90
+
91
+ def _opt_str(value: object) -> str | None:
92
+ return value if isinstance(value, str) else None
93
+
94
+
95
+ _ES_ENDINGS = ("ses", "xes", "zes", "ches", "shes")
96
+ _S_EXCEPTIONS = ("ss", "us", "is")
97
+
98
+
99
+ def singularise(name: str) -> str:
100
+ """Singularise a regular plural, leaving anything irregular alone.
101
+
102
+ A list field takes a repeated singular flag: `tags` -> `--tag`. Irregular
103
+ plurals keep the field name; `Field(flag=...)` is the way out.
104
+ """
105
+ if len(name) > 4 and name.endswith("ies"):
106
+ return f"{name[:-3]}y"
107
+ if name.endswith(_ES_ENDINGS):
108
+ return name[:-2]
109
+ if name.endswith("s") and not name.endswith(_S_EXCEPTIONS):
110
+ return name[:-1]
111
+ return name
112
+
113
+
114
+ def flag_for(path: str, *, repeated: bool = False, override: str | None = None) -> str:
115
+ """Long flag for a dotted field path: `database.host` -> `--database-host`."""
116
+ if override is not None:
117
+ return f"--{override.lstrip('-')}"
118
+ parts = path.split(".")
119
+ if repeated:
120
+ parts[-1] = singularise(parts[-1])
121
+ return "--" + "-".join(part.replace("_", "-") for part in parts)
122
+
123
+
124
+ def env_name_for(path: str, prefix: str) -> str:
125
+ """Environment variable for a dotted path, nested with a double underscore."""
126
+ return prefix + "__".join(part.upper() for part in path.split("."))
127
+
128
+
129
+ # Exact matches only: datetime subclasses date, so a scan over types would have to
130
+ # care about order, and an annotation is virtually always one of these exactly.
131
+ _METAVARS: dict[Any, str] = {
132
+ Path: "PATH",
133
+ PurePath: "PATH",
134
+ int: "N",
135
+ float: "X",
136
+ Decimal: "DECIMAL",
137
+ UUID: "UUID",
138
+ datetime: "DATETIME",
139
+ date: "DATE",
140
+ time: "TIME",
141
+ timedelta: "DURATION",
142
+ }
143
+
144
+
145
+ def metavar_for(path: str, annotation: Any, override: str | None = None) -> str:
146
+ """Help-text placeholder for a field, derived from its type where it can be.
147
+
148
+ Falls back to the dotted path in UPPER_SNAKE, which keeps a nested field's
149
+ placeholder unambiguous in the usage line.
150
+ """
151
+ if override is not None:
152
+ return override
153
+ derived = _METAVARS.get(annotation)
154
+ if derived is not None:
155
+ return derived
156
+ # Enums and Literals fall through: their choices are listed in the help text,
157
+ # so the placeholder stays plain.
158
+ return _upper_snake(path)
159
+
160
+
161
+ def metavar_from_name(path: str, override: str | None = None) -> str:
162
+ """Placeholder for a positional argument.
163
+
164
+ A positional's placeholder is also the only name the operator sees for it, so
165
+ it comes from the field name rather than the type: `INPUT_PATH`, not `PATH`.
166
+ """
167
+ return override if override is not None else _upper_snake(path)
168
+
169
+
170
+ def _upper_snake(path: str) -> str:
171
+ return re.sub(r"[.\-]", "_", path).upper()
monoconf/merge.py ADDED
@@ -0,0 +1,151 @@
1
+ """Deep merge of sparse layers, and the provenance that comes out of it.
2
+
3
+ Every source returns `(values, origins)`: the values it actually set, and where
4
+ each of them came from. `merge` folds those layers lowest priority first and
5
+ records the winning origin for each leaf as it wins, so "why is port 9090?" is
6
+ answerable for every field, always. No I/O lives in this module.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable, Iterator, Mapping
12
+ from dataclasses import dataclass
13
+ from enum import StrEnum
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from monoconf.errors import MonoconfError
18
+
19
+ __all__ = [
20
+ "Layer",
21
+ "Origin",
22
+ "Origins",
23
+ "SourceKind",
24
+ "Values",
25
+ "leaves",
26
+ "merge",
27
+ "origins_for",
28
+ "set_leaf",
29
+ ]
30
+
31
+
32
+ class SourceKind(StrEnum):
33
+ """Where a value came from, in precedence order."""
34
+
35
+ DEFAULT = "default"
36
+ SYSTEM_FILE = "system file"
37
+ SYSTEM_DROPIN = "system drop-in"
38
+ USER_FILE = "user file"
39
+ USER_DROPIN = "user drop-in"
40
+ OVERRIDE_FILE = "override file"
41
+ ENV = "environment"
42
+ CLI = "command line"
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Origin:
47
+ """Where one resolved value came from."""
48
+
49
+ source: SourceKind
50
+ path: Path | None = None
51
+ """The file, for a file source."""
52
+
53
+ detail: str | None = None
54
+ """The variable name, or the flag as typed."""
55
+
56
+ def describe(self) -> str:
57
+ """The shortest thing that answers "where did this come from?"."""
58
+ if self.path is not None:
59
+ return str(self.path)
60
+ if self.detail is not None:
61
+ return self.detail
62
+ return str(self.source)
63
+
64
+
65
+ Values = dict[str, Any]
66
+ """A sparse tree of the values one source set, nested as the schema is."""
67
+
68
+ Origins = dict[str, Origin]
69
+ """One origin per leaf, keyed by dotted path."""
70
+
71
+ Layer = tuple[Values, Origins]
72
+
73
+
74
+ def leaves(values: Mapping[str, Any], prefix: str = "") -> Iterator[tuple[str, Any]]:
75
+ """Walk a value tree, yielding `(dotted path, value)` for every leaf.
76
+
77
+ A `dict` is structure, never a value: monoconf rejects dict-typed fields at
78
+ class-definition time precisely so that this distinction holds.
79
+ """
80
+ for key, value in values.items():
81
+ path = f"{prefix}{key}"
82
+ if isinstance(value, dict):
83
+ yield from leaves(value, f"{path}.")
84
+ else:
85
+ yield path, value
86
+
87
+
88
+ def origins_for(values: Mapping[str, Any], origin: Origin) -> Origins:
89
+ """Attribute every leaf of one source's values to the same origin."""
90
+ return {path: origin for path, _ in leaves(values)}
91
+
92
+
93
+ def merge(layers: Iterable[Layer]) -> Layer:
94
+ """Fold layers lowest priority first into one tree plus its provenance.
95
+
96
+ Merging is per leaf, so a drop-in setting `database.port` leaves
97
+ `database.host` from the base file intact. Lists are values, not structure, so
98
+ a later layer's list replaces the earlier one wholesale.
99
+ """
100
+ values: Values = {}
101
+ origins: Origins = {}
102
+ for layer_values, layer_origins in layers:
103
+ _fold(values, origins, layer_values, layer_origins, "")
104
+ return values, origins
105
+
106
+
107
+ def _fold(
108
+ values: Values,
109
+ origins: Origins,
110
+ incoming: Mapping[str, Any],
111
+ incoming_origins: Mapping[str, Origin],
112
+ prefix: str,
113
+ ) -> None:
114
+ for key, value in incoming.items():
115
+ path = f"{prefix}{key}"
116
+ if isinstance(value, dict):
117
+ existing = values.get(key)
118
+ child: Values = existing if isinstance(existing, dict) else {}
119
+ _fold(child, origins, value, incoming_origins, f"{path}.")
120
+ # A source that set nothing sets nothing: an empty branch never
121
+ # materialises a key that no layer actually filled in.
122
+ if child:
123
+ values[key] = child
124
+ continue
125
+ values[key] = value
126
+ origins[path] = _origin_of(incoming_origins, path)
127
+
128
+
129
+ def _origin_of(origins: Mapping[str, Origin], path: str) -> Origin:
130
+ origin = origins.get(path)
131
+ if origin is None:
132
+ raise MonoconfError(
133
+ f"a source returned a value for '{path}' without an origin. Every "
134
+ "resolved value has to know where it came from; the source is at fault."
135
+ )
136
+ return origin
137
+
138
+
139
+ def set_leaf(values: Values, path: str, value: Any) -> None:
140
+ """Set a dotted path in a value tree, creating the branches it needs.
141
+
142
+ The inverse of `leaves`, for a source that knows dotted paths and has to hand
143
+ back the nested shape the schema has. The path's branches must be branches --
144
+ which the definition-time checks guarantee, a field being either a leaf or a
145
+ model and never both.
146
+ """
147
+ head, _, rest = path.partition(".")
148
+ if not rest:
149
+ values[head] = value
150
+ return
151
+ set_leaf(values.setdefault(head, {}), rest, value)
monoconf/py.typed ADDED
File without changes