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
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from
|
|
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
|
-
) ->
|
|
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
|
-
|
|
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
|
-
|
|
212
|
+
model_report = _analyse_model(model_cls, builder)
|
|
213
|
+
reports.append(model_report)
|
|
214
|
+
all_gaps.extend(model_report.gaps)
|
|
160
215
|
|
|
161
|
-
|
|
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,
|
|
229
|
+
for field_name, model_field in model.model_fields.items():
|
|
183
230
|
total_fields += 1
|
|
184
231
|
summary = summaries[field_name]
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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[
|
|
274
|
+
def _strategy_status(summary: FieldSummary, strategy: StrategyResult) -> tuple[bool, list[GapInfo]]:
|
|
194
275
|
if isinstance(strategy, UnionStrategy):
|
|
195
|
-
|
|
276
|
+
gap_infos: list[GapInfo] = []
|
|
196
277
|
covered = True
|
|
197
278
|
for choice in strategy.choices:
|
|
198
|
-
choice_ok,
|
|
279
|
+
choice_ok, choice_gaps = _strategy_status(choice.summary, choice)
|
|
199
280
|
if not choice_ok:
|
|
200
281
|
covered = False
|
|
201
|
-
|
|
202
|
-
return covered,
|
|
282
|
+
gap_infos.extend(choice_gaps)
|
|
283
|
+
return covered, gap_infos
|
|
203
284
|
|
|
204
|
-
|
|
285
|
+
gaps: list[GapInfo] = []
|
|
205
286
|
if strategy.enum_values:
|
|
206
|
-
return True,
|
|
287
|
+
return True, gaps
|
|
207
288
|
|
|
208
289
|
if summary.type in {"model", "dataclass"}:
|
|
209
|
-
return True,
|
|
290
|
+
return True, gaps
|
|
210
291
|
|
|
211
292
|
if strategy.provider_ref is None:
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
sys.path
|
|
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:
|