pydantic-fixturegen 1.0.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.
Potentially problematic release.
This version of pydantic-fixturegen might be problematic. Click here for more details.
- pydantic_fixturegen/__init__.py +7 -0
- pydantic_fixturegen/cli/__init__.py +85 -0
- pydantic_fixturegen/cli/doctor.py +235 -0
- pydantic_fixturegen/cli/gen/__init__.py +23 -0
- pydantic_fixturegen/cli/gen/_common.py +139 -0
- pydantic_fixturegen/cli/gen/explain.py +145 -0
- pydantic_fixturegen/cli/gen/fixtures.py +283 -0
- pydantic_fixturegen/cli/gen/json.py +262 -0
- pydantic_fixturegen/cli/gen/schema.py +164 -0
- pydantic_fixturegen/cli/list.py +164 -0
- pydantic_fixturegen/core/__init__.py +103 -0
- pydantic_fixturegen/core/ast_discover.py +169 -0
- pydantic_fixturegen/core/config.py +440 -0
- pydantic_fixturegen/core/errors.py +136 -0
- pydantic_fixturegen/core/generate.py +311 -0
- pydantic_fixturegen/core/introspect.py +141 -0
- pydantic_fixturegen/core/io_utils.py +77 -0
- pydantic_fixturegen/core/providers/__init__.py +32 -0
- pydantic_fixturegen/core/providers/collections.py +74 -0
- pydantic_fixturegen/core/providers/identifiers.py +68 -0
- pydantic_fixturegen/core/providers/numbers.py +133 -0
- pydantic_fixturegen/core/providers/registry.py +98 -0
- pydantic_fixturegen/core/providers/strings.py +109 -0
- pydantic_fixturegen/core/providers/temporal.py +42 -0
- pydantic_fixturegen/core/safe_import.py +403 -0
- pydantic_fixturegen/core/schema.py +320 -0
- pydantic_fixturegen/core/seed.py +154 -0
- pydantic_fixturegen/core/strategies.py +193 -0
- pydantic_fixturegen/core/version.py +52 -0
- pydantic_fixturegen/emitters/__init__.py +15 -0
- pydantic_fixturegen/emitters/json_out.py +373 -0
- pydantic_fixturegen/emitters/pytest_codegen.py +365 -0
- pydantic_fixturegen/emitters/schema_out.py +84 -0
- pydantic_fixturegen/plugins/builtin.py +45 -0
- pydantic_fixturegen/plugins/hookspecs.py +59 -0
- pydantic_fixturegen/plugins/loader.py +72 -0
- pydantic_fixturegen-1.0.0.dist-info/METADATA +280 -0
- pydantic_fixturegen-1.0.0.dist-info/RECORD +41 -0
- pydantic_fixturegen-1.0.0.dist-info/WHEEL +4 -0
- pydantic_fixturegen-1.0.0.dist-info/entry_points.txt +5 -0
- pydantic_fixturegen-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Configuration loader for pydantic-fixturegen."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping, MutableMapping, Sequence
|
|
7
|
+
from dataclasses import dataclass, field, replace
|
|
8
|
+
from importlib import import_module
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, TypeVar, cast
|
|
11
|
+
|
|
12
|
+
from .seed import DEFAULT_LOCALE
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _import_tomllib() -> Any:
|
|
16
|
+
try: # pragma: no cover - runtime path
|
|
17
|
+
return import_module("tomllib")
|
|
18
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
19
|
+
return import_module("tomli")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
tomllib = cast(Any, _import_tomllib())
|
|
23
|
+
|
|
24
|
+
try: # pragma: no cover - optional dependency
|
|
25
|
+
yaml = cast(Any, import_module("yaml"))
|
|
26
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
27
|
+
yaml = None
|
|
28
|
+
|
|
29
|
+
_DEFAULT_PYPROJECT = Path("pyproject.toml")
|
|
30
|
+
_DEFAULT_YAML_NAMES = (
|
|
31
|
+
Path("pydantic-fixturegen.yaml"),
|
|
32
|
+
Path("pydantic-fixturegen.yml"),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
UNION_POLICIES = {"first", "random", "weighted"}
|
|
36
|
+
ENUM_POLICIES = {"first", "random"}
|
|
37
|
+
|
|
38
|
+
TRUTHY = {"1", "true", "yes", "on"}
|
|
39
|
+
FALSY = {"0", "false", "no", "off"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ConfigError(ValueError):
|
|
43
|
+
"""Raised when configuration sources contain invalid data."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class PytestEmitterConfig:
|
|
48
|
+
style: str = "functions"
|
|
49
|
+
scope: str = "function"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class JsonConfig:
|
|
54
|
+
indent: int = 2
|
|
55
|
+
orjson: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class EmittersConfig:
|
|
60
|
+
pytest: PytestEmitterConfig = field(default_factory=PytestEmitterConfig)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class AppConfig:
|
|
65
|
+
seed: int | str | None = None
|
|
66
|
+
locale: str = DEFAULT_LOCALE
|
|
67
|
+
include: tuple[str, ...] = ()
|
|
68
|
+
exclude: tuple[str, ...] = ()
|
|
69
|
+
p_none: float | None = None
|
|
70
|
+
union_policy: str = "first"
|
|
71
|
+
enum_policy: str = "first"
|
|
72
|
+
overrides: Mapping[str, Mapping[str, Any]] = field(default_factory=dict)
|
|
73
|
+
emitters: EmittersConfig = field(default_factory=EmittersConfig)
|
|
74
|
+
json: JsonConfig = field(default_factory=JsonConfig)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
DEFAULT_CONFIG = AppConfig()
|
|
78
|
+
|
|
79
|
+
T = TypeVar("T")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_config(
|
|
83
|
+
*,
|
|
84
|
+
root: Path | str | None = None,
|
|
85
|
+
pyproject_path: Path | str | None = None,
|
|
86
|
+
yaml_path: Path | str | None = None,
|
|
87
|
+
env: Mapping[str, str] | None = None,
|
|
88
|
+
cli: Mapping[str, Any] | None = None,
|
|
89
|
+
) -> AppConfig:
|
|
90
|
+
"""Load configuration applying precedence CLI > env > config > defaults."""
|
|
91
|
+
root_path = Path(root) if root else Path.cwd()
|
|
92
|
+
pyproject = Path(pyproject_path) if pyproject_path else root_path / _DEFAULT_PYPROJECT
|
|
93
|
+
yaml_file = Path(yaml_path) if yaml_path else _find_existing_yaml(root_path)
|
|
94
|
+
|
|
95
|
+
data: dict[str, Any] = {}
|
|
96
|
+
_deep_merge(data, _config_defaults_dict())
|
|
97
|
+
|
|
98
|
+
file_config = _load_file_config(pyproject, yaml_file)
|
|
99
|
+
_deep_merge(data, file_config)
|
|
100
|
+
|
|
101
|
+
env_config = _load_env_config(env or os.environ)
|
|
102
|
+
_deep_merge(data, env_config)
|
|
103
|
+
|
|
104
|
+
if cli:
|
|
105
|
+
_deep_merge(data, cli)
|
|
106
|
+
|
|
107
|
+
return _build_app_config(data)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _config_defaults_dict() -> dict[str, Any]:
|
|
111
|
+
return {
|
|
112
|
+
"seed": DEFAULT_CONFIG.seed,
|
|
113
|
+
"locale": DEFAULT_CONFIG.locale,
|
|
114
|
+
"include": list(DEFAULT_CONFIG.include),
|
|
115
|
+
"exclude": list(DEFAULT_CONFIG.exclude),
|
|
116
|
+
"p_none": DEFAULT_CONFIG.p_none,
|
|
117
|
+
"union_policy": DEFAULT_CONFIG.union_policy,
|
|
118
|
+
"enum_policy": DEFAULT_CONFIG.enum_policy,
|
|
119
|
+
"overrides": {},
|
|
120
|
+
"emitters": {
|
|
121
|
+
"pytest": {
|
|
122
|
+
"style": DEFAULT_CONFIG.emitters.pytest.style,
|
|
123
|
+
"scope": DEFAULT_CONFIG.emitters.pytest.scope,
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"json": {
|
|
127
|
+
"indent": DEFAULT_CONFIG.json.indent,
|
|
128
|
+
"orjson": DEFAULT_CONFIG.json.orjson,
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _load_file_config(pyproject_path: Path, yaml_path: Path | None) -> dict[str, Any]:
|
|
134
|
+
config: dict[str, Any] = {}
|
|
135
|
+
|
|
136
|
+
if pyproject_path.is_file():
|
|
137
|
+
with pyproject_path.open("rb") as fh:
|
|
138
|
+
pyproject_data = tomllib.load(fh)
|
|
139
|
+
tool_config = cast(Mapping[str, Any], pyproject_data.get("tool", {}))
|
|
140
|
+
project_config = cast(Mapping[str, Any], tool_config.get("pydantic_fixturegen", {}))
|
|
141
|
+
config = _ensure_mutable(project_config)
|
|
142
|
+
|
|
143
|
+
if yaml_path and yaml_path.is_file():
|
|
144
|
+
if yaml is None:
|
|
145
|
+
raise ConfigError("YAML configuration provided but PyYAML is not installed.")
|
|
146
|
+
with yaml_path.open("r", encoding="utf-8") as fh:
|
|
147
|
+
yaml_data = yaml.safe_load(fh) or {}
|
|
148
|
+
if not isinstance(yaml_data, Mapping):
|
|
149
|
+
raise ConfigError("YAML configuration must be a mapping at the top level.")
|
|
150
|
+
yaml_dict = _ensure_mutable(yaml_data)
|
|
151
|
+
_deep_merge(config, yaml_dict)
|
|
152
|
+
|
|
153
|
+
return config
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _find_existing_yaml(root: Path) -> Path | None:
|
|
157
|
+
for candidate in _DEFAULT_YAML_NAMES:
|
|
158
|
+
path = root / candidate
|
|
159
|
+
if path.is_file():
|
|
160
|
+
return path
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _load_env_config(env: Mapping[str, str]) -> dict[str, Any]:
|
|
165
|
+
config: dict[str, Any] = {}
|
|
166
|
+
prefix = "PFG_"
|
|
167
|
+
|
|
168
|
+
for key, raw_value in env.items():
|
|
169
|
+
if not key.startswith(prefix):
|
|
170
|
+
continue
|
|
171
|
+
path_segments = key[len(prefix) :].split("__")
|
|
172
|
+
if not path_segments:
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
top_key = path_segments[0].lower()
|
|
176
|
+
nested_segments = path_segments[1:]
|
|
177
|
+
|
|
178
|
+
target = cast(MutableMapping[str, Any], config)
|
|
179
|
+
current_key = top_key
|
|
180
|
+
preserve_case = top_key == "overrides"
|
|
181
|
+
|
|
182
|
+
for index, segment in enumerate(nested_segments):
|
|
183
|
+
next_key = segment if preserve_case else segment.lower()
|
|
184
|
+
|
|
185
|
+
if index == len(nested_segments) - 1:
|
|
186
|
+
value = _coerce_env_value(raw_value)
|
|
187
|
+
_set_nested_value(target, current_key, next_key, value)
|
|
188
|
+
else:
|
|
189
|
+
next_container = cast(MutableMapping[str, Any], target.setdefault(current_key, {}))
|
|
190
|
+
target = next_container
|
|
191
|
+
current_key = next_key
|
|
192
|
+
preserve_case = preserve_case or current_key == "overrides"
|
|
193
|
+
|
|
194
|
+
if not nested_segments:
|
|
195
|
+
value = _coerce_env_value(raw_value)
|
|
196
|
+
target[current_key] = value
|
|
197
|
+
|
|
198
|
+
return config
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _set_nested_value(
|
|
202
|
+
mapping: MutableMapping[str, Any], current_key: str, next_key: str, value: Any
|
|
203
|
+
) -> None:
|
|
204
|
+
if current_key not in mapping or not isinstance(mapping[current_key], MutableMapping):
|
|
205
|
+
mapping[current_key] = {}
|
|
206
|
+
nested = cast(MutableMapping[str, Any], mapping[current_key])
|
|
207
|
+
nested[next_key] = value
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _coerce_env_value(value: str) -> Any:
|
|
211
|
+
stripped = value.strip()
|
|
212
|
+
lower = stripped.lower()
|
|
213
|
+
|
|
214
|
+
if lower in TRUTHY:
|
|
215
|
+
return True
|
|
216
|
+
if lower in FALSY:
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
if "," in stripped:
|
|
220
|
+
return [part.strip() for part in stripped.split(",") if part.strip()]
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
return int(stripped)
|
|
224
|
+
except ValueError:
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
return float(stripped)
|
|
229
|
+
except ValueError:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
return stripped
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _build_app_config(data: Mapping[str, Any]) -> AppConfig:
|
|
236
|
+
seed = data.get("seed")
|
|
237
|
+
locale = _coerce_str(data.get("locale"), "locale")
|
|
238
|
+
include = _normalize_sequence(data.get("include"))
|
|
239
|
+
exclude = _normalize_sequence(data.get("exclude"))
|
|
240
|
+
|
|
241
|
+
p_none = data.get("p_none")
|
|
242
|
+
if p_none is not None:
|
|
243
|
+
try:
|
|
244
|
+
p_val = float(p_none)
|
|
245
|
+
except (TypeError, ValueError) as exc:
|
|
246
|
+
raise ConfigError("p_none must be a float value.") from exc
|
|
247
|
+
if not (0.0 <= p_val <= 1.0):
|
|
248
|
+
raise ConfigError("p_none must be between 0.0 and 1.0 inclusive.")
|
|
249
|
+
p_none_value: float | None = p_val
|
|
250
|
+
else:
|
|
251
|
+
p_none_value = None
|
|
252
|
+
|
|
253
|
+
union_policy = _coerce_policy(data.get("union_policy"), UNION_POLICIES, "union_policy")
|
|
254
|
+
enum_policy = _coerce_policy(data.get("enum_policy"), ENUM_POLICIES, "enum_policy")
|
|
255
|
+
|
|
256
|
+
overrides_value = _normalize_overrides(data.get("overrides"))
|
|
257
|
+
|
|
258
|
+
emitters_value = _normalize_emitters(data.get("emitters"))
|
|
259
|
+
json_value = _normalize_json(data.get("json"))
|
|
260
|
+
|
|
261
|
+
seed_value: int | str | None
|
|
262
|
+
if isinstance(seed, (int, str)) or seed is None:
|
|
263
|
+
seed_value = seed
|
|
264
|
+
else:
|
|
265
|
+
raise ConfigError("seed must be an int, str, or null.")
|
|
266
|
+
|
|
267
|
+
config = AppConfig(
|
|
268
|
+
seed=seed_value,
|
|
269
|
+
locale=locale,
|
|
270
|
+
include=include,
|
|
271
|
+
exclude=exclude,
|
|
272
|
+
p_none=p_none_value,
|
|
273
|
+
union_policy=union_policy,
|
|
274
|
+
enum_policy=enum_policy,
|
|
275
|
+
overrides=overrides_value,
|
|
276
|
+
emitters=emitters_value,
|
|
277
|
+
json=json_value,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
return config
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _coerce_str(value: Any, field_name: str) -> str:
|
|
284
|
+
if value is None:
|
|
285
|
+
return cast(str, getattr(DEFAULT_CONFIG, field_name))
|
|
286
|
+
if not isinstance(value, str):
|
|
287
|
+
raise ConfigError(f"{field_name} must be a string.")
|
|
288
|
+
return value
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _normalize_sequence(value: Any) -> tuple[str, ...]:
|
|
292
|
+
if value is None:
|
|
293
|
+
return ()
|
|
294
|
+
if isinstance(value, str):
|
|
295
|
+
parts = [part.strip() for part in value.split(",") if part.strip()]
|
|
296
|
+
return tuple(parts)
|
|
297
|
+
if isinstance(value, Sequence):
|
|
298
|
+
sequence_items: list[str] = []
|
|
299
|
+
for item in value:
|
|
300
|
+
if not isinstance(item, str):
|
|
301
|
+
raise ConfigError("Sequence values must contain only strings.")
|
|
302
|
+
sequence_items.append(item)
|
|
303
|
+
return tuple(sequence_items)
|
|
304
|
+
raise ConfigError("Expected a sequence or string value.")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _coerce_policy(value: Any, allowed: set[str], field_name: str) -> str:
|
|
308
|
+
default_value = cast(str, getattr(DEFAULT_CONFIG, field_name))
|
|
309
|
+
if value is None:
|
|
310
|
+
return default_value
|
|
311
|
+
if not isinstance(value, str):
|
|
312
|
+
raise ConfigError(f"{field_name} must be a string.")
|
|
313
|
+
if value not in allowed:
|
|
314
|
+
raise ConfigError(f"{field_name} must be one of {sorted(allowed)}.")
|
|
315
|
+
return value
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _normalize_overrides(value: Any) -> Mapping[str, Mapping[str, Any]]:
|
|
319
|
+
if value is None:
|
|
320
|
+
return {}
|
|
321
|
+
if not isinstance(value, Mapping):
|
|
322
|
+
raise ConfigError("overrides must be a mapping.")
|
|
323
|
+
|
|
324
|
+
overrides: dict[str, dict[str, Any]] = {}
|
|
325
|
+
for model_key, fields in value.items():
|
|
326
|
+
if not isinstance(model_key, str):
|
|
327
|
+
raise ConfigError("override model keys must be strings.")
|
|
328
|
+
if not isinstance(fields, Mapping):
|
|
329
|
+
raise ConfigError("override fields must be mappings.")
|
|
330
|
+
overrides[model_key] = {}
|
|
331
|
+
for field_name, field_config in fields.items():
|
|
332
|
+
if not isinstance(field_name, str):
|
|
333
|
+
raise ConfigError("override field names must be strings.")
|
|
334
|
+
overrides[model_key][field_name] = field_config
|
|
335
|
+
return overrides
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _normalize_emitters(value: Any) -> EmittersConfig:
|
|
339
|
+
pytest_config = PytestEmitterConfig()
|
|
340
|
+
|
|
341
|
+
if value:
|
|
342
|
+
if not isinstance(value, Mapping):
|
|
343
|
+
raise ConfigError("emitters must be a mapping.")
|
|
344
|
+
pytest_data = value.get("pytest")
|
|
345
|
+
if pytest_data is not None:
|
|
346
|
+
if not isinstance(pytest_data, Mapping):
|
|
347
|
+
raise ConfigError("emitters.pytest must be a mapping.")
|
|
348
|
+
pytest_config = replace(
|
|
349
|
+
pytest_config,
|
|
350
|
+
style=_coerce_optional_str(pytest_data.get("style"), "emitters.pytest.style"),
|
|
351
|
+
scope=_coerce_optional_str(pytest_data.get("scope"), "emitters.pytest.scope"),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
return EmittersConfig(pytest=pytest_config)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _normalize_json(value: Any) -> JsonConfig:
|
|
358
|
+
json_config = JsonConfig()
|
|
359
|
+
|
|
360
|
+
if value is None:
|
|
361
|
+
return json_config
|
|
362
|
+
if not isinstance(value, Mapping):
|
|
363
|
+
raise ConfigError("json configuration must be a mapping.")
|
|
364
|
+
|
|
365
|
+
indent_raw = value.get("indent", json_config.indent)
|
|
366
|
+
orjson_raw = value.get("orjson", json_config.orjson)
|
|
367
|
+
|
|
368
|
+
indent = _coerce_indent(indent_raw)
|
|
369
|
+
orjson = _coerce_bool(orjson_raw, "json.orjson")
|
|
370
|
+
|
|
371
|
+
return JsonConfig(indent=indent, orjson=orjson)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _coerce_indent(value: Any) -> int:
|
|
375
|
+
if value is None:
|
|
376
|
+
return JsonConfig().indent
|
|
377
|
+
try:
|
|
378
|
+
indent_val = int(value)
|
|
379
|
+
except (TypeError, ValueError) as exc:
|
|
380
|
+
raise ConfigError("json.indent must be an integer.") from exc
|
|
381
|
+
if indent_val < 0:
|
|
382
|
+
raise ConfigError("json.indent must be non-negative.")
|
|
383
|
+
return indent_val
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _coerce_bool(value: Any, field_name: str) -> bool:
|
|
387
|
+
if isinstance(value, bool):
|
|
388
|
+
return value
|
|
389
|
+
if isinstance(value, str):
|
|
390
|
+
lower = value.lower()
|
|
391
|
+
if lower in TRUTHY:
|
|
392
|
+
return True
|
|
393
|
+
if lower in FALSY:
|
|
394
|
+
return False
|
|
395
|
+
raise ConfigError(f"{field_name} must be a boolean string.")
|
|
396
|
+
if value is None:
|
|
397
|
+
attr = field_name.split(".")[-1]
|
|
398
|
+
return cast(bool, getattr(DEFAULT_CONFIG.json, attr))
|
|
399
|
+
raise ConfigError(f"{field_name} must be a boolean.")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _coerce_optional_str(value: Any, field_name: str) -> str:
|
|
403
|
+
if value is None:
|
|
404
|
+
default = DEFAULT_CONFIG.emitters.pytest
|
|
405
|
+
attr = field_name.split(".")[-1]
|
|
406
|
+
return cast(str, getattr(default, attr))
|
|
407
|
+
if not isinstance(value, str):
|
|
408
|
+
raise ConfigError(f"{field_name} must be a string.")
|
|
409
|
+
return value
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _ensure_mutable(mapping: Mapping[str, Any]) -> dict[str, Any]:
|
|
413
|
+
mutable: dict[str, Any] = {}
|
|
414
|
+
for key, value in mapping.items():
|
|
415
|
+
if isinstance(value, Mapping):
|
|
416
|
+
mutable[key] = _ensure_mutable(value)
|
|
417
|
+
elif isinstance(value, list):
|
|
418
|
+
items: list[Any] = []
|
|
419
|
+
for item in value:
|
|
420
|
+
if isinstance(item, Mapping):
|
|
421
|
+
items.append(_ensure_mutable(item))
|
|
422
|
+
else:
|
|
423
|
+
items.append(item)
|
|
424
|
+
mutable[key] = items
|
|
425
|
+
else:
|
|
426
|
+
mutable[key] = value
|
|
427
|
+
return mutable
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _deep_merge(target: MutableMapping[str, Any], source: Mapping[str, Any]) -> None:
|
|
431
|
+
for key, value in source.items():
|
|
432
|
+
if key in target and isinstance(target[key], MutableMapping) and isinstance(value, Mapping):
|
|
433
|
+
_deep_merge(cast(MutableMapping[str, Any], target[key]), value)
|
|
434
|
+
else:
|
|
435
|
+
if isinstance(value, Mapping):
|
|
436
|
+
target[key] = _ensure_mutable(value)
|
|
437
|
+
elif isinstance(value, list):
|
|
438
|
+
target[key] = list(value)
|
|
439
|
+
else:
|
|
440
|
+
target[key] = value
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Core error taxonomy and helpers for structured reporting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from enum import IntEnum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ErrorCode(IntEnum):
|
|
11
|
+
"""Stable error codes used across the CLI."""
|
|
12
|
+
|
|
13
|
+
DISCOVERY = 10
|
|
14
|
+
MAPPING = 20
|
|
15
|
+
EMIT = 30
|
|
16
|
+
UNSAFE_IMPORT = 40
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PFGError(Exception):
|
|
20
|
+
"""Base class for structured errors with stable codes."""
|
|
21
|
+
|
|
22
|
+
code: ErrorCode
|
|
23
|
+
kind: str
|
|
24
|
+
details: dict[str, Any]
|
|
25
|
+
hint: str | None
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
message: str,
|
|
30
|
+
*,
|
|
31
|
+
code: ErrorCode,
|
|
32
|
+
kind: str,
|
|
33
|
+
details: Mapping[str, Any] | None = None,
|
|
34
|
+
hint: str | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.code = code
|
|
38
|
+
self.kind = kind
|
|
39
|
+
self.details = dict(details or {})
|
|
40
|
+
self.hint = hint
|
|
41
|
+
|
|
42
|
+
def to_payload(self) -> dict[str, Any]:
|
|
43
|
+
"""Return a serialisable payload for JSON output."""
|
|
44
|
+
return {
|
|
45
|
+
"code": int(self.code),
|
|
46
|
+
"kind": self.kind,
|
|
47
|
+
"message": str(self),
|
|
48
|
+
"details": self.details,
|
|
49
|
+
"hint": self.hint,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DiscoveryError(PFGError):
|
|
54
|
+
"""Raised when discovery or configuration fails."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
message: str,
|
|
59
|
+
*,
|
|
60
|
+
details: Mapping[str, Any] | None = None,
|
|
61
|
+
hint: str | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
super().__init__(
|
|
64
|
+
message,
|
|
65
|
+
code=ErrorCode.DISCOVERY,
|
|
66
|
+
kind="DiscoveryError",
|
|
67
|
+
details=details,
|
|
68
|
+
hint=hint,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class MappingError(PFGError):
|
|
73
|
+
"""Raised when model mapping or generation cannot be satisfied."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
message: str,
|
|
78
|
+
*,
|
|
79
|
+
details: Mapping[str, Any] | None = None,
|
|
80
|
+
hint: str | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
super().__init__(
|
|
83
|
+
message,
|
|
84
|
+
code=ErrorCode.MAPPING,
|
|
85
|
+
kind="MappingError",
|
|
86
|
+
details=details,
|
|
87
|
+
hint=hint,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class EmitError(PFGError):
|
|
92
|
+
"""Raised when emitting artifacts fails."""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
message: str,
|
|
97
|
+
*,
|
|
98
|
+
details: Mapping[str, Any] | None = None,
|
|
99
|
+
hint: str | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
super().__init__(
|
|
102
|
+
message,
|
|
103
|
+
code=ErrorCode.EMIT,
|
|
104
|
+
kind="EmitError",
|
|
105
|
+
details=details,
|
|
106
|
+
hint=hint,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class UnsafeImportError(PFGError):
|
|
111
|
+
"""Raised when the safe importer detects a security violation."""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
message: str,
|
|
116
|
+
*,
|
|
117
|
+
details: Mapping[str, Any] | None = None,
|
|
118
|
+
hint: str | None = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
super().__init__(
|
|
121
|
+
message,
|
|
122
|
+
code=ErrorCode.UNSAFE_IMPORT,
|
|
123
|
+
kind="UnsafeImportViolation",
|
|
124
|
+
details=details,
|
|
125
|
+
hint=hint,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
__all__ = [
|
|
130
|
+
"EmitError",
|
|
131
|
+
"DiscoveryError",
|
|
132
|
+
"ErrorCode",
|
|
133
|
+
"MappingError",
|
|
134
|
+
"PFGError",
|
|
135
|
+
"UnsafeImportError",
|
|
136
|
+
]
|