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,992 @@
1
+ """CLI command for diffing regenerated artifacts against existing output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import difflib
7
+ import tempfile
8
+ from collections.abc import Iterable
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, cast
12
+
13
+ import typer
14
+ from pydantic import BaseModel
15
+
16
+ from pydantic_fixturegen.core.config import load_config
17
+ from pydantic_fixturegen.core.errors import (
18
+ DiffError,
19
+ DiscoveryError,
20
+ EmitError,
21
+ MappingError,
22
+ PFGError,
23
+ )
24
+ from pydantic_fixturegen.core.field_policies import FieldPolicy
25
+ from pydantic_fixturegen.core.generate import GenerationConfig, InstanceGenerator
26
+ from pydantic_fixturegen.core.seed import SeedManager
27
+ from pydantic_fixturegen.core.seed_freeze import (
28
+ FreezeStatus,
29
+ SeedFreezeFile,
30
+ compute_model_digest,
31
+ derive_default_model_seed,
32
+ model_identifier,
33
+ resolve_freeze_path,
34
+ )
35
+ from pydantic_fixturegen.emitters.json_out import emit_json_samples
36
+ from pydantic_fixturegen.emitters.pytest_codegen import (
37
+ PytestEmitConfig,
38
+ emit_pytest_fixtures,
39
+ )
40
+ from pydantic_fixturegen.emitters.schema_out import emit_model_schema, emit_models_schema
41
+ from pydantic_fixturegen.logging import get_logger
42
+ from pydantic_fixturegen.plugins.hookspecs import EmitterContext
43
+ from pydantic_fixturegen.plugins.loader import emit_artifact, load_entrypoint_plugins
44
+
45
+ from .gen._common import (
46
+ JSON_ERRORS_OPTION,
47
+ NOW_OPTION,
48
+ DiscoveryMethod,
49
+ clear_module_cache,
50
+ discover_models,
51
+ emit_constraint_summary,
52
+ load_model_class,
53
+ render_cli_error,
54
+ split_patterns,
55
+ )
56
+ from .gen.fixtures import (
57
+ DEFAULT_RETURN,
58
+ ReturnLiteral,
59
+ StyleLiteral,
60
+ _coerce_return_type,
61
+ _coerce_scope,
62
+ _coerce_style,
63
+ )
64
+
65
+ PATH_ARGUMENT = typer.Argument(
66
+ ...,
67
+ help="Python module file containing Pydantic models to diff against artifacts.",
68
+ )
69
+
70
+ INCLUDE_OPTION = typer.Option(
71
+ None,
72
+ "--include",
73
+ "-i",
74
+ help="Comma-separated glob pattern(s) of fully-qualified model names to include.",
75
+ )
76
+
77
+ EXCLUDE_OPTION = typer.Option(
78
+ None,
79
+ "--exclude",
80
+ "-e",
81
+ help="Comma-separated glob pattern(s) of fully-qualified model names to exclude.",
82
+ )
83
+
84
+ AST_OPTION = typer.Option(False, "--ast", help="Use AST discovery only (no imports executed).")
85
+
86
+ HYBRID_OPTION = typer.Option(False, "--hybrid", help="Combine AST and safe import discovery.")
87
+
88
+ TIMEOUT_OPTION = typer.Option(
89
+ 5.0,
90
+ "--timeout",
91
+ min=0.1,
92
+ help="Timeout in seconds for safe import execution.",
93
+ )
94
+
95
+ MEMORY_LIMIT_OPTION = typer.Option(
96
+ 256,
97
+ "--memory-limit-mb",
98
+ min=1,
99
+ help="Memory limit in megabytes for safe import subprocess.",
100
+ )
101
+
102
+ SEED_OPTION = typer.Option(
103
+ None,
104
+ "--seed",
105
+ help="Seed override for regenerated artifacts.",
106
+ )
107
+
108
+ PNONE_OPTION = typer.Option(
109
+ None,
110
+ "--p-none",
111
+ min=0.0,
112
+ max=1.0,
113
+ help="Override probability of None for optional fields.",
114
+ )
115
+
116
+ PRESET_OPTION = typer.Option(
117
+ None,
118
+ "--preset",
119
+ help="Apply a curated generation preset during diff regeneration.",
120
+ )
121
+
122
+ FREEZE_SEEDS_OPTION = typer.Option(
123
+ False,
124
+ "--freeze-seeds/--no-freeze-seeds",
125
+ help="Read/write per-model seeds using a freeze file for deterministic diffs.",
126
+ )
127
+
128
+ FREEZE_FILE_OPTION = typer.Option(
129
+ None,
130
+ "--freeze-seeds-file",
131
+ help="Seed freeze file path (defaults to `.pfg-seeds.json`).",
132
+ )
133
+
134
+ JSON_OUT_OPTION = typer.Option(
135
+ None,
136
+ "--json-out",
137
+ help="Existing JSON/JSONL artifact path to compare.",
138
+ )
139
+
140
+ JSON_COUNT_OPTION = typer.Option(
141
+ 1,
142
+ "--json-count",
143
+ min=1,
144
+ help="Number of JSON samples to regenerate for comparison.",
145
+ )
146
+
147
+ JSON_JSONL_OPTION = typer.Option(
148
+ False,
149
+ "--json-jsonl/--no-json-jsonl",
150
+ help="Treat JSON artifact as newline-delimited JSON.",
151
+ )
152
+
153
+ JSON_INDENT_OPTION = typer.Option(
154
+ None,
155
+ "--json-indent",
156
+ min=0,
157
+ help="Indentation override for JSON output.",
158
+ )
159
+
160
+ JSON_ORJSON_OPTION = typer.Option(
161
+ None,
162
+ "--json-orjson/--json-std",
163
+ help="Toggle orjson serialization for JSON diff generation.",
164
+ )
165
+
166
+ JSON_SHARD_OPTION = typer.Option(
167
+ None,
168
+ "--json-shard-size",
169
+ min=1,
170
+ help="Shard size used when the JSON artifact was generated.",
171
+ )
172
+
173
+ FIXTURES_OUT_OPTION = typer.Option(
174
+ None,
175
+ "--fixtures-out",
176
+ help="Existing pytest fixtures module path to compare.",
177
+ )
178
+
179
+ FIXTURES_STYLE_OPTION = typer.Option(
180
+ None,
181
+ "--fixtures-style",
182
+ help="Fixture style override (functions, factory, class).",
183
+ )
184
+
185
+ FIXTURES_SCOPE_OPTION = typer.Option(
186
+ None,
187
+ "--fixtures-scope",
188
+ help="Fixture scope override (function, module, session).",
189
+ )
190
+
191
+ FIXTURES_CASES_OPTION = typer.Option(
192
+ 1,
193
+ "--fixtures-cases",
194
+ min=1,
195
+ help="Number of parametrised cases per fixture.",
196
+ )
197
+
198
+ FIXTURES_RETURN_OPTION = typer.Option(
199
+ None,
200
+ "--fixtures-return-type",
201
+ help="Return type override for fixtures (model or dict).",
202
+ )
203
+
204
+ SCHEMA_OUT_OPTION = typer.Option(
205
+ None,
206
+ "--schema-out",
207
+ help="Existing JSON schema file path to compare.",
208
+ )
209
+
210
+ SCHEMA_INDENT_OPTION = typer.Option(
211
+ None,
212
+ "--schema-indent",
213
+ min=0,
214
+ help="Indentation override for schema JSON output.",
215
+ )
216
+
217
+ SHOW_DIFF_OPTION = typer.Option(
218
+ False,
219
+ "--show-diff/--no-show-diff",
220
+ help="Show unified diffs when differences are detected.",
221
+ )
222
+
223
+
224
+ app = typer.Typer(invoke_without_command=True, subcommand_metavar="")
225
+
226
+
227
+ @dataclass(slots=True)
228
+ class DiffReport:
229
+ kind: str
230
+ target: Path
231
+ checked_paths: list[Path]
232
+ messages: list[str]
233
+ diff_outputs: list[tuple[str, str]]
234
+ summary: str | None
235
+ constraint_report: dict[str, Any] | None = None
236
+ time_anchor: str | None = None
237
+
238
+ @property
239
+ def changed(self) -> bool:
240
+ return bool(self.messages)
241
+
242
+
243
+ def diff( # noqa: PLR0913 - CLI mirrors documented parameters
244
+ ctx: typer.Context,
245
+ path: str = PATH_ARGUMENT,
246
+ include: str | None = INCLUDE_OPTION,
247
+ exclude: str | None = EXCLUDE_OPTION,
248
+ ast_mode: bool = AST_OPTION,
249
+ hybrid_mode: bool = HYBRID_OPTION,
250
+ timeout: float = TIMEOUT_OPTION,
251
+ memory_limit_mb: int = MEMORY_LIMIT_OPTION,
252
+ seed: int | None = SEED_OPTION,
253
+ p_none: float | None = PNONE_OPTION,
254
+ now: str | None = NOW_OPTION,
255
+ json_out: Path | None = JSON_OUT_OPTION,
256
+ json_count: int = JSON_COUNT_OPTION,
257
+ json_jsonl: bool = JSON_JSONL_OPTION,
258
+ json_indent: int | None = JSON_INDENT_OPTION,
259
+ json_orjson: bool | None = JSON_ORJSON_OPTION,
260
+ json_shard_size: int | None = JSON_SHARD_OPTION,
261
+ fixtures_out: Path | None = FIXTURES_OUT_OPTION,
262
+ fixtures_style: str | None = FIXTURES_STYLE_OPTION,
263
+ fixtures_scope: str | None = FIXTURES_SCOPE_OPTION,
264
+ fixtures_cases: int = FIXTURES_CASES_OPTION,
265
+ fixtures_return_type: str | None = FIXTURES_RETURN_OPTION,
266
+ schema_out: Path | None = SCHEMA_OUT_OPTION,
267
+ schema_indent: int | None = SCHEMA_INDENT_OPTION,
268
+ show_diff: bool = SHOW_DIFF_OPTION,
269
+ json_errors: bool = JSON_ERRORS_OPTION,
270
+ preset: str | None = PRESET_OPTION,
271
+ freeze_seeds: bool = FREEZE_SEEDS_OPTION,
272
+ freeze_seeds_file: Path | None = FREEZE_FILE_OPTION,
273
+ ) -> None:
274
+ _ = ctx
275
+ logger = get_logger()
276
+ try:
277
+ reports = _execute_diff(
278
+ target=path,
279
+ include=include,
280
+ exclude=exclude,
281
+ ast_mode=ast_mode,
282
+ hybrid_mode=hybrid_mode,
283
+ timeout=timeout,
284
+ memory_limit_mb=memory_limit_mb,
285
+ seed_override=seed,
286
+ p_none_override=p_none,
287
+ json_options=JsonDiffOptions(
288
+ out=json_out,
289
+ count=json_count,
290
+ jsonl=json_jsonl,
291
+ indent=json_indent,
292
+ use_orjson=json_orjson,
293
+ shard_size=json_shard_size,
294
+ ),
295
+ fixtures_options=FixturesDiffOptions(
296
+ out=fixtures_out,
297
+ style=fixtures_style,
298
+ scope=fixtures_scope,
299
+ cases=fixtures_cases,
300
+ return_type=fixtures_return_type,
301
+ ),
302
+ schema_options=SchemaDiffOptions(
303
+ out=schema_out,
304
+ indent=schema_indent,
305
+ ),
306
+ preset=preset,
307
+ freeze_seeds=freeze_seeds,
308
+ freeze_seeds_file=freeze_seeds_file,
309
+ now_override=now,
310
+ )
311
+ except PFGError as exc:
312
+ render_cli_error(exc, json_errors=json_errors)
313
+ return
314
+
315
+ changed = any(report.changed for report in reports)
316
+
317
+ if json_errors and changed:
318
+ payload = {
319
+ "artifacts": [
320
+ {
321
+ "kind": report.kind,
322
+ "target": str(report.target),
323
+ "checked": [str(path) for path in report.checked_paths],
324
+ "messages": report.messages,
325
+ "diffs": [
326
+ {"path": path, "diff": diff_text} for path, diff_text in report.diff_outputs
327
+ ],
328
+ "constraints": report.constraint_report,
329
+ "time_anchor": report.time_anchor,
330
+ }
331
+ for report in reports
332
+ if report.kind and (report.changed or report.messages or report.checked_paths)
333
+ ]
334
+ }
335
+ render_cli_error(DiffError("Artifacts differ.", details=payload), json_errors=True)
336
+ return
337
+
338
+ _render_reports(reports, show_diff, logger, logger.config.json)
339
+
340
+ if changed:
341
+ raise typer.Exit(code=1)
342
+
343
+
344
+ app.callback(invoke_without_command=True)(diff)
345
+
346
+
347
+ @dataclass(slots=True)
348
+ class JsonDiffOptions:
349
+ out: Path | None
350
+ count: int
351
+ jsonl: bool
352
+ indent: int | None
353
+ use_orjson: bool | None
354
+ shard_size: int | None
355
+
356
+
357
+ @dataclass(slots=True)
358
+ class FixturesDiffOptions:
359
+ out: Path | None
360
+ style: str | None
361
+ scope: str | None
362
+ cases: int
363
+ return_type: str | None
364
+
365
+
366
+ @dataclass(slots=True)
367
+ class SchemaDiffOptions:
368
+ out: Path | None
369
+ indent: int | None
370
+
371
+
372
+ def _execute_diff(
373
+ *,
374
+ target: str,
375
+ include: str | None,
376
+ exclude: str | None,
377
+ ast_mode: bool,
378
+ hybrid_mode: bool,
379
+ timeout: float,
380
+ memory_limit_mb: int,
381
+ seed_override: int | None,
382
+ p_none_override: float | None,
383
+ json_options: JsonDiffOptions,
384
+ fixtures_options: FixturesDiffOptions,
385
+ schema_options: SchemaDiffOptions,
386
+ freeze_seeds: bool,
387
+ freeze_seeds_file: Path | None,
388
+ preset: str | None,
389
+ now_override: str | None,
390
+ ) -> list[DiffReport]:
391
+ if not any((json_options.out, fixtures_options.out, schema_options.out)):
392
+ raise DiscoveryError("Provide at least one artifact path to diff.")
393
+
394
+ target_path = Path(target)
395
+ if not target_path.exists():
396
+ raise DiscoveryError(f"Target path '{target}' does not exist.", details={"path": target})
397
+ if not target_path.is_file():
398
+ raise DiscoveryError("Target must be a Python module file.", details={"path": target})
399
+
400
+ clear_module_cache()
401
+ load_entrypoint_plugins()
402
+
403
+ logger = get_logger()
404
+
405
+ freeze_manager: SeedFreezeFile | None = None
406
+ if freeze_seeds:
407
+ freeze_path = resolve_freeze_path(freeze_seeds_file, root=Path.cwd())
408
+ freeze_manager = SeedFreezeFile.load(freeze_path)
409
+ for message in freeze_manager.messages:
410
+ logger.warn(
411
+ "Seed freeze file ignored",
412
+ event="seed_freeze_invalid",
413
+ path=str(freeze_path),
414
+ reason=message,
415
+ )
416
+
417
+ config_cli_overrides: dict[str, Any] = {}
418
+ if preset is not None:
419
+ config_cli_overrides["preset"] = preset
420
+ if now_override is not None:
421
+ config_cli_overrides["now"] = now_override
422
+
423
+ app_config = load_config(
424
+ root=Path.cwd(), cli=config_cli_overrides if config_cli_overrides else None
425
+ )
426
+
427
+ anchor_iso = app_config.now.isoformat() if app_config.now else None
428
+
429
+ if anchor_iso:
430
+ logger.info(
431
+ "Using temporal anchor",
432
+ event="temporal_anchor_set",
433
+ time_anchor=anchor_iso,
434
+ )
435
+
436
+ include_patterns = split_patterns(include) if include is not None else list(app_config.include)
437
+ exclude_patterns = split_patterns(exclude) if exclude is not None else list(app_config.exclude)
438
+
439
+ method = _resolve_method(ast_mode, hybrid_mode)
440
+ discovery = discover_models(
441
+ target_path,
442
+ include=include_patterns,
443
+ exclude=exclude_patterns,
444
+ method=method,
445
+ timeout=timeout,
446
+ memory_limit_mb=memory_limit_mb,
447
+ )
448
+
449
+ if discovery.errors:
450
+ raise DiscoveryError("; ".join(discovery.errors))
451
+
452
+ for warning in discovery.warnings:
453
+ if warning.strip():
454
+ typer.secho(f"warning: {warning.strip()}", err=True, fg=typer.colors.YELLOW)
455
+
456
+ if not discovery.models:
457
+ raise DiscoveryError("No models discovered.")
458
+
459
+ try:
460
+ model_classes = [load_model_class(model) for model in discovery.models]
461
+ except RuntimeError as exc:
462
+ raise DiscoveryError(str(exc)) from exc
463
+
464
+ seed_value = seed_override if seed_override is not None else app_config.seed
465
+ p_none_value = p_none_override if p_none_override is not None else app_config.p_none
466
+
467
+ per_model_seeds: dict[str, int] = {}
468
+ model_digests: dict[str, str | None] = {}
469
+
470
+ for model_cls in model_classes:
471
+ model_id = model_identifier(model_cls)
472
+ digest = compute_model_digest(model_cls)
473
+ model_digests[model_id] = digest
474
+
475
+ default_seed = derive_default_model_seed(seed_value, model_id)
476
+ selected_seed = default_seed
477
+
478
+ if freeze_manager is not None:
479
+ stored_seed, status = freeze_manager.resolve_seed(model_id, model_digest=digest)
480
+ if status is FreezeStatus.VALID and stored_seed is not None:
481
+ selected_seed = stored_seed
482
+ else:
483
+ event = (
484
+ "seed_freeze_missing" if status is FreezeStatus.MISSING else "seed_freeze_stale"
485
+ )
486
+ logger.warn(
487
+ "Seed freeze entry unavailable; deriving new seed",
488
+ event=event,
489
+ model=model_id,
490
+ path=str(freeze_manager.path),
491
+ )
492
+ selected_seed = default_seed
493
+
494
+ per_model_seeds[model_id] = selected_seed
495
+
496
+ reports: list[DiffReport] = []
497
+
498
+ if json_options.out is not None:
499
+ json_model_id = model_identifier(model_classes[0])
500
+ json_seed_value = (
501
+ per_model_seeds[json_model_id] if freeze_manager is not None else seed_value
502
+ )
503
+ reports.append(
504
+ _diff_json_artifact(
505
+ model_classes=model_classes,
506
+ seed_value=json_seed_value,
507
+ app_config_indent=app_config.json.indent,
508
+ app_config_orjson=app_config.json.orjson,
509
+ app_config_enum=app_config.enum_policy,
510
+ app_config_union=app_config.union_policy,
511
+ app_config_p_none=p_none_value,
512
+ app_config_now=app_config.now,
513
+ options=json_options,
514
+ )
515
+ )
516
+
517
+ if fixtures_options.out is not None:
518
+ reports.append(
519
+ _diff_fixtures_artifact(
520
+ model_classes=model_classes,
521
+ app_config_seed=seed_value,
522
+ app_config_p_none=p_none_value,
523
+ app_config_style=app_config.emitters.pytest.style,
524
+ app_config_scope=app_config.emitters.pytest.scope,
525
+ options=fixtures_options,
526
+ per_model_seeds=per_model_seeds if freeze_manager is not None else None,
527
+ app_config_now=app_config.now,
528
+ app_config_field_policies=app_config.field_policies,
529
+ )
530
+ )
531
+
532
+ if schema_options.out is not None:
533
+ reports.append(
534
+ _diff_schema_artifact(
535
+ model_classes=model_classes,
536
+ app_config_indent=app_config.json.indent,
537
+ options=schema_options,
538
+ )
539
+ )
540
+
541
+ if freeze_manager is not None:
542
+ for model_cls in model_classes:
543
+ model_id = model_identifier(model_cls)
544
+ freeze_manager.record_seed(
545
+ model_id,
546
+ per_model_seeds[model_id],
547
+ model_digest=model_digests[model_id],
548
+ )
549
+ freeze_manager.save()
550
+
551
+ return reports
552
+
553
+
554
+ def _diff_json_artifact(
555
+ *,
556
+ model_classes: list[type[BaseModel]],
557
+ seed_value: int | str | None,
558
+ app_config_indent: int | None,
559
+ app_config_orjson: bool,
560
+ app_config_enum: str,
561
+ app_config_union: str,
562
+ app_config_p_none: float | None,
563
+ app_config_now: datetime.datetime | None,
564
+ options: JsonDiffOptions,
565
+ ) -> DiffReport:
566
+ if not model_classes:
567
+ raise DiscoveryError("No models available for JSON diff.")
568
+ if len(model_classes) > 1:
569
+ names = ", ".join(model.__name__ for model in model_classes)
570
+ raise DiscoveryError(
571
+ "Multiple models discovered. Use --include/--exclude to narrow selection for JSON"
572
+ " diffs.",
573
+ details={"models": names},
574
+ )
575
+
576
+ if options.out is None:
577
+ raise DiscoveryError("JSON diff requires --json-out.")
578
+
579
+ target_model = model_classes[0]
580
+ output_path = Path(options.out)
581
+
582
+ with tempfile.TemporaryDirectory() as tmp_dir:
583
+ temp_base = Path(tmp_dir) / "json" / output_path.name
584
+ temp_base.parent.mkdir(parents=True, exist_ok=True)
585
+
586
+ generator = _build_instance_generator(
587
+ seed_value=seed_value,
588
+ union_policy=app_config_union,
589
+ enum_policy=app_config_enum,
590
+ p_none=app_config_p_none,
591
+ time_anchor=app_config_now,
592
+ )
593
+
594
+ def sample_factory() -> BaseModel:
595
+ instance = generator.generate_one(target_model)
596
+ if instance is None:
597
+ raise MappingError(
598
+ f"Failed to generate instance for {target_model.__name__}.",
599
+ details={"model": target_model.__name__},
600
+ )
601
+ return instance
602
+
603
+ indent_value = options.indent if options.indent is not None else app_config_indent
604
+ use_orjson_value = (
605
+ options.use_orjson if options.use_orjson is not None else app_config_orjson
606
+ )
607
+
608
+ try:
609
+ generated_paths = emit_json_samples(
610
+ sample_factory,
611
+ output_path=temp_base,
612
+ count=options.count,
613
+ jsonl=options.jsonl,
614
+ indent=indent_value,
615
+ shard_size=options.shard_size,
616
+ use_orjson=use_orjson_value,
617
+ ensure_ascii=False,
618
+ )
619
+ except RuntimeError as exc:
620
+ raise EmitError(str(exc)) from exc
621
+
622
+ constraint_summary = generator.constraint_report.summary()
623
+
624
+ generated_paths = sorted(generated_paths, key=lambda p: p.name)
625
+ actual_parent = output_path.parent if output_path.parent != Path("") else Path(".")
626
+
627
+ checked_paths: list[Path] = []
628
+ messages: list[str] = []
629
+ diff_outputs: list[tuple[str, str]] = []
630
+
631
+ for generated_path in generated_paths:
632
+ actual_path = actual_parent / generated_path.name
633
+ checked_paths.append(actual_path)
634
+ if not actual_path.exists():
635
+ messages.append(f"Missing JSON artifact: {actual_path}")
636
+ continue
637
+ if actual_path.is_dir():
638
+ messages.append(f"JSON artifact path is a directory: {actual_path}")
639
+ continue
640
+
641
+ actual_text = actual_path.read_text(encoding="utf-8")
642
+ generated_text = generated_path.read_text(encoding="utf-8")
643
+ if actual_text != generated_text:
644
+ messages.append(f"JSON artifact differs: {actual_path}")
645
+ diff_outputs.append(
646
+ (
647
+ str(actual_path),
648
+ _build_unified_diff(
649
+ actual_text,
650
+ generated_text,
651
+ str(actual_path),
652
+ f"{actual_path} (generated)",
653
+ ),
654
+ )
655
+ )
656
+
657
+ expected_names = {path.name for path in generated_paths}
658
+ suffix = ".jsonl" if options.jsonl else ".json"
659
+ stem = output_path.stem if output_path.suffix else output_path.name
660
+ pattern = f"{stem}*{suffix}"
661
+ extra_candidates = [p for p in actual_parent.glob(pattern) if p.name not in expected_names]
662
+
663
+ for extra in sorted(extra_candidates, key=lambda p: p.name):
664
+ if extra.is_file():
665
+ messages.append(f"Unexpected extra JSON artifact: {extra}")
666
+
667
+ summary = None
668
+ if not messages:
669
+ summary = f"JSON artifacts match ({len(checked_paths)} file(s))."
670
+
671
+ anchor_iso = app_config_now.isoformat() if app_config_now else None
672
+
673
+ return DiffReport(
674
+ kind="json",
675
+ target=output_path,
676
+ checked_paths=checked_paths,
677
+ messages=messages,
678
+ diff_outputs=diff_outputs,
679
+ summary=summary,
680
+ constraint_report=(constraint_summary if constraint_summary.get("models") else None),
681
+ time_anchor=anchor_iso,
682
+ )
683
+
684
+
685
+ def _diff_fixtures_artifact(
686
+ *,
687
+ model_classes: list[type[BaseModel]],
688
+ app_config_seed: int | str | None,
689
+ app_config_p_none: float | None,
690
+ app_config_style: str,
691
+ app_config_scope: str,
692
+ options: FixturesDiffOptions,
693
+ per_model_seeds: dict[str, int] | None,
694
+ app_config_now: datetime.datetime | None,
695
+ app_config_field_policies: tuple[FieldPolicy, ...],
696
+ ) -> DiffReport:
697
+ if options.out is None:
698
+ raise DiscoveryError("Fixtures diff requires --fixtures-out.")
699
+
700
+ output_path = Path(options.out)
701
+ style_value = _coerce_style(options.style)
702
+ scope_value = _coerce_scope(options.scope)
703
+ return_type_value = _coerce_return_type(options.return_type)
704
+
705
+ seed_normalized: int | None = None
706
+ if app_config_seed is not None:
707
+ seed_normalized = SeedManager(seed=app_config_seed).normalized_seed
708
+
709
+ style_default = cast(StyleLiteral, app_config_style)
710
+ style_final: StyleLiteral = style_value or style_default
711
+ scope_final = scope_value or app_config_scope
712
+ return_type_default: ReturnLiteral = DEFAULT_RETURN
713
+ return_type_final: ReturnLiteral = return_type_value or return_type_default
714
+
715
+ constraint_summary: dict[str, Any] | None = None
716
+
717
+ with tempfile.TemporaryDirectory() as tmp_dir:
718
+ temp_out = Path(tmp_dir) / "fixtures" / output_path.name
719
+ temp_out.parent.mkdir(parents=True, exist_ok=True)
720
+
721
+ header_seed = seed_normalized if per_model_seeds is None else None
722
+
723
+ pytest_config = PytestEmitConfig(
724
+ scope=scope_final,
725
+ style=style_final,
726
+ return_type=return_type_final,
727
+ cases=options.cases,
728
+ seed=header_seed,
729
+ optional_p_none=app_config_p_none,
730
+ per_model_seeds=per_model_seeds,
731
+ time_anchor=app_config_now,
732
+ field_policies=app_config_field_policies,
733
+ )
734
+
735
+ context = EmitterContext(
736
+ models=tuple(model_classes),
737
+ output=temp_out,
738
+ parameters={
739
+ "style": style_final,
740
+ "scope": scope_final,
741
+ "cases": options.cases,
742
+ "return_type": return_type_final,
743
+ },
744
+ )
745
+
746
+ generated_path: Path
747
+ if emit_artifact("fixtures", context):
748
+ generated_path = temp_out
749
+ else:
750
+ try:
751
+ result = emit_pytest_fixtures(
752
+ model_classes,
753
+ output_path=temp_out,
754
+ config=pytest_config,
755
+ )
756
+ except Exception as exc: # pragma: no cover - defensive
757
+ raise EmitError(str(exc)) from exc
758
+ generated_path = result.path
759
+ if result.metadata and "constraints" in result.metadata:
760
+ constraint_summary = result.metadata["constraints"]
761
+
762
+ if not generated_path.exists() or generated_path.is_dir():
763
+ raise EmitError("Fixture emitter did not produce a file to diff.")
764
+
765
+ generated_text = generated_path.read_text(encoding="utf-8")
766
+
767
+ actual_path = output_path
768
+ checked_paths = [actual_path]
769
+ messages: list[str] = []
770
+ diff_outputs: list[tuple[str, str]] = []
771
+
772
+ if not actual_path.exists():
773
+ messages.append(f"Missing fixtures module: {actual_path}")
774
+ elif actual_path.is_dir():
775
+ messages.append(f"Fixtures path is a directory: {actual_path}")
776
+ else:
777
+ actual_text = actual_path.read_text(encoding="utf-8")
778
+ if actual_text != generated_text:
779
+ messages.append(f"Fixtures module differs: {actual_path}")
780
+ diff_outputs.append(
781
+ (
782
+ str(actual_path),
783
+ _build_unified_diff(
784
+ actual_text,
785
+ generated_text,
786
+ str(actual_path),
787
+ f"{actual_path} (generated)",
788
+ ),
789
+ )
790
+ )
791
+
792
+ summary = None
793
+ if not messages:
794
+ summary = "Fixtures artifact matches."
795
+
796
+ anchor_iso = app_config_now.isoformat() if app_config_now else None
797
+
798
+ return DiffReport(
799
+ kind="fixtures",
800
+ target=output_path,
801
+ checked_paths=checked_paths,
802
+ messages=messages,
803
+ diff_outputs=diff_outputs,
804
+ summary=summary,
805
+ constraint_report=(
806
+ constraint_summary if constraint_summary and constraint_summary.get("models") else None
807
+ ),
808
+ time_anchor=anchor_iso,
809
+ )
810
+
811
+
812
+ def _diff_schema_artifact(
813
+ *,
814
+ model_classes: list[type[BaseModel]],
815
+ app_config_indent: int | None,
816
+ options: SchemaDiffOptions,
817
+ ) -> DiffReport:
818
+ if options.out is None:
819
+ raise DiscoveryError("Schema diff requires --schema-out.")
820
+
821
+ output_path = Path(options.out)
822
+
823
+ indent_value = options.indent if options.indent is not None else app_config_indent
824
+
825
+ with tempfile.TemporaryDirectory() as tmp_dir:
826
+ temp_out = Path(tmp_dir) / "schema" / output_path.name
827
+ temp_out.parent.mkdir(parents=True, exist_ok=True)
828
+
829
+ context = EmitterContext(
830
+ models=tuple(model_classes),
831
+ output=temp_out,
832
+ parameters={"indent": indent_value},
833
+ )
834
+
835
+ if emit_artifact("schema", context):
836
+ generated_path = temp_out
837
+ else:
838
+ try:
839
+ if len(model_classes) == 1:
840
+ generated_path = emit_model_schema(
841
+ model_classes[0],
842
+ output_path=temp_out,
843
+ indent=indent_value,
844
+ ensure_ascii=False,
845
+ )
846
+ else:
847
+ generated_path = emit_models_schema(
848
+ model_classes,
849
+ output_path=temp_out,
850
+ indent=indent_value,
851
+ ensure_ascii=False,
852
+ )
853
+ except Exception as exc: # pragma: no cover - defensive
854
+ raise EmitError(str(exc)) from exc
855
+
856
+ if not generated_path.exists() or generated_path.is_dir():
857
+ raise EmitError("Schema emitter did not produce a file to diff.")
858
+
859
+ generated_text = generated_path.read_text(encoding="utf-8")
860
+
861
+ actual_path = output_path
862
+ checked_paths = [actual_path]
863
+ messages: list[str] = []
864
+ diff_outputs: list[tuple[str, str]] = []
865
+
866
+ if not actual_path.exists():
867
+ messages.append(f"Missing schema artifact: {actual_path}")
868
+ elif actual_path.is_dir():
869
+ messages.append(f"Schema path is a directory: {actual_path}")
870
+ else:
871
+ actual_text = actual_path.read_text(encoding="utf-8")
872
+ if actual_text != generated_text:
873
+ messages.append(f"Schema artifact differs: {actual_path}")
874
+ diff_outputs.append(
875
+ (
876
+ str(actual_path),
877
+ _build_unified_diff(
878
+ actual_text,
879
+ generated_text,
880
+ str(actual_path),
881
+ f"{actual_path} (generated)",
882
+ ),
883
+ )
884
+ )
885
+
886
+ summary = None
887
+ if not messages:
888
+ summary = "Schema artifact matches."
889
+
890
+ return DiffReport(
891
+ kind="schema",
892
+ target=output_path,
893
+ checked_paths=checked_paths,
894
+ messages=messages,
895
+ diff_outputs=diff_outputs,
896
+ summary=summary,
897
+ )
898
+
899
+
900
+ def _build_instance_generator(
901
+ *,
902
+ seed_value: int | str | None,
903
+ union_policy: str,
904
+ enum_policy: str,
905
+ p_none: float | None,
906
+ time_anchor: datetime.datetime | None,
907
+ ) -> InstanceGenerator:
908
+ normalized_seed: int | None = None
909
+ if seed_value is not None:
910
+ normalized_seed = SeedManager(seed=seed_value).normalized_seed
911
+
912
+ p_none_value = p_none if p_none is not None else 0.0
913
+
914
+ gen_config = GenerationConfig(
915
+ seed=normalized_seed,
916
+ enum_policy=enum_policy,
917
+ union_policy=union_policy,
918
+ default_p_none=p_none_value,
919
+ optional_p_none=p_none_value,
920
+ time_anchor=time_anchor,
921
+ )
922
+ return InstanceGenerator(config=gen_config)
923
+
924
+
925
+ def _resolve_method(ast_mode: bool, hybrid_mode: bool) -> DiscoveryMethod:
926
+ if ast_mode and hybrid_mode:
927
+ raise DiscoveryError("Choose only one of --ast or --hybrid.")
928
+ if hybrid_mode:
929
+ return "hybrid"
930
+ if ast_mode:
931
+ return "ast"
932
+ return "import"
933
+
934
+
935
+ def _render_reports(
936
+ reports: Iterable[DiffReport],
937
+ show_diff: bool,
938
+ logger: Any,
939
+ json_mode: bool,
940
+ ) -> None:
941
+ reports = list(reports)
942
+ if not reports:
943
+ typer.secho("No artifacts were compared.", fg=typer.colors.YELLOW)
944
+ return
945
+
946
+ any_changes = False
947
+ for report in reports:
948
+ if report.changed:
949
+ any_changes = True
950
+ typer.secho(f"{report.kind.upper()} differences detected:", fg=typer.colors.YELLOW)
951
+ for message in report.messages:
952
+ typer.echo(f" - {message}")
953
+ if show_diff:
954
+ for _path, diff_text in report.diff_outputs:
955
+ if diff_text:
956
+ typer.echo(diff_text.rstrip())
957
+ typer.echo()
958
+ else:
959
+ if report.summary:
960
+ typer.echo(report.summary)
961
+
962
+ if report.time_anchor:
963
+ typer.echo(f" Temporal anchor: {report.time_anchor}")
964
+
965
+ if report.constraint_report:
966
+ emit_constraint_summary(
967
+ report.constraint_report,
968
+ logger=logger,
969
+ json_mode=json_mode,
970
+ heading=f"{report.kind.upper()} constraint report",
971
+ )
972
+
973
+ if not any_changes:
974
+ typer.secho("All compared artifacts match.", fg=typer.colors.GREEN)
975
+
976
+
977
+ def _build_unified_diff(
978
+ original: str,
979
+ regenerated: str,
980
+ original_label: str,
981
+ regenerated_label: str,
982
+ ) -> str:
983
+ diff = difflib.unified_diff(
984
+ original.splitlines(keepends=True),
985
+ regenerated.splitlines(keepends=True),
986
+ fromfile=original_label,
987
+ tofile=regenerated_label,
988
+ )
989
+ return "".join(diff)
990
+
991
+
992
+ __all__ = ["app"]