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
@@ -3,27 +3,18 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
- from typing import Any
7
6
 
8
7
  import typer
9
- from pydantic import BaseModel
10
-
11
- from pydantic_fixturegen.core.config import AppConfig, ConfigError, load_config
12
- from pydantic_fixturegen.core.errors import DiscoveryError, EmitError, MappingError, PFGError
13
- from pydantic_fixturegen.core.generate import GenerationConfig, InstanceGenerator
14
- from pydantic_fixturegen.core.seed import SeedManager
15
- from pydantic_fixturegen.emitters.json_out import emit_json_samples
16
- from pydantic_fixturegen.plugins.hookspecs import EmitterContext
17
- from pydantic_fixturegen.plugins.loader import emit_artifact, load_entrypoint_plugins
18
-
19
- from ._common import (
20
- JSON_ERRORS_OPTION,
21
- clear_module_cache,
22
- discover_models,
23
- load_model_class,
24
- render_cli_error,
25
- split_patterns,
26
- )
8
+
9
+ from pydantic_fixturegen.api._runtime import generate_json_artifacts
10
+ from pydantic_fixturegen.api.models import JsonGenerationResult
11
+ from pydantic_fixturegen.core.config import ConfigError
12
+ from pydantic_fixturegen.core.errors import DiscoveryError, EmitError, PFGError
13
+ from pydantic_fixturegen.core.path_template import OutputTemplate
14
+
15
+ from ...logging import Logger, get_logger
16
+ from ..watch import gather_default_watch_paths, run_with_watch
17
+ from ._common import JSON_ERRORS_OPTION, NOW_OPTION, emit_constraint_summary, render_cli_error
27
18
 
28
19
  TARGET_ARGUMENT = typer.Argument(
29
20
  ...,
@@ -91,6 +82,37 @@ SEED_OPTION = typer.Option(
91
82
  help="Seed override for deterministic generation.",
92
83
  )
93
84
 
85
+ WATCH_OPTION = typer.Option(
86
+ False,
87
+ "--watch",
88
+ help="Watch source files and regenerate when changes are detected.",
89
+ )
90
+
91
+ WATCH_DEBOUNCE_OPTION = typer.Option(
92
+ 0.5,
93
+ "--watch-debounce",
94
+ min=0.1,
95
+ help="Debounce interval in seconds for filesystem events.",
96
+ )
97
+
98
+ FREEZE_SEEDS_OPTION = typer.Option(
99
+ False,
100
+ "--freeze-seeds/--no-freeze-seeds",
101
+ help="Read/write per-model seeds using a freeze file to ensure deterministic regeneration.",
102
+ )
103
+
104
+ FREEZE_FILE_OPTION = typer.Option(
105
+ None,
106
+ "--freeze-seeds-file",
107
+ help="Seed freeze file path (defaults to `.pfg-seeds.json` in the project root).",
108
+ )
109
+
110
+ PRESET_OPTION = typer.Option(
111
+ None,
112
+ "--preset",
113
+ help="Apply a curated generation preset (e.g. 'boundary', 'boundary-max').",
114
+ )
115
+
94
116
 
95
117
  def register(app: typer.Typer) -> None:
96
118
  @app.command("json")
@@ -105,33 +127,87 @@ def register(app: typer.Typer) -> None:
105
127
  include: str | None = INCLUDE_OPTION,
106
128
  exclude: str | None = EXCLUDE_OPTION,
107
129
  seed: int | None = SEED_OPTION,
130
+ now: str | None = NOW_OPTION,
108
131
  json_errors: bool = JSON_ERRORS_OPTION,
132
+ watch: bool = WATCH_OPTION,
133
+ watch_debounce: float = WATCH_DEBOUNCE_OPTION,
134
+ freeze_seeds: bool = FREEZE_SEEDS_OPTION,
135
+ freeze_seeds_file: Path | None = FREEZE_FILE_OPTION,
136
+ preset: str | None = PRESET_OPTION,
109
137
  ) -> None:
