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
@@ -2,8 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import dataclass
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass, field
6
7
  from pathlib import Path
8
+ from typing import Literal, cast
7
9
 
8
10
  import typer
9
11
  from pydantic import BaseModel
@@ -58,6 +60,13 @@ MEMORY_LIMIT_OPTION = typer.Option(
58
60
  help="Memory limit in megabytes for safe import subprocess.",
59
61
  )
60
62
 
63
+ FAIL_ON_GAPS_OPTION = typer.Option(
64
+ None,
65
+ "--fail-on-gaps",
66
+ min=0,
67
+ help="Exit with code 2 when uncovered fields exceed this number (errors only).",
68
+ )
69
+
61
70
 
62
71
  app = typer.Typer(invoke_without_command=True, subcommand_metavar="")
63
72
 
@@ -67,6 +76,43 @@ class ModelReport:
67
76
  model: type[BaseModel]
68
77
  coverage: tuple[int, int]
69
78
  issues: list[str]
79
+ gaps: list[FieldGap] = field(default_factory=list)
80
+
81
+
82
+ @dataclass(slots=True)
83
+ class GapInfo:
84
+ type_name: str
85
+ reason: str
86
+ remediation: str
87
+ severity: Literal["error", "warning"]
88
+
89
+
90
+ @dataclass(slots=True)
91
+ class FieldGap:
92
+ model: type[BaseModel]
93
+ field: str
94
+ info: GapInfo
95
+
96
+ @property
97
+ def qualified_field(self) -> str:
98
+ return f"{self.model.__name__}.{self.field}"
99
+
100
+
101
+ @dataclass(slots=True)
102
+ class TypeGapSummary:
103
+ type_name: str
104
+ reason: str
105
+ remediation: str
106
+ severity: Literal["error", "warning"]
107
+ occurrences: int
108
+ fields: list[str]
109
+
110
+
111
+ @dataclass(slots=True)
112
+ class GapSummary:
113
+ summaries: list[TypeGapSummary]
114
+ total_error_fields: int
115
+ total_warning_fields: int
70
116
 
71
117
 
