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.
- pydantic_fixturegen/api/__init__.py +137 -0
- pydantic_fixturegen/api/_runtime.py +726 -0
- pydantic_fixturegen/api/models.py +73 -0
- pydantic_fixturegen/cli/__init__.py +32 -1
- pydantic_fixturegen/cli/check.py +230 -0
- pydantic_fixturegen/cli/diff.py +992 -0
- pydantic_fixturegen/cli/doctor.py +188 -35
- pydantic_fixturegen/cli/gen/_common.py +134 -7
- pydantic_fixturegen/cli/gen/explain.py +597 -40
- pydantic_fixturegen/cli/gen/fixtures.py +244 -112
- pydantic_fixturegen/cli/gen/json.py +229 -138
- pydantic_fixturegen/cli/gen/schema.py +170 -85
- pydantic_fixturegen/cli/init.py +333 -0
- pydantic_fixturegen/cli/schema.py +45 -0
- pydantic_fixturegen/cli/watch.py +126 -0
- pydantic_fixturegen/core/config.py +137 -3
- pydantic_fixturegen/core/config_schema.py +178 -0
- pydantic_fixturegen/core/constraint_report.py +305 -0
- pydantic_fixturegen/core/errors.py +42 -0
- pydantic_fixturegen/core/field_policies.py +100 -0
- pydantic_fixturegen/core/generate.py +241 -37
- pydantic_fixturegen/core/io_utils.py +10 -2
- pydantic_fixturegen/core/path_template.py +197 -0
- pydantic_fixturegen/core/presets.py +73 -0
- pydantic_fixturegen/core/providers/temporal.py +10 -0
- pydantic_fixturegen/core/safe_import.py +146 -12
- pydantic_fixturegen/core/seed_freeze.py +176 -0
- pydantic_fixturegen/emitters/json_out.py +65 -16
- pydantic_fixturegen/emitters/pytest_codegen.py +68 -13
- pydantic_fixturegen/emitters/schema_out.py +27 -3
- pydantic_fixturegen/logging.py +114 -0
- pydantic_fixturegen/schemas/config.schema.json +244 -0
- pydantic_fixturegen-1.1.0.dist-info/METADATA +173 -0
- pydantic_fixturegen-1.1.0.dist-info/RECORD +57 -0
- pydantic_fixturegen-1.0.0.dist-info/METADATA +0 -280
- pydantic_fixturegen-1.0.0.dist-info/RECORD +0 -41
- {pydantic_fixturegen-1.0.0.dist-info → pydantic_fixturegen-1.1.0.dist-info}/WHEEL +0 -0
- {pydantic_fixturegen-1.0.0.dist-info → pydantic_fixturegen-1.1.0.dist-info}/entry_points.txt +0 -0
- {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
|
+
]
|