138
+ logger = get_logger()
139
+
110
140
  try:
111
- _execute_json_command(
112
- target=target,
113
- out=out,
114
- count=count,
115
- jsonl=jsonl,
116
- indent=indent,
117
- use_orjson=use_orjson,
118
- shard_size=shard_size,
119
- include=include,
120
- exclude=exclude,
121
- seed=seed,
122
- )
141
+ output_template = OutputTemplate(str(out))
123
142
  except PFGError as exc:
124
143
  render_cli_error(exc, json_errors=json_errors)
125
- except ConfigError as exc:
126
- render_cli_error(DiscoveryError(str(exc)), json_errors=json_errors)
127
- except Exception as exc: # pragma: no cover - defensive
128
- render_cli_error(EmitError(str(exc)), json_errors=json_errors)
144
+ return
145
+
146
+ watch_output: Path | None = None
147
+ watch_extra: list[Path] | None = None
148
+ if output_template.has_dynamic_directories():
149
+ watch_extra = [output_template.watch_parent()]
150
+ else:
151
+ watch_output = output_template.preview_path()
152
+
153
+ def invoke(exit_app: bool) -> None:
154
+ try:
155
+ _execute_json_command(
156
+ target=target,
157
+ output_template=output_template,
158
+ count=count,
159
+ jsonl=jsonl,
160
+ indent=indent,
161
+ use_orjson=use_orjson,
162
+ shard_size=shard_size,
163
+ include=include,
164
+ exclude=exclude,
165
+ seed=seed,
166
+ freeze_seeds=freeze_seeds,
167
+ freeze_seeds_file=freeze_seeds_file,
168
+ preset=preset,
169
+ now=now,
170
+ )
171
+ except PFGError as exc:
172
+ render_cli_error(exc, json_errors=json_errors, exit_app=exit_app)
173
+ except ConfigError as exc:
174
+ render_cli_error(
175
+ DiscoveryError(str(exc)),
176
+ json_errors=json_errors,
177
+ exit_app=exit_app,
178
+ )
179
+ except Exception as exc: # pragma: no cover - defensive
180
+ render_cli_error(
181
+ EmitError(str(exc)),
182
+ json_errors=json_errors,
183
+ exit_app=exit_app,
184
+ )
185
+
186
+ if watch:
187
+ watch_paths = gather_default_watch_paths(
188
+ Path(target),
189
+ output=watch_output,
190
+ extra=watch_extra,
191
+ )
192
+ try:
193
+ logger.debug(
194
+ "Entering watch loop",
195
+ event="watch_loop_enter",
196
+ target=str(target),
197
+ output=str(watch_output or output_template.preview_path()),
198
+ debounce=watch_debounce,
199
+ )
200
+ run_with_watch(lambda: invoke(exit_app=False), watch_paths, debounce=watch_debounce)
201
+ except PFGError as exc:
202
+ render_cli_error(exc, json_errors=json_errors)
203
+ else:
204
+ invoke(exit_app=True)
129
205
 
130
206
 
