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 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"
@@ -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"]