72
118
  def doctor( # noqa: D401 - Typer callback
@@ -79,10 +125,11 @@ def doctor( # noqa: D401 - Typer callback
79
125
  timeout: float = TIMEOUT_OPTION,
80
126
  memory_limit_mb: int = MEMORY_LIMIT_OPTION,
81
127
  json_errors: bool = JSON_ERRORS_OPTION,
128
+ fail_on_gaps: int | None = FAIL_ON_GAPS_OPTION,
82
129
  ) -> None:
83
130
  _ = ctx # unused
84
131
  try:
85
- _execute_doctor(
132
+ gap_summary = _execute_doctor(
86
133
  target=path,
87
134
  include=include,
88
135
  exclude=exclude,
@@ -93,6 +140,10 @@ def doctor( # noqa: D401 - Typer callback
93
140
  )
94
141
  except PFGError as exc:
95
142
  render_cli_error(exc, json_errors=json_errors)
143
+ return
144
+
145
+ if fail_on_gaps is not None and gap_summary.total_error_fields > fail_on_gaps:
146
+ raise typer.Exit(code=2)
96
147
 
97
148
 
98
149
  app.callback(invoke_without_command=True)(doctor)
@@ -117,7 +168,7 @@ def _execute_doctor(
117
168
  hybrid_mode: bool,
118
169
  timeout: float,
119
170
  memory_limit_mb: int,
120
- ) -> None:
171
+ ) -> GapSummary:
121
172
  path = Path(target)
122
173
  if not path.exists():
123
174
  raise DiscoveryError(f"Target path '{target}' does not exist.", details={"path": target})
@@ -145,79 +196,167 @@ def _execute_doctor(
145
196
 
146
197
  if not discovery.models:
147
198
  typer.echo("No models discovered.")
148
- return
199
+ empty_summary = GapSummary(summaries=[], total_error_fields=0, total_warning_fields=0)
200
+ return empty_summary
149
201
 
150
202
  registry = create_default_registry(load_plugins=True)
151
203
  builder = StrategyBuilder(registry, plugin_manager=get_plugin_manager())
152
204
 
153
205
  reports: list[ModelReport] = []
206
+ all_gaps: list[FieldGap] = []
154
207
  for model_info in discovery.models:
155
208
  try:
156
209
  model_cls = load_model_class(model_info)
157
210
  except RuntimeError as exc:
158
211
  raise DiscoveryError(str(exc)) from exc
159
- reports.append(_analyse_model(model_cls, builder))
212
+ model_report = _analyse_model(model_cls, builder)
213
+ reports.append(model_report)
214
+ all_gaps.extend(model_report.gaps)
160
215
 
161
- _render_report(reports)
216
+ gap_summary = _summarize_gaps(all_gaps)
217
+ _render_report(reports, gap_summary)
218
+ return gap_summary
162
219
 
163
220
 
164
221
  def _analyse_model(model: type[BaseModel], builder: StrategyBuilder) -> ModelReport:
165
222
  total_fields = 0
166
223
  covered_fields = 0
167
224
  issues: list[str] = []
168
-
169
- try:
170
- strategies = builder.build_model_strategies(model)
171
- except ValueError as exc: # missing provider
172
- summaries = summarize_model_fields(model)
173
- message = str(exc)
174
- return ModelReport(
175
- model=model,
176
- coverage=(0, len(summaries)),
177
- issues=[message],
178
- )
225
+ field_gaps: list[FieldGap] = []
179
226
 
180
227
  summaries = summarize_model_fields(model)
181
228
 
182
- for field_name, strategy in strategies.items():
229
+ for field_name, model_field in model.model_fields.items():
183
230
  total_fields += 1
184
231
  summary = summaries[field_name]
185
- covered, field_issues = _strategy_status(summary, strategy)
232
+ try:
233
+ strategy = builder.build_field_strategy(
234
+ model,
235
+ field_name,
236
+ model_field.annotation,
237
+ summary,
238
+ )
239
+ except ValueError as exc:
240
+ message = str(exc)
241
+ issues.append(f"{model.__name__}.{field_name}: [error] {message}")
242
+ field_gaps.append(
243
+ FieldGap(
244
+ model=model,
245
+ field=field_name,
246
+ info=GapInfo(
247
+ type_name=summary.type,
248
+ reason=message,
249
+ remediation=(
250
+ "Register a custom provider or configure an override for this field."
251
+ ),
252
+ severity="error",
253
+ ),
254
+ )
255
+ )
256
+ continue
257
+
258
+ covered, gap_infos = _strategy_status(summary, strategy)
186
259
  if covered:
187
260
  covered_fields += 1
188
- issues.extend(f"{model.__name__}.{field_name}: {msg}" for msg in field_issues)
189
-
190
- return ModelReport(model=model, coverage=(covered_fields, total_fields), issues=issues)
261
+ for gap_info in gap_infos:
262
+ severity_label = "warning" if gap_info.severity == "warning" else "error"
263
+ issues.append(f"{model.__name__}.{field_name}: [{severity_label}] {gap_info.reason}")
264
+ field_gaps.append(FieldGap(model=model, field=field_name, info=gap_info))
265
+
266
+ return ModelReport(
267
+ model=model,
268
+ coverage=(covered_fields, total_fields),
269
+ issues=issues,
270
+ gaps=field_gaps,
271
+ )
191
272
 
192
273
 
193
- def _strategy_status(summary: FieldSummary, strategy: StrategyResult) -> tuple[bool, list[str]]:
274
+ def _strategy_status(summary: FieldSummary, strategy: StrategyResult) -> tuple[bool, list[GapInfo]]:
194
275
  if isinstance(strategy, UnionStrategy):
195
- issue_messages: list[str] = []
276
+ gap_infos: list[GapInfo] = []
196
277
  covered = True
197
278
  for choice in strategy.choices:
198
- choice_ok, choice_issues = _strategy_status(choice.summary, choice)
279
+ choice_ok, choice_gaps = _strategy_status(choice.summary, choice)
199
280
  if not choice_ok:
200
281
  covered = False
201
- issue_messages.extend(choice_issues)
202
- return covered, issue_messages
282
+ gap_infos.extend(choice_gaps)
283
+ return covered, gap_infos
203
284
 
204
- messages: list[str] = []
285
+ gaps: list[GapInfo] = []
205
286
  if strategy.enum_values:
206
- return True, messages
287
+ return True, gaps
207
288
 
208
289
  if summary.type in {"model", "dataclass"}:
209
- return True, messages
290
+ return True, gaps
210
291
 
211
292
  if strategy.provider_ref is None:
212
- messages.append(f"no provider for type '{summary.type}'")
213
- return False, messages
293
+ gaps.append(
294
+ GapInfo(
295
+ type_name=summary.type,
296
+ reason=f"No provider registered for type '{summary.type}'.",
297
+ remediation="Register a custom provider or configure an override for this field.",
298
+ severity="error",
299
+ )
300
+ )
301
+ return False, gaps
214
302
 
215
303
  if summary.type == "any":
216
- messages.append("falls back to generic type")
217
- return True, messages
304
+ gaps.append(
305
+ GapInfo(
306
+ type_name="any",
307
+ reason="Falls back to generic `Any` provider.",
308
+ remediation="Define a provider for this shape or narrow the field annotation.",
309
+ severity="warning",
310
+ )
311
+ )
312
+ return True, gaps
313
+
218
314
 
315
+ def _summarize_gaps(field_gaps: Iterable[FieldGap]) -> GapSummary:
316
+ grouped: dict[tuple[str, str, str, str], list[str]] = {}
317
+ error_fields = 0
318
+ warning_fields = 0
219
319
 
220
- def _render_report(reports: list[ModelReport]) -> None:
320
+ for gap in field_gaps:
321
+ key = (
322
+ gap.info.severity,
323
+ gap.info.type_name,
324
+ gap.info.reason,
325
+ gap.info.remediation,
326
+ )
327
+ grouped.setdefault(key, []).append(gap.qualified_field)
328
+ if gap.info.severity == "error":
329
+ error_fields += 1
330
+ else:
331
+ warning_fields += 1
332
+
333
+ summaries = [
334
+ TypeGapSummary(
335
+ type_name=type_name,
336
+ reason=reason,
337
+ remediation=remediation,
338
+ severity=cast(Literal["error", "warning"], severity),
339
+ occurrences=len(fields),
340
+ fields=sorted(fields),
341
+ )
342
+ for (severity, type_name, reason, remediation), fields in grouped.items()
343
+ ]
344
+ summaries.sort(
345
+ key=lambda item: (
346
+ 0 if item.severity == "error" else 1,
347
+ item.type_name,
348
+ item.reason,
349
+ )
350
+ )
351
+
352
+ return GapSummary(
353
+ summaries=summaries,
354
+ total_error_fields=error_fields,
355
+ total_warning_fields=warning_fields,
356
+ )
357
+
358
+
359
+ def _render_report(reports: list[ModelReport], gap_summary: GapSummary) -> None:
221
360
  for report in reports:
222
361
  covered, total = report.coverage
223
362
  coverage_pct = (covered / total * 100) if total else 100.0
@@ -231,5 +370,19 @@ def _render_report(reports: list[ModelReport]) -> None:
231
370
  typer.echo(" Issues: none")
232
371
  typer.echo("")
233
372
 
373
+ if not gap_summary.summaries:
374
+ typer.echo("Type coverage gaps: none")
375
+ return
376
+
377
+ typer.echo("Type coverage gaps:")
378
+ for summary in gap_summary.summaries:
379
+ level = "WARNING" if summary.severity == "warning" else "ERROR"
380
+ typer.echo(f" - {summary.type_name}: {summary.reason} [{level}]")
381
+ typer.echo(f" Fields ({summary.occurrences}):")
382
+ for field_name in summary.fields:
383
+ typer.echo(f" • {field_name}")
384
+ typer.echo(f" Remediation: {summary.remediation}")
385
+ typer.echo("")
386
+
234
387
 
235
388
  __all__ = ["app"]
@@ -6,10 +6,10 @@ import importlib
6
6
  import importlib.util
7
7
  import json
8
8
  import sys
9
- from collections.abc import Sequence
9
+ from collections.abc import Mapping, Sequence
10
10
  from pathlib import Path
11
11
  from types import ModuleType
12
- from typing import Literal
12
+ from typing import Any, Literal
13
13
 
14
14
  import typer
15
15
  from pydantic import BaseModel
@@ -20,14 +20,17 @@ from pydantic_fixturegen.core.introspect import (
20
20
  IntrospectionResult,
21
21
  discover,
22
22
  )
23
+ from pydantic_fixturegen.logging import Logger
23
24
 
24
25
  __all__ = [
25
26
  "JSON_ERRORS_OPTION",
27
+ "NOW_OPTION",
26
28
  "clear_module_cache",
27
29
  "discover_models",
28
30
  "load_model_class",
29
31
  "render_cli_error",
30
32
  "split_patterns",
33
+ "emit_constraint_summary",
31
34
  ]
32
35
 
33
36
 
@@ -40,6 +43,12 @@ JSON_ERRORS_OPTION = typer.Option(
40
43
  help="Emit structured JSON errors to stdout.",
41
44
  )
42
45
 
46
+ NOW_OPTION = typer.Option(
47
+ None,
48
+ "--now",
49
+ help="Anchor timestamp (ISO 8601) used for temporal value generation.",
50
+ )
51
+
43
52
 
44
53
  def clear_module_cache() -> None:
45
54
  """Clear cached module imports used during CLI execution."""
@@ -53,6 +62,49 @@ def split_patterns(raw: str | None) -> list[str]:
53
62
  return [part.strip() for part in raw.split(",") if part.strip()]
54
63
 
55
64
 
65
+ def _package_hierarchy(module_path: Path) -> list[Path]:
66
+ hierarchy: list[Path] = []
67
+ current = module_path.parent.resolve()
68
+
69
+ while True:
70
+ init_file = current / "__init__.py"
71
+ if not init_file.exists():
72
+ break
73
+ hierarchy.append(current)
74
+ parent = current.parent.resolve()
75
+ if parent == current:
76
+ break
77
+ current = parent
78
+
79
+ hierarchy.reverse()
80
+ return hierarchy
81
+
82
+
83
+ def _module_sys_path_entries(module_path: Path) -> list[str]:
84
+ resolved_path = module_path.resolve()
85
+ candidates: list[Path] = []
86
+ packages = _package_hierarchy(resolved_path)
87
+
88
+ if packages:
89
+ root_parent = packages[0].parent.resolve()
90
+ if root_parent != packages[0] and root_parent.is_dir():
91
+ candidates.append(root_parent)
92
+
93
+ parent_dir = resolved_path.parent
94
+ if parent_dir.is_dir():
95
+ candidates.append(parent_dir.resolve())
96
+
97
+ ordered: list[str] = []
98
+ seen: set[str] = set()
99
+ for entry in candidates:
100
+ entry_str = str(entry)
101
+ if entry_str in seen:
102
+ continue
103
+ ordered.append(entry_str)
104
+ seen.add(entry_str)
105
+ return ordered
106
+
107
+
56
108
  DiscoveryMethod = Literal["ast", "import", "hybrid"]
57
109
 
58
110
 
@@ -87,7 +139,7 @@ def load_model_class(model_info: IntrospectedModel) -> type[BaseModel]:
87
139
  return attr
88
140
 
89
141
 
90
- def render_cli_error(error: PFGError, *, json_errors: bool) -> None:
142
+ def render_cli_error(error: PFGError, *, json_errors: bool, exit_app: bool = True) -> None:
91
143
  if json_errors:
92
144
  payload = {"error": error.to_payload()}
93
145
  typer.echo(json.dumps(payload, indent=2))
@@ -95,7 +147,81 @@ def render_cli_error(error: PFGError, *, json_errors: bool) -> None:
95
147
  typer.secho(f"{error.kind}: {error}", err=True, fg=typer.colors.RED)
96
148
  if error.hint:
97
149
  typer.secho(f"hint: {error.hint}", err=True, fg=typer.colors.YELLOW)
98
- raise typer.Exit(code=int(error.code))
150
+ if exit_app:
151
+ raise typer.Exit(code=int(error.code))
152
+
153
+
154
+ def emit_constraint_summary(
155
+ report: Mapping[str, Any] | None,
156
+ *,
157
+ logger: Logger,
158
+ json_mode: bool,
159
+ heading: str | None = None,
160
+ ) -> None:
161
+ if not report:
162
+ return
163
+
164
+ models = report.get("models")
165
+ if not models:
166
+ return
167
+
168
+ has_failures = any(
169
+ field.get("failures") for model_entry in models for field in model_entry.get("fields", [])
170
+ )
171
+
172
+ event_payload = {"report": report, "heading": heading}
173
+
174
+ if has_failures:
175
+ logger.warn(
176
+ "Constraint violations detected.",
177
+ event="constraint_report",
178
+ **event_payload,
179
+ )
180
+ else:
181
+ logger.debug(
182
+ "Constraint report recorded.",
183
+ event="constraint_report",
184
+ **event_payload,
185
+ )
186
+
187
+ if not has_failures or json_mode:
188
+ return
189
+
190
+ title = heading or "Constraint report"
191
+ typer.secho(title + ":", fg=typer.colors.CYAN)
192
+
193
+ for model_entry in models:
194
+ fields = [field for field in model_entry.get("fields", []) if field.get("failures")]
195
+ if not fields:
196
+ continue
197
+
198
+ typer.secho(
199
+ (
200
+ f" {model_entry['model']} "
201
+ f"(attempts={model_entry['attempts']}, successes={model_entry['successes']})"
202
+ ),
203
+ fg=typer.colors.CYAN,
204
+ )
205
+ for field_entry in fields:
206
+ typer.secho(
207
+ (
208
+ f" {field_entry['name']} "
209
+ f"(attempts={field_entry['attempts']}, successes={field_entry['successes']})"
210
+ ),
211
+ fg=typer.colors.YELLOW,
212
+ )
213
+ for failure in field_entry.get("failures", []):
214
+ location = failure.get("location") or [field_entry["name"]]
215
+ location_display = ".".join(str(part) for part in location)
216
+ typer.secho(
217
+ f" ✖ {location_display}: {failure.get('message', '')}",
218
+ fg=typer.colors.RED,
219
+ )
220
+ if failure.get("value") is not None:
221
+ typer.echo(f" value={failure['value']}")
222
+ hint = failure.get("hint")
223
+ if hint:
224
+ typer.secho(f" hint: {hint}", fg=typer.colors.MAGENTA)
99
225
 
100
226
 
101
227
  def _load_module(module_name: str, locator: Path) -> ModuleType:
@@ -117,9 +243,10 @@ def _import_module_by_path(module_name: str, path: Path) -> ModuleType:
117
243
  if not path.exists():
118
244
  raise RuntimeError(f"Could not locate module source at {path}.")
119
245
 
120
- sys_path_entry = str(path.parent.resolve())
121
- if sys_path_entry not in sys.path:
122
- sys.path.insert(0, sys_path_entry)
246
+ sys_path_entries = _module_sys_path_entries(path)
247
+ for entry in reversed(sys_path_entries):
248
+ if entry not in sys.path:
249
+ sys.path.insert(0, entry)
123
250
 
124
251
  unique_name = module_name
125
252
  if module_name in sys.modules: