mgf-common 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.
- mgf/common/__init__.py +35 -0
- mgf/common/_identity.py +69 -0
- mgf/common/config/__init__.py +43 -0
- mgf/common/config/_loader.py +185 -0
- mgf/common/config/_paths.py +114 -0
- mgf/common/config/_settings.py +185 -0
- mgf/common/exceptions/__init__.py +65 -0
- mgf/common/exceptions/_base.py +49 -0
- mgf/common/exceptions/_well_known.py +213 -0
- mgf/common/observability/__init__.py +44 -0
- mgf/common/observability/crash_reporter.py +234 -0
- mgf/common/observability/excepthook.py +79 -0
- mgf/common/observability/json_formatter.py +171 -0
- mgf/common/observability/logging_setup.py +258 -0
- mgf/common/observability/otel_setup.py +206 -0
- mgf/common/observability/redaction.py +134 -0
- mgf/common/observability/text_formatter.py +111 -0
- mgf/common/observability/threading_excepthook.py +62 -0
- mgf/common/py.typed +0 -0
- mgf_common-0.1.0.dist-info/METADATA +143 -0
- mgf_common-0.1.0.dist-info/RECORD +23 -0
- mgf_common-0.1.0.dist-info/WHEEL +4 -0
- mgf_common-0.1.0.dist-info/licenses/LICENSE +21 -0
mgf/common/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""mgf.common — shared infrastructure for MGF projects.
|
|
2
|
+
|
|
3
|
+
Three core components today:
|
|
4
|
+
|
|
5
|
+
- :mod:`mgf.common.exceptions` — typed exception hierarchy
|
|
6
|
+
(``AppError`` base + 7 category bases + 6 generic concretes).
|
|
7
|
+
- :mod:`mgf.common.config` — :class:`BaseAppSettings` +
|
|
8
|
+
layered loader (kwargs > env > .env > YAML > defaults) +
|
|
9
|
+
cross-OS path helpers.
|
|
10
|
+
- :mod:`mgf.common.observability` — structured logging
|
|
11
|
+
(JSON / text formatters, multi-sink :func:`setup_logging`),
|
|
12
|
+
crash reporter, sys / threading excepthooks, optional
|
|
13
|
+
OpenTelemetry instrumentation.
|
|
14
|
+
|
|
15
|
+
Identity is process-wide: call :func:`configure` once at startup
|
|
16
|
+
to set the app name + version that every observability function
|
|
17
|
+
reads (log paths, env-var prefixes, OTel service name, crash
|
|
18
|
+
report ``app`` field).
|
|
19
|
+
|
|
20
|
+
See ``docs/standards/`` (lands in Phase 4) for the cross-project
|
|
21
|
+
engineering standards this package implements, and ``COMMON.md``
|
|
22
|
+
(lands in Phase 5) for the extraction plan.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from mgf.common._identity import app_name, app_version, configure
|
|
28
|
+
|
|
29
|
+
__all__ = ["app_name", "app_version", "configure"]
|
|
30
|
+
|
|
31
|
+
# Version string for the package itself. Bumped when a release is
|
|
32
|
+
# tagged; consumers rarely need to read this — they use
|
|
33
|
+
# ``mgf.common.app_version()`` for THEIR own app version, set via
|
|
34
|
+
# ``configure(app_version=...)``.
|
|
35
|
+
__version__ = "0.1.0"
|
mgf/common/_identity.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""App-identity cache for the mgf.common observability stack.
|
|
2
|
+
|
|
3
|
+
Set once at startup via :func:`mgf.common.configure`. Subsequent
|
|
4
|
+
calls in the observability layer (``setup_logging``, ``setup_otel``,
|
|
5
|
+
``report_now``, ``install_excepthook``, ...) read the identity
|
|
6
|
+
from the module-level cache here.
|
|
7
|
+
|
|
8
|
+
Why module-level state and not a contextvar:
|
|
9
|
+
- The identity is process-scoped, not request-scoped.
|
|
10
|
+
- A contextvar would suggest "different apps in the same process"
|
|
11
|
+
which is not a real use case for any consumer of this library.
|
|
12
|
+
- Module-level state is faster (no descriptor lookup) and simpler
|
|
13
|
+
to reason about.
|
|
14
|
+
|
|
15
|
+
The defaults below are intentionally placeholder strings — every
|
|
16
|
+
production caller MUST call :func:`mgf.common.configure` before any
|
|
17
|
+
observability function runs. The defaults exist only so unit tests
|
|
18
|
+
that touch the observability layer don't trip on uninitialised
|
|
19
|
+
state.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
# Defaults are placeholders. Real consumers MUST call configure().
|
|
25
|
+
_APP_NAME: str = "app"
|
|
26
|
+
_APP_VERSION: str = "unknown"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def configure(app_name: str, app_version: str = "unknown") -> None:
|
|
30
|
+
"""Set the app identity for the entire mgf.common stack.
|
|
31
|
+
|
|
32
|
+
Call once at startup, before any other ``mgf.common.*`` function
|
|
33
|
+
that emits observability data (logging setup, OTel setup, crash
|
|
34
|
+
reporter, excepthook installation).
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
app_name
|
|
39
|
+
Short lower-case name for the consuming application
|
|
40
|
+
(e.g., ``"vmanager"``). Used as the default service name in
|
|
41
|
+
OTel traces, the log file directory under ``<state>/``, the
|
|
42
|
+
prefix for env vars (``<APP_NAME>_CRASH_SINK``,
|
|
43
|
+
``<APP_NAME>_LOG_HTTP_SINK``, ``<APP_NAME>_SYSLOG_ADDR``),
|
|
44
|
+
and the SYSLOG identifier for journald.
|
|
45
|
+
app_version
|
|
46
|
+
Version string included in crash reports. Default
|
|
47
|
+
``"unknown"``. Pass ``__version__`` from your top-level
|
|
48
|
+
package.
|
|
49
|
+
|
|
50
|
+
Idempotent: calling again replaces the cached identity. In
|
|
51
|
+
practice this is only useful in tests that exercise multiple
|
|
52
|
+
consumer-app shapes; production callers configure once.
|
|
53
|
+
"""
|
|
54
|
+
global _APP_NAME, _APP_VERSION
|
|
55
|
+
_APP_NAME = app_name
|
|
56
|
+
_APP_VERSION = app_version
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def app_name() -> str:
|
|
60
|
+
"""Return the current app name. Call AFTER :func:`configure`."""
|
|
61
|
+
return _APP_NAME
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def app_version() -> str:
|
|
65
|
+
"""Return the current app version. Call AFTER :func:`configure`."""
|
|
66
|
+
return _APP_VERSION
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
__all__ = ["app_name", "app_version", "configure"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Reusable config infrastructure — base settings + loader + paths.
|
|
2
|
+
|
|
3
|
+
Each consumer project subclasses :class:`BaseAppSettings` to declare
|
|
4
|
+
its own typed fields and its own ``env_prefix``. The package
|
|
5
|
+
contributes:
|
|
6
|
+
|
|
7
|
+
* :class:`BaseAppSettings` — pydantic-settings base with layered
|
|
8
|
+
sources, YAML file override, version-check validator.
|
|
9
|
+
* :func:`make_settings_config` — helper that builds a
|
|
10
|
+
:class:`SettingsConfigDict` with mgf.common's recommended
|
|
11
|
+
baseline, plus any consumer overrides (canonical pattern for
|
|
12
|
+
setting ``env_prefix`` on a subclass).
|
|
13
|
+
* :func:`load_settings` / :func:`save_settings` — typed loader
|
|
14
|
+
with optional migration callback + atomic writer.
|
|
15
|
+
* :func:`platform_dir` / :func:`default_config_path` — cross-OS
|
|
16
|
+
directory helpers honouring ``XDG_*`` env vars.
|
|
17
|
+
* :func:`expand_path` — reusable validator helper for ``~`` and
|
|
18
|
+
``$VARS`` expansion on Path-typed fields.
|
|
19
|
+
|
|
20
|
+
See ``docs/standards/CONFIGURATION.md`` for the design.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from mgf.common.config._loader import load_settings, save_settings
|
|
26
|
+
from mgf.common.config._paths import (
|
|
27
|
+
PathKind,
|
|
28
|
+
default_config_path,
|
|
29
|
+
expand_path,
|
|
30
|
+
platform_dir,
|
|
31
|
+
)
|
|
32
|
+
from mgf.common.config._settings import BaseAppSettings, make_settings_config
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"BaseAppSettings",
|
|
36
|
+
"PathKind",
|
|
37
|
+
"default_config_path",
|
|
38
|
+
"expand_path",
|
|
39
|
+
"load_settings",
|
|
40
|
+
"make_settings_config",
|
|
41
|
+
"platform_dir",
|
|
42
|
+
"save_settings",
|
|
43
|
+
]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Generic YAML loader / saver for :class:`BaseAppSettings` subclasses.
|
|
2
|
+
|
|
3
|
+
Layered loading per ``docs/standards/CONFIGURATION.md`` rule CF-02
|
|
4
|
+
(top wins):
|
|
5
|
+
|
|
6
|
+
1. CLI flags / construction kwargs
|
|
7
|
+
2. Environment variables — prefix set by the subclass
|
|
8
|
+
3. ``.env`` file in the working directory — same prefix
|
|
9
|
+
4. On-disk YAML (``config.yaml``) — see :func:`mgf.common.config.default_config_path`
|
|
10
|
+
5. Typed defaults declared on the subclass
|
|
11
|
+
|
|
12
|
+
Schema versioning + migration (rule CF-03):
|
|
13
|
+
|
|
14
|
+
- The YAML file is read in :func:`load_settings` BEFORE the model
|
|
15
|
+
is built, so a version mismatch can be detected and migrated
|
|
16
|
+
on disk first.
|
|
17
|
+
- Loading a higher version than ``cls.CURRENT_VERSION`` fails loud.
|
|
18
|
+
- Loading a lower version runs the consumer-supplied ``migrate``
|
|
19
|
+
chain and writes the migrated content back atomically.
|
|
20
|
+
|
|
21
|
+
Atomic writes (rule CF-04):
|
|
22
|
+
|
|
23
|
+
- :func:`save_settings` writes to a temp file in the same directory
|
|
24
|
+
then renames, mode ``0o600``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import tempfile
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
33
|
+
|
|
34
|
+
import yaml
|
|
35
|
+
from pydantic import ValidationError
|
|
36
|
+
|
|
37
|
+
from mgf.common.config._settings import BaseAppSettings
|
|
38
|
+
from mgf.common.exceptions import AppConfigError
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Callable
|
|
42
|
+
|
|
43
|
+
T = TypeVar("T", bound=BaseAppSettings)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_settings(
|
|
47
|
+
settings_cls: type[T],
|
|
48
|
+
*,
|
|
49
|
+
path: Path | None = None,
|
|
50
|
+
migrate: Callable[[dict[str, Any], int], dict[str, Any]] | None = None,
|
|
51
|
+
) -> T:
|
|
52
|
+
"""Load ``config.yaml`` into a ``settings_cls`` instance.
|
|
53
|
+
|
|
54
|
+
A missing file is **not** an error — it returns the default
|
|
55
|
+
``settings_cls()`` (with env / .env overrides applied) so first-run
|
|
56
|
+
users get sensible behaviour without writing a config file. A
|
|
57
|
+
malformed file raises :class:`AppConfigError`.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
settings_cls
|
|
62
|
+
A concrete :class:`BaseAppSettings` subclass.
|
|
63
|
+
path
|
|
64
|
+
Path to the YAML file. When ``None``, the caller MUST resolve
|
|
65
|
+
the default themselves (typically via
|
|
66
|
+
:func:`mgf.common.config.default_config_path`) — passing
|
|
67
|
+
``None`` here means "do not read any file; use defaults +
|
|
68
|
+
env only".
|
|
69
|
+
migrate
|
|
70
|
+
Optional callback for upgrading older on-disk schema
|
|
71
|
+
versions. Signature ``(raw_dict, from_version) -> new_dict``.
|
|
72
|
+
When omitted, a version lower than ``settings_cls.CURRENT_VERSION``
|
|
73
|
+
is loaded as-is (the in-memory model fills any new fields
|
|
74
|
+
with their defaults).
|
|
75
|
+
"""
|
|
76
|
+
yaml_for_pydantic: Path | None = None
|
|
77
|
+
if path is not None and path.exists():
|
|
78
|
+
try:
|
|
79
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
80
|
+
except yaml.YAMLError as e:
|
|
81
|
+
raise AppConfigError(f"could not parse {path}: {e}") from e
|
|
82
|
+
if raw is None:
|
|
83
|
+
# Empty / whitespace-only file → treat as defaults; do
|
|
84
|
+
# not pass it through pydantic-settings.
|
|
85
|
+
yaml_for_pydantic = None
|
|
86
|
+
elif not isinstance(raw, dict):
|
|
87
|
+
raise AppConfigError(
|
|
88
|
+
f"{path} must contain a YAML mapping at the top level, got {type(raw).__name__}"
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
# Reject non-string keys at the top level. YAML happily
|
|
92
|
+
# accepts ``None`` / ints / lists / etc. as map keys
|
|
93
|
+
# (e.g., ``yaml.safe_load('?')`` returns ``{None: None}``)
|
|
94
|
+
# which would later crash pydantic-settings with a bare
|
|
95
|
+
# ``TypeError: keywords must be strings``. Catching it
|
|
96
|
+
# here gives the user a typed AppConfigError pointing at
|
|
97
|
+
# the offending key.
|
|
98
|
+
bad_keys = [k for k in raw if not isinstance(k, str)]
|
|
99
|
+
if bad_keys:
|
|
100
|
+
raise AppConfigError(f"{path} has non-string top-level keys: {bad_keys!r}")
|
|
101
|
+
current_version = settings_cls.CURRENT_VERSION
|
|
102
|
+
version = raw.get("version", current_version)
|
|
103
|
+
if not isinstance(version, int):
|
|
104
|
+
raise AppConfigError(f"version must be an int, got {type(version).__name__}")
|
|
105
|
+
if version > current_version:
|
|
106
|
+
raise AppConfigError(
|
|
107
|
+
f"config.yaml version {version} is newer than supported "
|
|
108
|
+
f"version {current_version}; upgrade the application"
|
|
109
|
+
)
|
|
110
|
+
if version < current_version and migrate is not None:
|
|
111
|
+
# Rewrite the file with the migrated content so
|
|
112
|
+
# subsequent loads see the current schema.
|
|
113
|
+
migrated = migrate(raw, version)
|
|
114
|
+
_write_yaml_atomic(path, migrated)
|
|
115
|
+
yaml_for_pydantic = path
|
|
116
|
+
|
|
117
|
+
# Tell the model where to read YAML from for THIS call, then
|
|
118
|
+
# construct. Reset afterwards so subsequent ``settings_cls()``
|
|
119
|
+
# constructions (e.g., in tests) don't accidentally pick up a
|
|
120
|
+
# stale path.
|
|
121
|
+
prior = settings_cls._yaml_file_override
|
|
122
|
+
settings_cls._yaml_file_override = yaml_for_pydantic
|
|
123
|
+
try:
|
|
124
|
+
return settings_cls()
|
|
125
|
+
except ValidationError as e:
|
|
126
|
+
# Aggregate every field error into one AppConfigError so the
|
|
127
|
+
# user sees every problem at once.
|
|
128
|
+
errors = "; ".join(
|
|
129
|
+
f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}" for err in e.errors()
|
|
130
|
+
)
|
|
131
|
+
raise AppConfigError(f"invalid configuration: {errors}") from e
|
|
132
|
+
finally:
|
|
133
|
+
settings_cls._yaml_file_override = prior
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def save_settings(cfg: BaseAppSettings, path: Path) -> None:
|
|
137
|
+
"""Write a settings instance to ``config.yaml`` atomically.
|
|
138
|
+
|
|
139
|
+
Writes to a temp file in the same directory then renames, so a
|
|
140
|
+
crash mid-write never leaves a truncated file behind. Creates
|
|
141
|
+
the parent directory if it does not exist. File mode is
|
|
142
|
+
``0o600`` — the config has no secrets today, but keeping it
|
|
143
|
+
user-private is a sensible default.
|
|
144
|
+
"""
|
|
145
|
+
payload = cfg.to_yaml_dict()
|
|
146
|
+
# Ensure version is always written, even if the caller hand-built
|
|
147
|
+
# the model from pieces and forgot to set it.
|
|
148
|
+
payload["version"] = type(cfg).CURRENT_VERSION
|
|
149
|
+
_write_yaml_atomic(path, payload)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Internal: atomic YAML write
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _write_yaml_atomic(target: Path, payload: dict[str, Any]) -> None:
|
|
158
|
+
"""Atomic temp-file + rename write of a YAML payload.
|
|
159
|
+
|
|
160
|
+
Mode 0o600. Creates the parent directory if absent. On failure
|
|
161
|
+
the temp file is cleaned up before the exception propagates.
|
|
162
|
+
|
|
163
|
+
Used by :func:`save_settings` AND by :func:`load_settings` when a
|
|
164
|
+
migration step rewrites the on-disk representation.
|
|
165
|
+
"""
|
|
166
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
fd, tmp_name = tempfile.mkstemp(prefix=".config.yaml.", dir=str(target.parent), text=True)
|
|
168
|
+
tmp_path = Path(tmp_name)
|
|
169
|
+
try:
|
|
170
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
171
|
+
yaml.safe_dump(
|
|
172
|
+
payload,
|
|
173
|
+
fh,
|
|
174
|
+
sort_keys=False,
|
|
175
|
+
default_flow_style=False,
|
|
176
|
+
allow_unicode=True,
|
|
177
|
+
)
|
|
178
|
+
tmp_path.chmod(0o600)
|
|
179
|
+
tmp_path.replace(target)
|
|
180
|
+
except Exception:
|
|
181
|
+
tmp_path.unlink(missing_ok=True)
|
|
182
|
+
raise
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
__all__ = ["load_settings", "save_settings"]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Cross-platform per-OS directory helpers.
|
|
2
|
+
|
|
3
|
+
Centralises the XDG / macOS Application Support / Windows AppData
|
|
4
|
+
mapping so :func:`default_config_path` and any future state-/data-dir
|
|
5
|
+
helper share one cross-platform map. Always honours ``XDG_*`` env
|
|
6
|
+
vars when set so power-users keep control across all platforms.
|
|
7
|
+
|
|
8
|
+
Resolved at call time (rule CF-04 lazy resolution) so tests
|
|
9
|
+
monkeypatching ``HOME`` / ``APPDATA`` per test get the right path
|
|
10
|
+
for that test.
|
|
11
|
+
|
|
12
|
+
Pure stdlib — no other mgf.common imports.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Literal
|
|
21
|
+
|
|
22
|
+
PathKind = Literal["config", "state", "data"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def platform_dir(kind: PathKind) -> Path:
|
|
26
|
+
"""Return the per-OS base directory for ``kind``.
|
|
27
|
+
|
|
28
|
+
The mapping (without env overrides):
|
|
29
|
+
|
|
30
|
+
+---------+-------------------------+-------------------------------------+----------------------------+
|
|
31
|
+
| kind | Linux | macOS | Windows |
|
|
32
|
+
+=========+=========================+=====================================+============================+
|
|
33
|
+
| config | ``~/.config`` | ``~/Library/Application Support`` | ``%APPDATA%`` |
|
|
34
|
+
+---------+-------------------------+-------------------------------------+----------------------------+
|
|
35
|
+
| state | ``~/.local/state`` | ``~/Library/Application Support`` | ``%LOCALAPPDATA%`` |
|
|
36
|
+
+---------+-------------------------+-------------------------------------+----------------------------+
|
|
37
|
+
| data | ``~/.local/share`` | ``~/Library/Application Support`` | ``%LOCALAPPDATA%`` |
|
|
38
|
+
+---------+-------------------------+-------------------------------------+----------------------------+
|
|
39
|
+
|
|
40
|
+
XDG always wins when set (rule CF-09 — power users keep control
|
|
41
|
+
across all platforms).
|
|
42
|
+
"""
|
|
43
|
+
xdg_var = {
|
|
44
|
+
"config": "XDG_CONFIG_HOME",
|
|
45
|
+
"state": "XDG_STATE_HOME",
|
|
46
|
+
"data": "XDG_DATA_HOME",
|
|
47
|
+
}[kind]
|
|
48
|
+
xdg = os.environ.get(xdg_var)
|
|
49
|
+
if xdg:
|
|
50
|
+
return Path(xdg)
|
|
51
|
+
|
|
52
|
+
if os.name == "nt": # pragma: no cover — Linux CI; covered by smoke
|
|
53
|
+
env_var = "APPDATA" if kind == "config" else "LOCALAPPDATA"
|
|
54
|
+
env_value = os.environ.get(env_var)
|
|
55
|
+
if env_value:
|
|
56
|
+
return Path(env_value)
|
|
57
|
+
# Fall back to the canonical Roaming/Local path beneath
|
|
58
|
+
# USERPROFILE; matches what %APPDATA% resolves to by default.
|
|
59
|
+
suffix = "Roaming" if kind == "config" else "Local"
|
|
60
|
+
return Path.home() / "AppData" / suffix
|
|
61
|
+
|
|
62
|
+
if sys.platform == "darwin": # pragma: no cover — Linux CI; covered by smoke
|
|
63
|
+
return Path.home() / "Library" / "Application Support"
|
|
64
|
+
|
|
65
|
+
# Linux / other Unix
|
|
66
|
+
fallback = {
|
|
67
|
+
"config": ".config",
|
|
68
|
+
"state": ".local/state",
|
|
69
|
+
"data": ".local/share",
|
|
70
|
+
}[kind]
|
|
71
|
+
return Path.home() / fallback
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def default_config_path(app: str | None = None) -> Path:
|
|
75
|
+
"""Return the default ``config.yaml`` path for ``app`` (rule CF-09).
|
|
76
|
+
|
|
77
|
+
When ``app`` is ``None``, reads from :func:`mgf.common.app_name`
|
|
78
|
+
so the consumer's configured identity is used.
|
|
79
|
+
"""
|
|
80
|
+
if app is None:
|
|
81
|
+
from mgf.common._identity import app_name
|
|
82
|
+
|
|
83
|
+
app = app_name()
|
|
84
|
+
return platform_dir("config") / app / "config.yaml"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def expand_path(v: object) -> object:
|
|
88
|
+
"""Expand ``~`` and ``$VARS`` in a Path-typed field.
|
|
89
|
+
|
|
90
|
+
Accepts ``str`` / ``Path`` / anything else (returns unchanged).
|
|
91
|
+
Lazy resolution per rule CF-04: paths are NOT expanded at
|
|
92
|
+
module import time, so tests that monkeypatch ``HOME`` per
|
|
93
|
+
test get the right path for that test.
|
|
94
|
+
|
|
95
|
+
Plug into a Pydantic ``BaseSettings`` subclass via::
|
|
96
|
+
|
|
97
|
+
@field_validator(*_PATH_FIELDS, mode="before")
|
|
98
|
+
@classmethod
|
|
99
|
+
def _expand_paths(cls, v: object) -> object:
|
|
100
|
+
return expand_path(v)
|
|
101
|
+
"""
|
|
102
|
+
if isinstance(v, str):
|
|
103
|
+
v = Path(v)
|
|
104
|
+
if isinstance(v, Path):
|
|
105
|
+
return Path(os.path.expandvars(str(v))).expanduser()
|
|
106
|
+
return v
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
__all__ = [
|
|
110
|
+
"PathKind",
|
|
111
|
+
"default_config_path",
|
|
112
|
+
"expand_path",
|
|
113
|
+
"platform_dir",
|
|
114
|
+
]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Reusable :class:`BaseAppSettings` for consumer projects to subclass.
|
|
2
|
+
|
|
3
|
+
Each consumer project subclasses :class:`BaseAppSettings` to declare
|
|
4
|
+
its own typed fields and its own ``env_prefix``. The base contributes:
|
|
5
|
+
|
|
6
|
+
- the layered source order (rule CF-02): kwargs > env > .env >
|
|
7
|
+
YAML > file_secrets;
|
|
8
|
+
- the YAML-file override hook (set by :func:`mgf.common.config.load_settings`
|
|
9
|
+
before constructing the model);
|
|
10
|
+
- the ``version`` field + version-check validator (rule CF-03);
|
|
11
|
+
- a ``to_yaml_dict`` helper that round-trips through :func:`load_settings`.
|
|
12
|
+
|
|
13
|
+
Subclasses MUST set their own ``env_prefix`` in ``model_config``.
|
|
14
|
+
Use :func:`make_settings_config` to get the recommended baseline plus
|
|
15
|
+
your own overrides — this is the canonical pattern::
|
|
16
|
+
|
|
17
|
+
from mgf.common.config import BaseAppSettings, make_settings_config
|
|
18
|
+
|
|
19
|
+
class AppConfig(BaseAppSettings):
|
|
20
|
+
model_config = make_settings_config(env_prefix="VMANAGER_")
|
|
21
|
+
|
|
22
|
+
libvirt_uri: str = "qemu:///system"
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
The ``**BaseAppSettings.model_config`` spread does NOT work because
|
|
26
|
+
pydantic-settings pre-populates ``model_config`` with ~40 defaults
|
|
27
|
+
(including ``env_prefix=""``); spreading them and then passing
|
|
28
|
+
``env_prefix="VMANAGER_"`` raises ``TypeError: got multiple values
|
|
29
|
+
for keyword argument 'env_prefix'``. The helper is the safe path.
|
|
30
|
+
|
|
31
|
+
See ``docs/standards/CONFIGURATION.md`` for the full standard.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import TYPE_CHECKING, Any, ClassVar, cast
|
|
38
|
+
|
|
39
|
+
from pydantic import field_validator
|
|
40
|
+
from pydantic_settings import (
|
|
41
|
+
BaseSettings,
|
|
42
|
+
SettingsConfigDict,
|
|
43
|
+
YamlConfigSettingsSource,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from pydantic_settings import PydanticBaseSettingsSource
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def make_settings_config(**overrides: Any) -> SettingsConfigDict:
|
|
51
|
+
"""Build a :class:`SettingsConfigDict` with mgf.common's recommended baseline.
|
|
52
|
+
|
|
53
|
+
Use this in every :class:`BaseAppSettings` subclass that needs to
|
|
54
|
+
set its own ``env_prefix`` (or override any other setting). The
|
|
55
|
+
helper applies these defaults first, then your ``overrides`` on top::
|
|
56
|
+
|
|
57
|
+
env_file = ".env"
|
|
58
|
+
env_file_encoding = "utf-8"
|
|
59
|
+
extra = "forbid" # rule CF-06
|
|
60
|
+
case_sensitive = False
|
|
61
|
+
validate_default = True
|
|
62
|
+
arbitrary_types_allowed = True
|
|
63
|
+
|
|
64
|
+
Why a helper instead of ``**BaseAppSettings.model_config``: pydantic
|
|
65
|
+
fully materialises ``model_config`` with every setting's default
|
|
66
|
+
(``env_prefix=""`` among them), so spreading it and re-supplying
|
|
67
|
+
``env_prefix="VMANAGER_"`` raises ``TypeError: got multiple values
|
|
68
|
+
for keyword argument 'env_prefix'``. The helper sidesteps that by
|
|
69
|
+
only passing the keys we explicitly opt into.
|
|
70
|
+
"""
|
|
71
|
+
defaults: dict[str, Any] = {
|
|
72
|
+
"env_file": ".env",
|
|
73
|
+
"env_file_encoding": "utf-8",
|
|
74
|
+
# Reject unknown keys (rule CF-06). Typos in the YAML file
|
|
75
|
+
# surface as a single ValidationError listing every bad key.
|
|
76
|
+
"extra": "forbid",
|
|
77
|
+
"case_sensitive": False,
|
|
78
|
+
"validate_default": True,
|
|
79
|
+
"arbitrary_types_allowed": True,
|
|
80
|
+
}
|
|
81
|
+
defaults.update(overrides)
|
|
82
|
+
# ``SettingsConfigDict`` is a TypedDict — at runtime just a dict —
|
|
83
|
+
# so cast is the cheapest way to carry our dynamic-key dict across
|
|
84
|
+
# the type boundary. Mypy can't validate the keys here because
|
|
85
|
+
# ``overrides`` is typed ``Any``; pydantic-settings validates the
|
|
86
|
+
# keys when it actually consumes ``model_config`` on the subclass.
|
|
87
|
+
return cast("SettingsConfigDict", defaults)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class BaseAppSettings(BaseSettings):
|
|
91
|
+
"""Base typed application configuration (rule CF-01).
|
|
92
|
+
|
|
93
|
+
Subclasses :class:`pydantic_settings.BaseSettings`. Subclasses
|
|
94
|
+
MUST set their own ``env_prefix`` via :func:`make_settings_config`
|
|
95
|
+
(the base leaves it empty so consumers can't accidentally inherit
|
|
96
|
+
a stale one).
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
model_config = make_settings_config()
|
|
100
|
+
|
|
101
|
+
# Per-call YAML override. :func:`load_settings` sets this before
|
|
102
|
+
# constructing the model and resets it after. NOT thread-safe;
|
|
103
|
+
# load_settings is not called concurrently in any code path today.
|
|
104
|
+
# ``ClassVar`` tells pydantic this is a real class attribute
|
|
105
|
+
# (not a model field or private attribute), so it lives on the
|
|
106
|
+
# class itself and :meth:`settings_customise_sources` can read it.
|
|
107
|
+
_yaml_file_override: ClassVar[Path | None] = None
|
|
108
|
+
|
|
109
|
+
# Current schema version. Subclasses MAY override.
|
|
110
|
+
CURRENT_VERSION: ClassVar[int] = 1
|
|
111
|
+
|
|
112
|
+
# ── Schema version field ───────────────────────────────────────────────
|
|
113
|
+
version: int = 1
|
|
114
|
+
|
|
115
|
+
@field_validator("version")
|
|
116
|
+
@classmethod
|
|
117
|
+
def _check_version(cls, v: int) -> int:
|
|
118
|
+
if v > cls.CURRENT_VERSION:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"config.yaml version {v} is newer than supported "
|
|
121
|
+
f"version {cls.CURRENT_VERSION}; upgrade the application"
|
|
122
|
+
)
|
|
123
|
+
return v
|
|
124
|
+
|
|
125
|
+
# ── Source customisation — wire YAML in at the right precedence ──────
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def settings_customise_sources(
|
|
129
|
+
cls,
|
|
130
|
+
settings_cls: type[BaseSettings],
|
|
131
|
+
init_settings: PydanticBaseSettingsSource,
|
|
132
|
+
env_settings: PydanticBaseSettingsSource,
|
|
133
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
134
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
135
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
136
|
+
"""Define source precedence (rule CF-02 — leftmost wins).
|
|
137
|
+
|
|
138
|
+
``init_settings`` (kwargs) > ``env_settings`` >
|
|
139
|
+
``dotenv_settings`` > YAML > ``file_secret_settings``
|
|
140
|
+
(Docker-style file mounts).
|
|
141
|
+
|
|
142
|
+
The YAML source is only attached when
|
|
143
|
+
:attr:`_yaml_file_override` is set (by :func:`load_settings`)
|
|
144
|
+
and the file actually exists. This keeps direct
|
|
145
|
+
``Settings()`` construction free of file IO and lets tests
|
|
146
|
+
construct settings without touching disk.
|
|
147
|
+
"""
|
|
148
|
+
sources: list[PydanticBaseSettingsSource] = [
|
|
149
|
+
init_settings,
|
|
150
|
+
env_settings,
|
|
151
|
+
dotenv_settings,
|
|
152
|
+
]
|
|
153
|
+
path = cls._yaml_file_override
|
|
154
|
+
if path is not None and path.exists():
|
|
155
|
+
sources.append(YamlConfigSettingsSource(settings_cls, yaml_file=path))
|
|
156
|
+
sources.append(file_secret_settings)
|
|
157
|
+
return tuple(sources)
|
|
158
|
+
|
|
159
|
+
# ── Serialisation helper ───────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
def to_yaml_dict(self) -> dict[str, Any]:
|
|
162
|
+
"""Return a plain dict suitable for ``yaml.safe_dump``.
|
|
163
|
+
|
|
164
|
+
``Path`` fields are serialised as POSIX strings; enums /
|
|
165
|
+
Literals as their underlying values. The result round-trips
|
|
166
|
+
through :func:`load_settings`.
|
|
167
|
+
|
|
168
|
+
Kept for backwards compatibility — new code MAY use
|
|
169
|
+
:meth:`model_dump` directly with ``mode="json"``.
|
|
170
|
+
"""
|
|
171
|
+
out: dict[str, Any] = {}
|
|
172
|
+
# Iterate over the CLASS-level ``model_fields`` (instance-level
|
|
173
|
+
# access is deprecated in Pydantic V2.11). Use ``getattr`` so
|
|
174
|
+
# Path fields stay as Path objects until we coerce them
|
|
175
|
+
# explicitly below.
|
|
176
|
+
for name in type(self).model_fields:
|
|
177
|
+
value = getattr(self, name)
|
|
178
|
+
if isinstance(value, Path):
|
|
179
|
+
out[name] = str(value)
|
|
180
|
+
else:
|
|
181
|
+
out[name] = value
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
__all__ = ["BaseAppSettings", "make_settings_config"]
|