pydantic-fixturegen 1.0.0__py3-none-any.whl → 1.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.

Potentially problematic release.


This version of pydantic-fixturegen might be problematic. Click here for more details.

Files changed (39) hide show
  1. pydantic_fixturegen/api/__init__.py +137 -0
  2. pydantic_fixturegen/api/_runtime.py +726 -0
  3. pydantic_fixturegen/api/models.py +73 -0
  4. pydantic_fixturegen/cli/__init__.py +32 -1
  5. pydantic_fixturegen/cli/check.py +230 -0
  6. pydantic_fixturegen/cli/diff.py +992 -0
  7. pydantic_fixturegen/cli/doctor.py +188 -35
  8. pydantic_fixturegen/cli/gen/_common.py +134 -7
  9. pydantic_fixturegen/cli/gen/explain.py +597 -40
  10. pydantic_fixturegen/cli/gen/fixtures.py +244 -112
  11. pydantic_fixturegen/cli/gen/json.py +229 -138
  12. pydantic_fixturegen/cli/gen/schema.py +170 -85
  13. pydantic_fixturegen/cli/init.py +333 -0
  14. pydantic_fixturegen/cli/schema.py +45 -0
  15. pydantic_fixturegen/cli/watch.py +126 -0
  16. pydantic_fixturegen/core/config.py +137 -3
  17. pydantic_fixturegen/core/config_schema.py +178 -0
  18. pydantic_fixturegen/core/constraint_report.py +305 -0
  19. pydantic_fixturegen/core/errors.py +42 -0
  20. pydantic_fixturegen/core/field_policies.py +100 -0
  21. pydantic_fixturegen/core/generate.py +241 -37
  22. pydantic_fixturegen/core/io_utils.py +10 -2
  23. pydantic_fixturegen/core/path_template.py +197 -0
  24. pydantic_fixturegen/core/presets.py +73 -0
  25. pydantic_fixturegen/core/providers/temporal.py +10 -0
  26. pydantic_fixturegen/core/safe_import.py +146 -12
  27. pydantic_fixturegen/core/seed_freeze.py +176 -0
  28. pydantic_fixturegen/emitters/json_out.py +65 -16
  29. pydantic_fixturegen/emitters/pytest_codegen.py +68 -13
  30. pydantic_fixturegen/emitters/schema_out.py +27 -3
  31. pydantic_fixturegen/logging.py +114 -0
  32. pydantic_fixturegen/schemas/config.schema.json +244 -0
  33. pydantic_fixturegen-1.1.0.dist-info/METADATA +173 -0
  34. pydantic_fixturegen-1.1.0.dist-info/RECORD +57 -0
  35. pydantic_fixturegen-1.0.0.dist-info/METADATA +0 -280
  36. pydantic_fixturegen-1.0.0.dist-info/RECORD +0 -41
  37. {pydantic_fixturegen-1.0.0.dist-info → pydantic_fixturegen-1.1.0.dist-info}/WHEEL +0 -0
  38. {pydantic_fixturegen-1.0.0.dist-info → pydantic_fixturegen-1.1.0.dist-info}/entry_points.txt +0 -0
  39. {pydantic_fixturegen-1.0.0.dist-info → pydantic_fixturegen-1.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,726 @@
1
+ """Shared runtime helpers for the Python API and CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as _dt
6
+ from collections.abc import Iterable, Mapping, Sequence
7
+ from pathlib import Path
8
+ from typing import Any, Literal, cast
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from pydantic_fixturegen.core.config import AppConfig, load_config
13
+ from pydantic_fixturegen.core.errors import DiscoveryError, EmitError, MappingError, PFGError
14
+ from pydantic_fixturegen.core.generate import GenerationConfig, InstanceGenerator
15
+ from pydantic_fixturegen.core.path_template import OutputTemplate, OutputTemplateContext
16
+ from pydantic_fixturegen.core.seed import SeedManager
17
+ from pydantic_fixturegen.core.seed_freeze import (
18
+ FreezeStatus,
19
+ SeedFreezeFile,
20
+ compute_model_digest,
21
+ derive_default_model_seed,
22
+ model_identifier,
23
+ resolve_freeze_path,
24
+ )
25
+ from pydantic_fixturegen.emitters.json_out import emit_json_samples
26
+ from pydantic_fixturegen.emitters.pytest_codegen import PytestEmitConfig, emit_pytest_fixtures
27
+ from pydantic_fixturegen.emitters.schema_out import emit_model_schema, emit_models_schema
28
+ from pydantic_fixturegen.logging import get_logger
29
+ from pydantic_fixturegen.plugins.hookspecs import EmitterContext
30
+ from pydantic_fixturegen.plugins.loader import emit_artifact, load_entrypoint_plugins
31
+
32
+ from ..logging import Logger
33
+ from .models import (
34
+ ConfigSnapshot,
35
+ FixturesGenerationResult,
36
+ JsonGenerationResult,
37
+ SchemaGenerationResult,
38
+ )
39
+
40
+
41
+ def _snapshot_config(app_config: AppConfig) -> ConfigSnapshot:
42
+ return ConfigSnapshot(
43
+ seed=app_config.seed,
44
+ include=tuple(app_config.include),
45
+ exclude=tuple(app_config.exclude),
46
+ time_anchor=app_config.now,
47
+ )
48
+
49
+
50
+ def _config_details(snapshot: ConfigSnapshot) -> dict[str, Any]:
51
+ return {
52
+ "seed": snapshot.seed,
53
+ "include": list(snapshot.include),
54
+ "exclude": list(snapshot.exclude),
55
+ "time_anchor": snapshot.time_anchor.isoformat() if snapshot.time_anchor else None,
56
+ }
57
+
58
+
59
+ def _split_patterns(raw: str | None) -> list[str]:
60
+ if not raw:
61
+ return []
62
+ return [part.strip() for part in raw.split(",") if part.strip()]
63
+
64
+
65
+ def _build_error_details(
66
+ *,
67
+ config_snapshot: ConfigSnapshot,
68
+ warnings: Sequence[str],
69
+ base_output: Path,
70
+ constraint_summary: Mapping[str, Any] | None,
71
+ ) -> dict[str, Any]:
72
+ details: dict[str, Any] = {
73
+ "config": _config_details(config_snapshot),
74
+ "warnings": list(warnings),
75
+ "base_output": str(base_output),
76
+ }
77
+ if constraint_summary:
78
+ details["constraint_summary"] = constraint_summary
79
+ return details
80
+
81
+
82
+ def _attach_error_details(exc: PFGError, details: Mapping[str, Any]) -> None:
83
+ for key, value in details.items():
84
+ exc.details.setdefault(key, value)
85
+
86
+
87
+ def _summarize_constraint_report(reporter: Any) -> Mapping[str, Any] | None:
88
+ if reporter is None:
89
+ return None
90
+ summary = reporter.summary()
91
+ if isinstance(summary, dict):
92
+ return summary
93
+ return None
94
+
95
+
96
+ def _resolve_patterns(patterns: Sequence[str] | None) -> Sequence[str] | None:
97
+ if patterns is None:
98
+ return None
99
+ resolved: list[str] = []
100
+ for pattern in patterns:
101
+ resolved.extend(_split_patterns(pattern))
102
+ return resolved
103
+
104
+
105
+ def _collect_warnings(messages: Iterable[str]) -> tuple[str, ...]:
106
+ return tuple(message.strip() for message in messages if message.strip())
107
+
108
+
109
+ def _build_instance_generator(
110
+ app_config: AppConfig,
111
+ *,
112
+ seed_override: int | None = None,
113
+ ) -> InstanceGenerator:
114
+ if seed_override is not None:
115
+ seed_value: int | None = seed_override
116
+ else:
117
+ seed_value = None
118
+ if app_config.seed is not None:
119
+ seed_value = SeedManager(seed=app_config.seed).normalized_seed
120
+
121
+ p_none = app_config.p_none if app_config.p_none is not None else 0.0
122
+ gen_config = GenerationConfig(
123
+ seed=seed_value,
124
+ enum_policy=app_config.enum_policy,
125
+ union_policy=app_config.union_policy,
126
+ default_p_none=p_none,
127
+ optional_p_none=p_none,
128
+ time_anchor=app_config.now,
129
+ field_policies=app_config.field_policies,
130
+ )
131
+ return InstanceGenerator(config=gen_config)
132
+
133
+
134
+ def generate_json_artifacts(
135
+ *,
136
+ target: str | Path,
137
+ output_template: OutputTemplate,
138
+ count: int,
139
+ jsonl: bool,
140
+ indent: int | None,
141
+ use_orjson: bool | None,
142
+ shard_size: int | None,
143
+ include: Sequence[str] | None,
144
+ exclude: Sequence[str] | None,
145
+ seed: int | None,
146
+ now: str | None,
147
+ freeze_seeds: bool,
148
+ freeze_seeds_file: Path | None,
149
+ preset: str | None,
150
+ logger: Logger | None = None,
151
+ ) -> JsonGenerationResult:
152
+ logger = logger or get_logger()
153
+ path = Path(target)
154
+ if not path.exists():
155
+ raise DiscoveryError(f"Target path '{target}' does not exist.", details={"path": target})
156
+ if not path.is_file():
157
+ raise DiscoveryError("Target must be a Python module file.", details={"path": target})
158
+
159
+ clear_include = _resolve_patterns(include)
160
+ clear_exclude = _resolve_patterns(exclude)
161
+
162
+ from ..cli.gen import _common as cli_common
163
+
164
+ cli_common.clear_module_cache()
165
+ load_entrypoint_plugins()
166
+
167
+ freeze_manager: SeedFreezeFile | None = None
168
+ if freeze_seeds:
169
+ freeze_path = resolve_freeze_path(freeze_seeds_file, root=Path.cwd())
170
+ freeze_manager = SeedFreezeFile.load(freeze_path)
171
+ for message in freeze_manager.messages:
172
+ logger.warn(
173
+ "Seed freeze file ignored",
174
+ event="seed_freeze_invalid",
175
+ path=str(freeze_manager.path),
176
+ reason=message,
177
+ )
178
+
179
+ cli_overrides: dict[str, Any] = {}
180
+ if preset is not None:
181
+ cli_overrides["preset"] = preset
182
+ if seed is not None:
183
+ cli_overrides["seed"] = seed
184
+ if now is not None:
185
+ cli_overrides["now"] = now
186
+ json_overrides: dict[str, Any] = {}
187
+ if indent is not None:
188
+ json_overrides["indent"] = indent
189
+ if use_orjson is not None:
190
+ json_overrides["orjson"] = use_orjson
191
+ if json_overrides:
192
+ cli_overrides["json"] = json_overrides
193
+ if clear_include:
194
+ cli_overrides["include"] = list(clear_include)
195
+ if clear_exclude:
196
+ cli_overrides["exclude"] = list(clear_exclude)
197
+
198
+ app_config = load_config(root=Path.cwd(), cli=cli_overrides if cli_overrides else None)
199
+ config_snapshot = _snapshot_config(app_config)
200
+
201
+ discovery = cli_common.discover_models(
202
+ path,
203
+ include=app_config.include,
204
+ exclude=app_config.exclude,
205
+ )
206
+
207
+ if discovery.errors:
208
+ raise DiscoveryError("; ".join(discovery.errors))
209
+
210
+ warnings = _collect_warnings(discovery.warnings)
211
+
212
+ if not discovery.models:
213
+ raise DiscoveryError("No models discovered.")
214
+
215
+ if len(discovery.models) > 1:
216
+ names = ", ".join(model.qualname for model in discovery.models)
217
+ raise DiscoveryError(
218
+ f"Multiple models discovered ({names}). Use include/exclude to narrow selection.",
219
+ details={"models": names},
220
+ )
221
+
222
+ target_model = discovery.models[0]
223
+
224
+ try:
225
+ model_cls = cli_common.load_model_class(target_model)
226
+ except RuntimeError as exc: # pragma: no cover - defensive
227
+ raise DiscoveryError(str(exc)) from exc
228
+
229
+ model_id = model_identifier(model_cls)
230
+ model_digest = compute_model_digest(model_cls)
231
+
232
+ if freeze_manager is not None:
233
+ default_seed = derive_default_model_seed(app_config.seed, model_id)
234
+ selected_seed: int | None = default_seed
235
+ stored_seed, status = freeze_manager.resolve_seed(model_id, model_digest=model_digest)
236
+ if status is FreezeStatus.VALID and stored_seed is not None:
237
+ selected_seed = stored_seed
238
+ else:
239
+ event = "seed_freeze_missing" if status is FreezeStatus.MISSING else "seed_freeze_stale"
240
+ logger.warn(
241
+ "Seed freeze entry unavailable; deriving new seed",
242
+ event=event,
243
+ model=model_id,
244
+ path=str(freeze_manager.path),
245
+ )
246
+ selected_seed = default_seed
247
+ else:
248
+ selected_seed = None
249
+
250
+ generator = _build_instance_generator(app_config, seed_override=selected_seed)
251
+
252
+ def sample_factory() -> BaseModel:
253
+ instance = generator.generate_one(model_cls)
254
+ if instance is None:
255
+ raise MappingError(
256
+ f"Failed to generate instance for {target_model.qualname}.",
257
+ details={"model": target_model.qualname},
258
+ )
259
+ return instance
260
+
261
+ indent_value = indent if indent is not None else app_config.json.indent
262
+ use_orjson_value = use_orjson if use_orjson is not None else app_config.json.orjson
263
+
264
+ timestamp = _dt.datetime.now(_dt.timezone.utc)
265
+ template_context = OutputTemplateContext(
266
+ model=model_cls.__name__,
267
+ timestamp=timestamp,
268
+ )
269
+ base_output = output_template.render(
270
+ context=template_context,
271
+ case_index=1 if output_template.uses_case_index() else None,
272
+ )
273
+
274
+ context = EmitterContext(
275
+ models=(model_cls,),
276
+ output=base_output,
277
+ parameters={
278
+ "count": count,
279
+ "jsonl": jsonl,
280
+ "indent": indent_value,
281
+ "shard_size": shard_size,
282
+ "use_orjson": use_orjson_value,
283
+ "path_template": output_template.raw,
284
+ },
285
+ )
286
+
287
+ if emit_artifact("json", context):
288
+ return JsonGenerationResult(
289
+ paths=(),
290
+ base_output=base_output,
291
+ model=model_cls,
292
+ config=config_snapshot,
293
+ constraint_summary=None,
294
+ warnings=warnings,
295
+ delegated=True,
296
+ )
297
+
298
+ reporter = getattr(generator, "constraint_report", None)
299
+ try:
300
+ paths = emit_json_samples(
301
+ sample_factory,
302
+ output_path=output_template.raw,
303
+ count=count,
304
+ jsonl=jsonl,
305
+ indent=indent_value,
306
+ shard_size=shard_size,
307
+ use_orjson=use_orjson_value,
308
+ ensure_ascii=False,
309
+ template=output_template,
310
+ template_context=template_context,
311
+ )
312
+ except RuntimeError as exc:
313
+ constraint_summary = _summarize_constraint_report(reporter)
314
+ details = _build_error_details(
315
+ config_snapshot=config_snapshot,
316
+ warnings=warnings,
317
+ base_output=base_output,
318
+ constraint_summary=constraint_summary,
319
+ )
320
+ raise EmitError(str(exc), details=details) from exc
321
+ except PFGError as exc:
322
+ constraint_summary = _summarize_constraint_report(reporter)
323
+ details = _build_error_details(
324
+ config_snapshot=config_snapshot,
325
+ warnings=warnings,
326
+ base_output=base_output,
327
+ constraint_summary=constraint_summary,
328
+ )
329
+ _attach_error_details(exc, details)
330
+ raise
331
+
332
+ if freeze_manager is not None:
333
+ assert selected_seed is not None
334
+ freeze_manager.record_seed(model_id, selected_seed, model_digest=model_digest)
335
+ freeze_manager.save()
336
+
337
+ constraint_summary = _summarize_constraint_report(reporter)
338
+
339
+ return JsonGenerationResult(
340
+ paths=tuple(paths),
341
+ base_output=base_output,
342
+ model=model_cls,
343
+ config=config_snapshot,
344
+ constraint_summary=constraint_summary,
345
+ warnings=warnings,
346
+ delegated=False,
347
+ )
348
+
349
+
350
+ def generate_fixtures_artifacts(
351
+ *,
352
+ target: str | Path,
353
+ output_template: OutputTemplate,
354
+ style: str | None,
355
+ scope: str | None,
356
+ cases: int,
357
+ return_type: str | None,
358
+ seed: int | None,
359
+ now: str | None,
360
+ p_none: float | None,
361
+ include: Sequence[str] | None,
362
+ exclude: Sequence[str] | None,
363
+ freeze_seeds: bool,
364
+ freeze_seeds_file: Path | None,
365
+ preset: str | None,
366
+ logger: Logger | None = None,
367
+ ) -> FixturesGenerationResult:
368
+ logger = logger or get_logger()
369
+ from ..cli.gen import _common as cli_common
370
+
371
+ path = Path(target)
372
+ if not path.exists():
373
+ raise DiscoveryError(f"Target path '{target}' does not exist.", details={"path": target})
374
+ if not path.is_file():
375
+ raise DiscoveryError("Target must be a Python module file.", details={"path": target})
376
+
377
+ clear_include = _resolve_patterns(include)
378
+ clear_exclude = _resolve_patterns(exclude)
379
+
380
+ cli_common.clear_module_cache()
381
+ load_entrypoint_plugins()
382
+
383
+ freeze_manager: SeedFreezeFile | None = None
384
+ if freeze_seeds:
385
+ freeze_path = resolve_freeze_path(freeze_seeds_file, root=Path.cwd())
386
+ freeze_manager = SeedFreezeFile.load(freeze_path)
387
+ for message in freeze_manager.messages:
388
+ logger.warn(
389
+ "Seed freeze file ignored",
390
+ event="seed_freeze_invalid",
391
+ path=str(freeze_path),
392
+ reason=message,
393
+ )
394
+
395
+ cli_overrides: dict[str, Any] = {}
396
+ if preset is not None:
397
+ cli_overrides["preset"] = preset
398
+ if seed is not None:
399
+ cli_overrides["seed"] = seed
400
+ if now is not None:
401
+ cli_overrides["now"] = now
402
+ if p_none is not None:
403
+ cli_overrides["p_none"] = p_none
404
+ emitter_overrides: dict[str, Any] = {}
405
+ if style is not None:
406
+ emitter_overrides["style"] = style
407
+ if scope is not None:
408
+ emitter_overrides["scope"] = scope
409
+ if emitter_overrides:
410
+ cli_overrides["emitters"] = {"pytest": emitter_overrides}
411
+ if clear_include:
412
+ cli_overrides["include"] = list(clear_include)
413
+ if clear_exclude:
414
+ cli_overrides["exclude"] = list(clear_exclude)
415
+
416
+ app_config = load_config(root=Path.cwd(), cli=cli_overrides if cli_overrides else None)
417
+ config_snapshot = _snapshot_config(app_config)
418
+
419
+ discovery = cli_common.discover_models(
420
+ path,
421
+ include=app_config.include,
422
+ exclude=app_config.exclude,
423
+ )
424
+
425
+ if discovery.errors:
426
+ raise DiscoveryError("; ".join(discovery.errors))
427
+
428
+ warnings = _collect_warnings(discovery.warnings)
429
+
430
+ if not discovery.models:
431
+ raise DiscoveryError("No models discovered.")
432
+
433
+ try:
434
+ model_classes = [cli_common.load_model_class(model) for model in discovery.models]
435
+ except RuntimeError as exc: # pragma: no cover - defensive
436
+ raise DiscoveryError(str(exc)) from exc
437
+
438
+ seed_value: int | None = None
439
+ if app_config.seed is not None:
440
+ seed_value = SeedManager(seed=app_config.seed).normalized_seed
441
+
442
+ style_value = style or app_config.emitters.pytest.style
443
+ scope_value = scope or app_config.emitters.pytest.scope
444
+ return_type_value = return_type or "model"
445
+
446
+ style_literal = cast(Literal["functions", "factory", "class"], style_value)
447
+ return_type_literal = cast(Literal["model", "dict"], return_type_value)
448
+
449
+ per_model_seeds: dict[str, int] = {}
450
+ model_digests: dict[str, str | None] = {}
451
+
452
+ for model_cls in model_classes:
453
+ model_id = model_identifier(model_cls)
454
+ digest = compute_model_digest(model_cls)
455
+ model_digests[model_id] = digest
456
+
457
+ if freeze_manager is not None:
458
+ default_seed = derive_default_model_seed(app_config.seed, model_id)
459
+ selected_seed = default_seed
460
+ stored_seed, status = freeze_manager.resolve_seed(model_id, model_digest=digest)
461
+ if status is FreezeStatus.VALID and stored_seed is not None:
462
+ selected_seed = stored_seed
463
+ else:
464
+ event = (
465
+ "seed_freeze_missing" if status is FreezeStatus.MISSING else "seed_freeze_stale"
466
+ )
467
+ logger.warn(
468
+ "Seed freeze entry unavailable; deriving new seed",
469
+ event=event,
470
+ model=model_id,
471
+ path=str(freeze_manager.path),
472
+ )
473
+ selected_seed = default_seed
474
+ else:
475
+ selected_seed = derive_default_model_seed(app_config.seed, model_id)
476
+
477
+ per_model_seeds[model_id] = selected_seed
478
+
479
+ header_seed = seed_value if freeze_manager is None else None
480
+
481
+ pytest_config = PytestEmitConfig(
482
+ scope=scope_value,
483
+ style=style_literal,
484
+ return_type=return_type_literal,
485
+ cases=cases,
486
+ seed=header_seed,
487
+ optional_p_none=app_config.p_none,
488
+ per_model_seeds=per_model_seeds if freeze_manager is not None else None,
489
+ time_anchor=app_config.now,
490
+ field_policies=app_config.field_policies,
491
+ )
492
+
493
+ timestamp = _dt.datetime.now(_dt.timezone.utc)
494
+ template_context = OutputTemplateContext(
495
+ model="combined" if len(model_classes) > 1 else model_classes[0].__name__,
496
+ timestamp=timestamp,
497
+ )
498
+ resolved_output = output_template.render(
499
+ context=template_context,
500
+ case_index=1 if output_template.uses_case_index() else None,
501
+ )
502
+
503
+ context = EmitterContext(
504
+ models=tuple(model_classes),
505
+ output=resolved_output,
506
+ parameters={
507
+ "style": style_value,
508
+ "scope": scope_value,
509
+ "cases": cases,
510
+ "return_type": return_type_value,
511
+ "path_template": output_template.raw,
512
+ },
513
+ )
514
+
515
+ if emit_artifact("fixtures", context):
516
+ return FixturesGenerationResult(
517
+ path=None,
518
+ base_output=resolved_output,
519
+ models=tuple(model_classes),
520
+ config=config_snapshot,
521
+ metadata=None,
522
+ warnings=warnings,
523
+ constraint_summary=None,
524
+ skipped=False,
525
+ delegated=True,
526
+ style=style_value,
527
+ scope=scope_value,
528
+ return_type=return_type_value,
529
+ cases=cases,
530
+ )
531
+
532
+ try:
533
+ result = emit_pytest_fixtures(
534
+ model_classes,
535
+ output_path=output_template.raw,
536
+ config=pytest_config,
537
+ template=output_template,
538
+ template_context=template_context,
539
+ )
540
+ except PFGError as exc:
541
+ details = _build_error_details(
542
+ config_snapshot=config_snapshot,
543
+ warnings=warnings,
544
+ base_output=resolved_output,
545
+ constraint_summary=None,
546
+ )
547
+ _attach_error_details(exc, details)
548
+ raise
549
+ except Exception as exc: # pragma: no cover - defensive
550
+ details = _build_error_details(
551
+ config_snapshot=config_snapshot,
552
+ warnings=warnings,
553
+ base_output=resolved_output,
554
+ constraint_summary=None,
555
+ )
556
+ raise EmitError(str(exc), details=details) from exc
557
+
558
+ constraint_summary = None
559
+ if result.metadata and "constraints" in result.metadata:
560
+ summary_value = result.metadata.get("constraints")
561
+ if isinstance(summary_value, dict):
562
+ constraint_summary = summary_value
563
+
564
+ if freeze_manager is not None:
565
+ for model_cls in model_classes:
566
+ model_id = model_identifier(model_cls)
567
+ freeze_manager.record_seed(
568
+ model_id,
569
+ per_model_seeds[model_id],
570
+ model_digest=model_digests[model_id],
571
+ )
572
+ freeze_manager.save()
573
+
574
+ return FixturesGenerationResult(
575
+ path=result.path,
576
+ base_output=resolved_output,
577
+ models=tuple(model_classes),
578
+ config=config_snapshot,
579
+ metadata=result.metadata or {},
580
+ warnings=warnings,
581
+ constraint_summary=constraint_summary,
582
+ skipped=result.skipped,
583
+ delegated=False,
584
+ style=style_literal,
585
+ scope=scope_value,
586
+ return_type=return_type_literal,
587
+ cases=cases,
588
+ )
589
+
590
+
591
+ def generate_schema_artifacts(
592
+ *,
593
+ target: str | Path,
594
+ output_template: OutputTemplate,
595
+ indent: int | None,
596
+ include: Sequence[str] | None,
597
+ exclude: Sequence[str] | None,
598
+ logger: Logger | None = None,
599
+ ) -> SchemaGenerationResult:
600
+ logger = logger or get_logger()
601
+ from ..cli.gen import _common as cli_common
602
+
603
+ path = Path(target)
604
+ if not path.exists():
605
+ raise DiscoveryError(f"Target path '{target}' does not exist.", details={"path": target})
606
+ if not path.is_file():
607
+ raise DiscoveryError("Target must be a Python module file.", details={"path": target})
608
+
609
+ clear_include = _resolve_patterns(include)
610
+ clear_exclude = _resolve_patterns(exclude)
611
+
612
+ cli_common.clear_module_cache()
613
+ load_entrypoint_plugins()
614
+
615
+ cli_overrides: dict[str, Any] = {}
616
+ if indent is not None:
617
+ cli_overrides.setdefault("json", {})["indent"] = indent
618
+ if clear_include:
619
+ cli_overrides["include"] = list(clear_include)
620
+ if clear_exclude:
621
+ cli_overrides["exclude"] = list(clear_exclude)
622
+
623
+ app_config = load_config(root=Path.cwd(), cli=cli_overrides if cli_overrides else None)
624
+ config_snapshot = _snapshot_config(app_config)
625
+
626
+ discovery = cli_common.discover_models(
627
+ path,
628
+ include=app_config.include,
629
+ exclude=app_config.exclude,
630
+ )
631
+
632
+ if discovery.errors:
633
+ raise DiscoveryError("; ".join(discovery.errors))
634
+
635
+ warnings = _collect_warnings(discovery.warnings)
636
+
637
+ if not discovery.models:
638
+ raise DiscoveryError("No models discovered.")
639
+
640
+ try:
641
+ model_classes = [cli_common.load_model_class(model) for model in discovery.models]
642
+ except RuntimeError as exc: # pragma: no cover - defensive
643
+ raise DiscoveryError(str(exc)) from exc
644
+
645
+ indent_value = indent if indent is not None else app_config.json.indent
646
+
647
+ if len(model_classes) > 1 and "model" in output_template.fields:
648
+ names = ", ".join(cls.__name__ for cls in model_classes)
649
+ raise EmitError(
650
+ "Template variable '{model}' requires a single model selection.",
651
+ details={
652
+ "config": _config_details(config_snapshot),
653
+ "warnings": list(warnings),
654
+ "models": names,
655
+ },
656
+ )
657
+
658
+ timestamp = _dt.datetime.now(_dt.timezone.utc)
659
+ template_context = OutputTemplateContext(
660
+ model="combined" if len(model_classes) > 1 else model_classes[0].__name__,
661
+ timestamp=timestamp,
662
+ )
663
+ resolved_output = output_template.render(
664
+ context=template_context,
665
+ case_index=1 if output_template.uses_case_index() else None,
666
+ )
667
+
668
+ context = EmitterContext(
669
+ models=tuple(model_classes),
670
+ output=resolved_output,
671
+ parameters={"indent": indent_value, "path_template": output_template.raw},
672
+ )
673
+
674
+ if emit_artifact("schema", context):
675
+ return SchemaGenerationResult(
676
+ path=None,
677
+ base_output=resolved_output,
678
+ models=tuple(model_classes),
679
+ config=config_snapshot,
680
+ warnings=warnings,
681
+ delegated=True,
682
+ )
683
+
684
+ try:
685
+ if len(model_classes) == 1:
686
+ emitted_path = emit_model_schema(
687
+ model_classes[0],
688
+ output_path=output_template.raw,
689
+ indent=indent_value,
690
+ ensure_ascii=False,
691
+ template=output_template,
692
+ template_context=template_context,
693
+ )
694
+ else:
695
+ emitted_path = emit_models_schema(
696
+ model_classes,
697
+ output_path=output_template.raw,
698
+ indent=indent_value,
699
+ ensure_ascii=False,
700
+ template=output_template,
701
+ template_context=template_context,
702
+ )
703
+ except Exception as exc: # pragma: no cover - defensive
704
+ details = _build_error_details(
705
+ config_snapshot=config_snapshot,
706
+ warnings=warnings,
707
+ base_output=resolved_output,
708
+ constraint_summary=None,
709
+ )
710
+ raise EmitError(str(exc), details=details) from exc
711
+
712
+ return SchemaGenerationResult(
713
+ path=emitted_path,
714
+ base_output=resolved_output,
715
+ models=tuple(model_classes),
716
+ config=config_snapshot,
717
+ warnings=warnings,
718
+ delegated=False,
719
+ )
720
+
721
+
722
+ __all__ = [
723
+ "generate_json_artifacts",
724
+ "generate_fixtures_artifacts",
725
+ "generate_schema_artifacts",
726
+ ]