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,25 +3,19 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
- from typing import Any, Literal, cast
6
+ from typing import Literal, cast
7
7
 
8
8
  import typer
9
9
 
10
- from pydantic_fixturegen.core.config import ConfigError, load_config
10
+ from pydantic_fixturegen.api._runtime import generate_fixtures_artifacts
11
+ from pydantic_fixturegen.api.models import FixturesGenerationResult
12
+ from pydantic_fixturegen.core.config import ConfigError
11
13
  from pydantic_fixturegen.core.errors import DiscoveryError, EmitError, PFGError
12
- from pydantic_fixturegen.core.seed import SeedManager
13
- from pydantic_fixturegen.emitters.pytest_codegen import PytestEmitConfig, emit_pytest_fixtures
14
- from pydantic_fixturegen.plugins.hookspecs import EmitterContext
15
- from pydantic_fixturegen.plugins.loader import emit_artifact, load_entrypoint_plugins
16
-
17
- from ._common import (
18
- JSON_ERRORS_OPTION,
19
- clear_module_cache,
20
- discover_models,
21
- load_model_class,
22
- render_cli_error,
23
- split_patterns,
24
- )
14
+ from pydantic_fixturegen.core.path_template import OutputTemplate
15
+
16
+ from ...logging import Logger, get_logger
17
+ from ..watch import gather_default_watch_paths, run_with_watch
18
+ from ._common import JSON_ERRORS_OPTION, NOW_OPTION, emit_constraint_summary, render_cli_error
25
19
 
26
20
  STYLE_CHOICES = {"functions", "factory", "class"}
27
21
  SCOPE_CHOICES = {"function", "module", "session"}
@@ -96,6 +90,37 @@ EXCLUDE_OPTION = typer.Option(
96
90
  help="Comma-separated pattern(s) of fully-qualified model names to exclude.",
97
91
  )
98
92
 
93
+ WATCH_OPTION = typer.Option(
94
+ False,
95
+ "--watch",
96
+ help="Watch source files and regenerate when changes are detected.",
97
+ )
98
+
99
+ WATCH_DEBOUNCE_OPTION = typer.Option(
100
+ 0.5,
101
+ "--watch-debounce",
102
+ min=0.1,
103
+ help="Debounce interval in seconds for filesystem events.",
104
+ )
105
+
106
+ FREEZE_SEEDS_OPTION = typer.Option(
107
+ False,
108
+ "--freeze-seeds/--no-freeze-seeds",
109
+ help="Read/write per-model seeds using a freeze file to stabilize fixture output.",
110
+ )
111
+
112
+ FREEZE_FILE_OPTION = typer.Option(
113
+ None,
114
+ "--freeze-seeds-file",
115
+ help="Seed freeze file path (defaults to `.pfg-seeds.json` in the project root).",
116
+ )
117
+
118
+ PRESET_OPTION = typer.Option(
119
+ None,
120
+ "--preset",
121
+ help="Apply a curated generation preset (e.g. 'boundary', 'boundary-max').",
122
+ )
123
+
99
124
 
100
125
  def register(app: typer.Typer) -> None:
101
126
  @app.command("fixtures")
@@ -107,146 +132,253 @@ def register(app: typer.Typer) -> None:
107
132
  cases: int = CASES_OPTION,
108
133
  return_type: str | None = RETURN_OPTION,
109
134
  seed: int | None = SEED_OPTION,
135
+ now: str | None = NOW_OPTION,
110
136
  p_none: float | None = P_NONE_OPTION,
111
137
  include: str | None = INCLUDE_OPTION,
112
138
  exclude: str | None = EXCLUDE_OPTION,
113
139
  json_errors: bool = JSON_ERRORS_OPTION,
140
+ watch: bool = WATCH_OPTION,
141
+ watch_debounce: float = WATCH_DEBOUNCE_OPTION,
142
+ freeze_seeds: bool = FREEZE_SEEDS_OPTION,
143
+ freeze_seeds_file: Path | None = FREEZE_FILE_OPTION,
144
+ preset: str | None = PRESET_OPTION,
114
145
  ) -> None:
