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.

Files changed (41) hide show
  1. pydantic_fixturegen/__init__.py +7 -0
  2. pydantic_fixturegen/cli/__init__.py +85 -0
  3. pydantic_fixturegen/cli/doctor.py +235 -0
  4. pydantic_fixturegen/cli/gen/__init__.py +23 -0
  5. pydantic_fixturegen/cli/gen/_common.py +139 -0
  6. pydantic_fixturegen/cli/gen/explain.py +145 -0
  7. pydantic_fixturegen/cli/gen/fixtures.py +283 -0
  8. pydantic_fixturegen/cli/gen/json.py +262 -0
  9. pydantic_fixturegen/cli/gen/schema.py +164 -0
  10. pydantic_fixturegen/cli/list.py +164 -0
  11. pydantic_fixturegen/core/__init__.py +103 -0
  12. pydantic_fixturegen/core/ast_discover.py +169 -0
  13. pydantic_fixturegen/core/config.py +440 -0
  14. pydantic_fixturegen/core/errors.py +136 -0
  15. pydantic_fixturegen/core/generate.py +311 -0
  16. pydantic_fixturegen/core/introspect.py +141 -0
  17. pydantic_fixturegen/core/io_utils.py +77 -0
  18. pydantic_fixturegen/core/providers/__init__.py +32 -0
  19. pydantic_fixturegen/core/providers/collections.py +74 -0
  20. pydantic_fixturegen/core/providers/identifiers.py +68 -0
  21. pydantic_fixturegen/core/providers/numbers.py +133 -0
  22. pydantic_fixturegen/core/providers/registry.py +98 -0
  23. pydantic_fixturegen/core/providers/strings.py +109 -0
  24. pydantic_fixturegen/core/providers/temporal.py +42 -0
  25. pydantic_fixturegen/core/safe_import.py +403 -0
  26. pydantic_fixturegen/core/schema.py +320 -0
  27. pydantic_fixturegen/core/seed.py +154 -0
  28. pydantic_fixturegen/core/strategies.py +193 -0
  29. pydantic_fixturegen/core/version.py +52 -0
  30. pydantic_fixturegen/emitters/__init__.py +15 -0
  31. pydantic_fixturegen/emitters/json_out.py +373 -0
  32. pydantic_fixturegen/emitters/pytest_codegen.py +365 -0
  33. pydantic_fixturegen/emitters/schema_out.py +84 -0
  34. pydantic_fixturegen/plugins/builtin.py +45 -0
  35. pydantic_fixturegen/plugins/hookspecs.py +59 -0
  36. pydantic_fixturegen/plugins/loader.py +72 -0
  37. pydantic_fixturegen-1.0.0.dist-info/METADATA +280 -0
  38. pydantic_fixturegen-1.0.0.dist-info/RECORD +41 -0
  39. pydantic_fixturegen-1.0.0.dist-info/WHEEL +4 -0
  40. pydantic_fixturegen-1.0.0.dist-info/entry_points.txt +5 -0
  41. 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
+ ]