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/__init__.py +46 -0
- monoconf/config.py +281 -0
- monoconf/errors.py +285 -0
- monoconf/fields.py +171 -0
- monoconf/merge.py +151 -0
- monoconf/py.typed +0 -0
- monoconf/schema.py +643 -0
- monoconf/settings.py +196 -0
- monoconf/sources/__init__.py +72 -0
- monoconf/sources/cli.py +435 -0
- monoconf/sources/defaults.py +30 -0
- monoconf/sources/env.py +95 -0
- monoconf/sources/files.py +422 -0
- monoconf/toml.py +195 -0
- monoconf-0.1.0.dist-info/METADATA +389 -0
- monoconf-0.1.0.dist-info/RECORD +18 -0
- monoconf-0.1.0.dist-info/WHEEL +4 -0
- monoconf-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|