146
+ logger = get_logger()
147
+
115
148
  try:
116
- _execute_fixtures_command(
117
- target=target,
118
- out=out,
119
- style=style,
120
- scope=scope,
121
- cases=cases,
122
- return_type=return_type,
123
- seed=seed,
124
- p_none=p_none,
125
- include=include,
126
- exclude=exclude,
127
- )
149
+ output_template = OutputTemplate(str(out))
128
150
  except PFGError as exc:
129
151
  render_cli_error(exc, json_errors=json_errors)
130
- except ConfigError as exc:
131
- render_cli_error(DiscoveryError(str(exc)), json_errors=json_errors)
132
- except Exception as exc: # pragma: no cover - defensive
133
- render_cli_error(EmitError(str(exc)), json_errors=json_errors)
152
+ return
153
+
154
+ watch_output: Path | None = None
155
+ watch_extra: list[Path] | None = None
156
+ if output_template.has_dynamic_directories():
157
+ watch_extra = [output_template.watch_parent()]
158
+ else:
159
+ watch_output = output_template.preview_path()
160
+
161
+ def invoke(exit_app: bool) -> None:
162
+ try:
163
+ _execute_fixtures_command(
164
+ target=target,
165
+ output_template=output_template,
166
+ style=style,
167
+ scope=scope,
168
+ cases=cases,
169
+ return_type=return_type,
170
+ seed=seed,
171
+ now=now,
172
+ p_none=p_none,
173
+ include=include,
174
+ exclude=exclude,
175
+ freeze_seeds=freeze_seeds,
176
+ freeze_seeds_file=freeze_seeds_file,
177
+ preset=preset,
178
+ )
179
+ except PFGError as exc:
180
+ render_cli_error(exc, json_errors=json_errors, exit_app=exit_app)
181
+ except ConfigError as exc:
182
+ render_cli_error(
183
+ DiscoveryError(str(exc)),
184
+ json_errors=json_errors,
185
+ exit_app=exit_app,
186
+ )
187
+ except Exception as exc: # pragma: no cover - defensive
188
+ render_cli_error(
189
+ EmitError(str(exc)),
190
+ json_errors=json_errors,
191
+ exit_app=exit_app,
192
+ )
193
+
194
+ if watch:
195
+ watch_paths = gather_default_watch_paths(
196
+ Path(target),
197
+ output=watch_output,
198
+ extra=watch_extra,
199
+ )
200
+ try:
201
+ logger.debug(
202
+ "Entering watch loop",
203
+ event="watch_loop_enter",
204
+ target=str(target),
205
+ output=str(watch_output or output_template.preview_path()),
206
+ debounce=watch_debounce,
207
+ )
208
+ run_with_watch(lambda: invoke(exit_app=False), watch_paths, debounce=watch_debounce)
209
+ except PFGError as exc:
210
+ render_cli_error(exc, json_errors=json_errors)
211
+ else:
212
+ invoke(exit_app=True)
134
213
 
135
214
 
136
215
  def _execute_fixtures_command(
137
216
  *,
138
217
  target: str,
139
- out: Path,
218
+ output_template: OutputTemplate,
140
219
  style: str | None,
141
220
  scope: str | None,
142
221
  cases: int,
143
222
  return_type: str | None,
144
223
  seed: int | None,
224
+ now: str | None,
145
225
  p_none: float | None,
146
226
  include: str | None,
147
227
  exclude: str | None,
228
+ freeze_seeds: bool,
229
+ freeze_seeds_file: Path | None,
230
+ preset: str | None,
148
231
  ) -> None:
149
- path = Path(target)
150
- if not path.exists():
151
- raise DiscoveryError(f"Target path '{target}' does not exist.", details={"path": target})
152
- if not path.is_file():
153
- raise DiscoveryError("Target must be a Python module file.", details={"path": target})
232
+ logger = get_logger()
154
233
 