131
207
  def _execute_json_command(
132
208
  *,
133
209
  target: str,
134
- out: Path,
210
+ output_template: OutputTemplate,
135
211
  count: int,
136
212
  jsonl: bool,
137
213
  indent: int | None,
@@ -140,123 +216,138 @@ def _execute_json_command(
140
216
  include: str | None,
141
217
  exclude: str | None,
142
218
  seed: int | None,
219
+ now: str | None,
220
+ freeze_seeds: bool,
221
+ freeze_seeds_file: Path | None,
222
+ preset: str | None,
143
223
  ) -> None:
144
- path = Path(target)
145
- if not path.exists():
146
- raise DiscoveryError(f"Target path '{target}' does not exist.", details={"path": target})
147
- if not path.is_file():
148
- raise DiscoveryError("Target must be a Python module file.", details={"path": target})
149
-
150
- clear_module_cache()
151
- load_entrypoint_plugins()
152
-
153
- cli_overrides: dict[str, Any] = {}
154
- if seed is not None:
155
- cli_overrides["seed"] = seed
156
- json_overrides: dict[str, Any] = {}
157
- if indent is not None:
158
- json_overrides["indent"] = indent
159
- if use_orjson is not None:
160
- json_overrides["orjson"] = use_orjson
161
- if json_overrides:
162
- cli_overrides["json"] = json_overrides
163
- if include:
164
- cli_overrides["include"] = split_patterns(include)
165
- if exclude:
166
- cli_overrides["exclude"] = split_patterns(exclude)
167
-
168
- app_config = load_config(root=Path.cwd(), cli=cli_overrides if cli_overrides else None)
169
-
170
- discovery = discover_models(
171
- path,
172
- include=app_config.include,
173
- exclude=app_config.exclude,
174
- )
175
-
176
- if discovery.errors:
177
- raise DiscoveryError("; ".join(discovery.errors))
178
-
179
- for warning in discovery.warnings:
180
- if warning.strip():
181
- typer.secho(warning.strip(), err=True, fg=typer.colors.YELLOW)
182
-
183
- if not discovery.models:
184
- raise DiscoveryError("No models discovered.")
224
+ logger = get_logger()
185
225
 
186
- if len(discovery.models) > 1:
187
- names = ", ".join(model.qualname for model in discovery.models)
188
- raise DiscoveryError(
189
- f"Multiple models discovered ({names}). Use --include/--exclude to narrow selection.",
190
- details={"models": names},
191
- )
192
-
193
- target_model = discovery.models[0]
194
-
195
- try:
196
- model_cls = load_model_class(target_model)
197
- except RuntimeError as exc:
198
- raise DiscoveryError(str(exc)) from exc
199
-
200
- generator = _build_instance_generator(app_config)
201
-
202
- def sample_factory() -> BaseModel:
203
- instance = generator.generate_one(model_cls)
204
- if instance is None:
205
- raise MappingError(
206
- f"Failed to generate instance for {target_model.qualname}.",
207
- details={"model": target_model.qualname},
208
- )
209
- return instance
210
-
211
- indent_value = indent if indent is not None else app_config.json.indent
212
- use_orjson_value = use_orjson if use_orjson is not None else app_config.json.orjson
213
-
214
- context = EmitterContext(
215
- models=(model_cls,),
216
- output=out,
217
- parameters={
218
- "count": count,
219
- "jsonl": jsonl,
220
- "indent": indent_value,
221
- "shard_size": shard_size,
222
- "use_orjson": use_orjson_value,
223
- },
224
- )
225
- if emit_artifact("json", context):
226
- return
226
+ include_patterns = [include] if include else None
227
+ exclude_patterns = [exclude] if exclude else None
227
228
 
228
229
  try:
229
- paths = emit_json_samples(
230
- sample_factory,
231
- output_path=out,
230
+ result = generate_json_artifacts(
231
+ target=target,
232
+ output_template=output_template,
232
233
  count=count,
233
234
  jsonl=jsonl,
234
- indent=indent_value,
235
+ indent=indent,
236
+ use_orjson=use_orjson,
235
237
  shard_size=shard_size,
236
- use_orjson=use_orjson_value,
237
- ensure_ascii=False,
238
+ include=include_patterns,
239
+ exclude=exclude_patterns,
240
+ seed=seed,
241
+ now=now,
242
+ freeze_seeds=freeze_seeds,
243
+ freeze_seeds_file=freeze_seeds_file,
244
+ preset=preset,
245
+ logger=logger,
238
246
  )
239
- except RuntimeError as exc:
247
+ except PFGError as exc:
248
+ _handle_generation_error(logger, exc)
249
+ raise
250
+ except Exception as exc: # pragma: no cover - defensive
251
+ if isinstance(exc, ConfigError):
252
+ raise
240
253
  raise EmitError(str(exc)) from exc
241
254
 
242
- for emitted_path in paths:
255
+ if _log_generation_snapshot(logger, result, count):
256
+ return
257
+
258
+ emit_constraint_summary(
259
+ result.constraint_summary,
260
+ logger=logger,
261
+ json_mode=logger.config.json,
262
+ )
263
+
264
+ for emitted_path in result.paths:
243
265
  typer.echo(str(emitted_path))
244
266
 
245
267
 
246
- def _build_instance_generator(app_config: AppConfig) -> InstanceGenerator:
247
- seed_value: int | None = None
248
- if app_config.seed is not None:
249
- seed_value = SeedManager(seed=app_config.seed).normalized_seed
268
+ def _log_generation_snapshot(logger: Logger, result: JsonGenerationResult, count: int) -> bool:
269
+ config_snapshot = result.config
270
+ anchor_iso = config_snapshot.time_anchor.isoformat() if config_snapshot.time_anchor else None
271
+
272
+ logger.debug(
273
+ "Loaded configuration",
274
+ event="config_loaded",
275
+ seed=config_snapshot.seed,
276
+ include=list(config_snapshot.include),
277
+ exclude=list(config_snapshot.exclude),
278
+ time_anchor=anchor_iso,
279
+ )
280
+
281
+ if anchor_iso:
282
+ logger.info(
283
+ "Using temporal anchor",
284
+ event="temporal_anchor_set",
285
+ time_anchor=anchor_iso,
286
+ )
250
287
 
251
- p_none = app_config.p_none if app_config.p_none is not None else 0.0
252
- gen_config = GenerationConfig(
253
- seed=seed_value,
254
- enum_policy=app_config.enum_policy,
255
- union_policy=app_config.union_policy,
256
- default_p_none=p_none,
257
- optional_p_none=p_none,
288
+ for warning in result.warnings:
289
+ logger.warn(
290
+ warning,
291
+ event="discovery_warning",
292
+ warning=warning,
293
+ )
294
+
295
+ if result.delegated:
296
+ logger.info(
297
+ "JSON generation handled by plugin",
298
+ event="json_generation_delegated",
299
+ output=str(result.base_output),
300
+ time_anchor=anchor_iso,
301
+ )
302
+ return True
303
+
304
+ logger.info(
305
+ "JSON generation complete",
306
+ event="json_generation_complete",
307
+ files=[str(path) for path in result.paths],
308
+ count=count,
309
+ time_anchor=anchor_iso,
258
310
  )
259
- return InstanceGenerator(config=gen_config)
311
+ return False
312
+
313
+
314
+ def _handle_generation_error(logger: Logger, exc: PFGError) -> None:
315
+ details = getattr(exc, "details", {}) or {}
316
+ config_info = details.get("config")
317
+ anchor_iso = None
318
+ if isinstance(config_info, dict):
319
+ anchor_iso = config_info.get("time_anchor")
320
+ logger.debug(
321
+ "Loaded configuration",
322
+ event="config_loaded",
323
+ seed=config_info.get("seed"),
324
+ include=config_info.get("include", []),
325
+ exclude=config_info.get("exclude", []),
326
+ time_anchor=anchor_iso,
327
+ )
328
+ if anchor_iso:
329
+ logger.info(
330
+ "Using temporal anchor",
331
+ event="temporal_anchor_set",
332
+ time_anchor=anchor_iso,
333
+ )
334
+
335
+ warnings = details.get("warnings") or []
336
+ for warning in warnings:
337
+ if isinstance(warning, str):
338
+ logger.warn(
339
+ warning,
340
+ event="discovery_warning",
341
+ warning=warning,
342
+ )
343
+
344
+ constraint_summary = details.get("constraint_summary")
345
+ if constraint_summary:
346
+ emit_constraint_summary(
347
+ constraint_summary,
348
+ logger=logger,
349
+ json_mode=logger.config.json,
350
+ )
260
351
 
261
352
 
262
353
  __all__ = ["register"]