155
234
  style_value = _coerce_style(style)
156
235
  scope_value = _coerce_scope(scope)
157
236
  return_type_value = _coerce_return_type(return_type)
158
237
 
159
- clear_module_cache()
160
- load_entrypoint_plugins()
161
-
162
- cli_overrides: dict[str, Any] = {}
163
- if seed is not None:
164
- cli_overrides["seed"] = seed
165
- if p_none is not None:
166
- cli_overrides["p_none"] = p_none
167
- emitter_overrides: dict[str, Any] = {}
168
- if style_value is not None:
169
- emitter_overrides["style"] = style_value
170
- if scope_value is not None:
171
- emitter_overrides["scope"] = scope_value
172
- if emitter_overrides:
173
- cli_overrides["emitters"] = {"pytest": emitter_overrides}
174
- if include:
175
- cli_overrides["include"] = split_patterns(include)
176
- if exclude:
177
- cli_overrides["exclude"] = split_patterns(exclude)
178
-
179
- app_config = load_config(root=Path.cwd(), cli=cli_overrides if cli_overrides else None)
180
-
181
- discovery = discover_models(
182
- path,
183
- include=app_config.include,
184
- exclude=app_config.exclude,
238
+ include_patterns = [include] if include else None
239
+ exclude_patterns = [exclude] if exclude else None
240
+
241
+ try:
242
+ result = generate_fixtures_artifacts(
243
+ target=target,
244
+ output_template=output_template,
245
+ style=style_value,
246
+ scope=scope_value,
247
+ cases=cases,
248
+ return_type=return_type_value,
249
+ seed=seed,
250
+ now=now,
251
+ p_none=p_none,
252
+ include=include_patterns,
253
+ exclude=exclude_patterns,
254
+ freeze_seeds=freeze_seeds,
255
+ freeze_seeds_file=freeze_seeds_file,
256
+ preset=preset,
257
+ logger=logger,
258
+ )
259
+ except PFGError as exc:
260
+ _handle_fixtures_error(logger, exc)
261
+ raise
262
+ except Exception as exc: # pragma: no cover - defensive
263
+ if isinstance(exc, ConfigError):
264
+ raise
265
+ raise EmitError(str(exc)) from exc
266
+
267
+ if _log_fixtures_snapshot(logger, result):
268
+ return
269
+
270
+ emit_constraint_summary(
271
+ result.constraint_summary,
272
+ logger=logger,
273
+ json_mode=logger.config.json,
185
274
  )
186
275
 
187
- if discovery.errors:
188
- raise DiscoveryError("; ".join(discovery.errors))
276
+ output_path = result.path or result.base_output
277
+ message = str(output_path)
278
+ if result.skipped:
279
+ message += " (unchanged)"
280
+ typer.echo(message)
189
281
 
190
- for warning in discovery.warnings:
191
- if warning.strip():
192
- typer.secho(warning.strip(), err=True, fg=typer.colors.YELLOW)
193
282
 
194
- if not discovery.models:
195
- raise DiscoveryError("No models discovered.")
283
+ def _log_fixtures_snapshot(logger: Logger, result: FixturesGenerationResult) -> bool:
284
+ config_snapshot = result.config
285
+ anchor_iso = config_snapshot.time_anchor.isoformat() if config_snapshot.time_anchor else None
196
286
 
197
- try:
198
- model_classes = [load_model_class(model) for model in discovery.models]
199
- except RuntimeError as exc:
200
- raise DiscoveryError(str(exc)) from exc
201
-
202
- seed_value: int | None = None
203
- if app_config.seed is not None:
204
- seed_value = SeedManager(seed=app_config.seed).normalized_seed
205
-
206
- style_final = style_value or cast(StyleLiteral, app_config.emitters.pytest.style)
207
- scope_final = scope_value or app_config.emitters.pytest.scope
208
- return_type_final = return_type_value or DEFAULT_RETURN
209
-
210
- pytest_config = PytestEmitConfig(
211
- scope=scope_final,
212
- style=style_final,
213
- return_type=return_type_final,
214
- cases=cases,
215
- seed=seed_value,
216
- optional_p_none=app_config.p_none,
287
+ logger.debug(
288
+ "Loaded configuration",
289
+ event="config_loaded",
290
+ seed=config_snapshot.seed,
291
+ include=list(config_snapshot.include),
292
+ exclude=list(config_snapshot.exclude),
293
+ time_anchor=anchor_iso,
217
294
  )
218
295
 
219
- context = EmitterContext(
220
- models=tuple(model_classes),
221
- output=out,
222
- parameters={
223
- "style": style_final,
224
- "scope": scope_final,
225
- "cases": cases,
226
- "return_type": return_type_final,
227
- },
228
- )
229
- if emit_artifact("fixtures", context):
230
- return
296
+ if anchor_iso:
297
+ logger.info(
298
+ "Using temporal anchor",
299
+ event="temporal_anchor_set",
300
+ time_anchor=anchor_iso,
301
+ )
231
302
 
232
- try:
233
- result = emit_pytest_fixtures(
234
- model_classes,
235
- output_path=out,
236
- config=pytest_config,
303
+ for warning in result.warnings:
304
+ logger.warn(
305
+ warning,
306
+ event="discovery_warning",
307
+ warning=warning,
237
308
  )
238
- except Exception as exc:
239
- raise EmitError(str(exc)) from exc
240
309
 
241
- message = str(out)
310
+ if result.delegated:
311
+ logger.info(
312
+ "Fixtures generation handled by plugin",
313
+ event="fixtures_generation_delegated",
314
+ output=str(result.base_output),
315
+ style=result.style,
316
+ scope=result.scope,
317
+ time_anchor=anchor_iso,
318
+ )
319
+ return True
320
+
242
321
  if result.skipped:
243
- message += " (unchanged)"
244
- typer.echo(message)
322
+ logger.info(
323
+ "Fixtures unchanged",
324
+ event="fixtures_generation_unchanged",
325
+ output=str(result.path),
326
+ time_anchor=anchor_iso,
327
+ )
328
+ else:
329
+ logger.info(
330
+ "Fixtures generation complete",
331
+ event="fixtures_generation_complete",
332
+ output=str(result.path),
333
+ style=result.style,
334
+ scope=result.scope,
335
+ time_anchor=anchor_iso,
336
+ )
337
+ return False
245
338
 
246
339
 
247
340
  __all__ = ["register"]
248
341
 
249
342
 
343
+ def _handle_fixtures_error(logger: Logger, exc: PFGError) -> None:
344
+ details = getattr(exc, "details", {}) or {}
345
+ config_info = details.get("config")
346
+ anchor_iso = None
347
+ if isinstance(config_info, dict):
348
+ anchor_iso = config_info.get("time_anchor")
349
+ logger.debug(
350
+ "Loaded configuration",
351
+ event="config_loaded",
352
+ seed=config_info.get("seed"),
353
+ include=config_info.get("include", []),
354
+ exclude=config_info.get("exclude", []),
355
+ time_anchor=anchor_iso,
356
+ )
357
+ if anchor_iso:
358
+ logger.info(
359
+ "Using temporal anchor",
360
+ event="temporal_anchor_set",
361
+ time_anchor=anchor_iso,
362
+ )
363
+
364
+ warnings = details.get("warnings") or []
365
+ for warning in warnings:
366
+ if isinstance(warning, str):
367
+ logger.warn(
368
+ warning,
369
+ event="discovery_warning",
370
+ warning=warning,
371
+ )
372
+
373
+ constraint_summary = details.get("constraint_summary")
374
+ if constraint_summary:
375
+ emit_constraint_summary(
376
+ constraint_summary,
377
+ logger=logger,
378
+ json_mode=logger.config.json,
379
+ )
380
+
381
+
250
382
  def _coerce_style(value: str | None) -> StyleLiteral | None:
251
383
  if value is None:
252
384
  return None