pydantic-fixturegen 1.0.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/__init__.py +7 -0
- pydantic_fixturegen/cli/__init__.py +85 -0
- pydantic_fixturegen/cli/doctor.py +235 -0
- pydantic_fixturegen/cli/gen/__init__.py +23 -0
- pydantic_fixturegen/cli/gen/_common.py +139 -0
- pydantic_fixturegen/cli/gen/explain.py +145 -0
- pydantic_fixturegen/cli/gen/fixtures.py +283 -0
- pydantic_fixturegen/cli/gen/json.py +262 -0
- pydantic_fixturegen/cli/gen/schema.py +164 -0
- pydantic_fixturegen/cli/list.py +164 -0
- pydantic_fixturegen/core/__init__.py +103 -0
- pydantic_fixturegen/core/ast_discover.py +169 -0
- pydantic_fixturegen/core/config.py +440 -0
- pydantic_fixturegen/core/errors.py +136 -0
- pydantic_fixturegen/core/generate.py +311 -0
- pydantic_fixturegen/core/introspect.py +141 -0
- pydantic_fixturegen/core/io_utils.py +77 -0
- pydantic_fixturegen/core/providers/__init__.py +32 -0
- pydantic_fixturegen/core/providers/collections.py +74 -0
- pydantic_fixturegen/core/providers/identifiers.py +68 -0
- pydantic_fixturegen/core/providers/numbers.py +133 -0
- pydantic_fixturegen/core/providers/registry.py +98 -0
- pydantic_fixturegen/core/providers/strings.py +109 -0
- pydantic_fixturegen/core/providers/temporal.py +42 -0
- pydantic_fixturegen/core/safe_import.py +403 -0
- pydantic_fixturegen/core/schema.py +320 -0
- pydantic_fixturegen/core/seed.py +154 -0
- pydantic_fixturegen/core/strategies.py +193 -0
- pydantic_fixturegen/core/version.py +52 -0
- pydantic_fixturegen/emitters/__init__.py +15 -0
- pydantic_fixturegen/emitters/json_out.py +373 -0
- pydantic_fixturegen/emitters/pytest_codegen.py +365 -0
- pydantic_fixturegen/emitters/schema_out.py +84 -0
- pydantic_fixturegen/plugins/builtin.py +45 -0
- pydantic_fixturegen/plugins/hookspecs.py +59 -0
- pydantic_fixturegen/plugins/loader.py +72 -0
- pydantic_fixturegen-1.0.0.dist-info/METADATA +280 -0
- pydantic_fixturegen-1.0.0.dist-info/RECORD +41 -0
- pydantic_fixturegen-1.0.0.dist-info/WHEEL +4 -0
- pydantic_fixturegen-1.0.0.dist-info/entry_points.txt +5 -0
- pydantic_fixturegen-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Command line interface for pydantic-fixturegen."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from typer.main import get_command
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _load_typer(import_path: str) -> typer.Typer:
|
|
13
|
+
module_name, attr = import_path.split(":", 1)
|
|
14
|
+
module = import_module(module_name)
|
|
15
|
+
loaded = getattr(module, attr)
|
|
16
|
+
if not isinstance(loaded, typer.Typer):
|
|
17
|
+
raise TypeError(f"Attribute {attr!r} in module {module_name!r} is not a Typer app.")
|
|
18
|
+
return loaded
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _invoke(import_path: str, ctx: typer.Context) -> None:
|
|
22
|
+
sub_app = _load_typer(import_path)
|
|
23
|
+
command = get_command(sub_app)
|
|
24
|
+
args = builtins.list(ctx.args)
|
|
25
|
+
result = command.main(
|
|
26
|
+
args=args,
|
|
27
|
+
prog_name=ctx.command_path,
|
|
28
|
+
standalone_mode=False,
|
|
29
|
+
)
|
|
30
|
+
if isinstance(result, int):
|
|
31
|
+
raise typer.Exit(code=result)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(
|
|
35
|
+
help="pydantic-fixturegen command line interface",
|
|
36
|
+
invoke_without_command=True,
|
|
37
|
+
context_settings={
|
|
38
|
+
"allow_extra_args": True,
|
|
39
|
+
"ignore_unknown_options": True,
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.callback(invoke_without_command=True)
|
|
45
|
+
def _root(ctx: typer.Context) -> None: # noqa: D401
|
|
46
|
+
if ctx.invoked_subcommand is None:
|
|
47
|
+
_invoke("pydantic_fixturegen.cli.list:app", ctx)
|
|
48
|
+
raise typer.Exit()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _proxy(name: str, import_path: str, help_text: str) -> None:
|
|
52
|
+
context_settings = {
|
|
53
|
+
"allow_extra_args": True,
|
|
54
|
+
"ignore_unknown_options": True,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@app.command(name, context_settings=context_settings)
|
|
58
|
+
def command(ctx: typer.Context) -> None:
|
|
59
|
+
_invoke(import_path, ctx)
|
|
60
|
+
|
|
61
|
+
command.__doc__ = help_text
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_proxy(
|
|
65
|
+
"list",
|
|
66
|
+
"pydantic_fixturegen.cli.list:app",
|
|
67
|
+
"List Pydantic models from modules or files.",
|
|
68
|
+
)
|
|
69
|
+
_proxy(
|
|
70
|
+
"gen",
|
|
71
|
+
"pydantic_fixturegen.cli.gen:app",
|
|
72
|
+
"Generate artifacts for discovered models.",
|
|
73
|
+
)
|
|
74
|
+
_proxy(
|
|
75
|
+
"doctor",
|
|
76
|
+
"pydantic_fixturegen.cli.doctor:app",
|
|
77
|
+
"Inspect models for coverage and risks.",
|
|
78
|
+
)
|
|
79
|
+
_proxy(
|
|
80
|
+
"explain",
|
|
81
|
+
"pydantic_fixturegen.cli.gen.explain:app",
|
|
82
|
+
"Explain generation strategies per model field.",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
__all__ = ["app"]
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""CLI command for inspecting project health."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from pydantic_fixturegen.core.errors import DiscoveryError, PFGError
|
|
12
|
+
from pydantic_fixturegen.core.providers import create_default_registry
|
|
13
|
+
from pydantic_fixturegen.core.schema import FieldSummary, summarize_model_fields
|
|
14
|
+
from pydantic_fixturegen.core.strategies import StrategyBuilder, StrategyResult, UnionStrategy
|
|
15
|
+
from pydantic_fixturegen.plugins.loader import get_plugin_manager
|
|
16
|
+
|
|
17
|
+
from .gen._common import (
|
|
18
|
+
JSON_ERRORS_OPTION,
|
|
19
|
+
DiscoveryMethod,
|
|
20
|
+
clear_module_cache,
|
|
21
|
+
discover_models,
|
|
22
|
+
load_model_class,
|
|
23
|
+
render_cli_error,
|
|
24
|
+
split_patterns,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
PATH_ARGUMENT = typer.Argument(..., help="Path to a Python module containing Pydantic models.")
|
|
28
|
+
|
|
29
|
+
INCLUDE_OPTION = typer.Option(
|
|
30
|
+
None,
|
|
31
|
+
"--include",
|
|
32
|
+
"-i",
|
|
33
|
+
help="Comma-separated pattern(s) of fully-qualified model names to include.",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
EXCLUDE_OPTION = typer.Option(
|
|
37
|
+
None,
|
|
38
|
+
"--exclude",
|
|
39
|
+
"-e",
|
|
40
|
+
help="Comma-separated pattern(s) of fully-qualified model names to exclude.",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
AST_OPTION = typer.Option(False, "--ast", help="Use AST discovery only (no imports executed).")
|
|
44
|
+
|
|
45
|
+
HYBRID_OPTION = typer.Option(False, "--hybrid", help="Combine AST and safe import discovery.")
|
|
46
|
+
|
|
47
|
+
TIMEOUT_OPTION = typer.Option(
|
|
48
|
+
5.0,
|
|
49
|
+
"--timeout",
|
|
50
|
+
min=0.1,
|
|
51
|
+
help="Timeout in seconds for safe import execution.",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
MEMORY_LIMIT_OPTION = typer.Option(
|
|
55
|
+
256,
|
|
56
|
+
"--memory-limit-mb",
|
|
57
|
+
min=1,
|
|
58
|
+
help="Memory limit in megabytes for safe import subprocess.",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
app = typer.Typer(invoke_without_command=True, subcommand_metavar="")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ModelReport:
|
|
67
|
+
model: type[BaseModel]
|
|
68
|
+
coverage: tuple[int, int]
|
|
69
|
+
issues: list[str]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def doctor( # noqa: D401 - Typer callback
|
|
73
|
+
ctx: typer.Context,
|
|
74
|
+
path: str = PATH_ARGUMENT,
|
|
75
|
+
include: str | None = INCLUDE_OPTION,
|
|
76
|
+
exclude: str | None = EXCLUDE_OPTION,
|
|
77
|
+
ast_mode: bool = AST_OPTION,
|
|
78
|
+
hybrid_mode: bool = HYBRID_OPTION,
|
|
79
|
+
timeout: float = TIMEOUT_OPTION,
|
|
80
|
+
memory_limit_mb: int = MEMORY_LIMIT_OPTION,
|
|
81
|
+
json_errors: bool = JSON_ERRORS_OPTION,
|
|
82
|
+
) -> None:
|
|
83
|
+
_ = ctx # unused
|
|
84
|
+
try:
|
|
85
|
+
_execute_doctor(
|
|
86
|
+
target=path,
|
|
87
|
+
include=include,
|
|
88
|
+
exclude=exclude,
|
|
89
|
+
ast_mode=ast_mode,
|
|
90
|
+
hybrid_mode=hybrid_mode,
|
|
91
|
+
timeout=timeout,
|
|
92
|
+
memory_limit_mb=memory_limit_mb,
|
|
93
|
+
)
|
|
94
|
+
except PFGError as exc:
|
|
95
|
+
render_cli_error(exc, json_errors=json_errors)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
app.callback(invoke_without_command=True)(doctor)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _resolve_method(ast_mode: bool, hybrid_mode: bool) -> DiscoveryMethod:
|
|
102
|
+
if ast_mode and hybrid_mode:
|
|
103
|
+
raise DiscoveryError("Choose only one of --ast or --hybrid.")
|
|
104
|
+
if hybrid_mode:
|
|
105
|
+
return "hybrid"
|
|
106
|
+
if ast_mode:
|
|
107
|
+
return "ast"
|
|
108
|
+
return "import"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _execute_doctor(
|
|
112
|
+
*,
|
|
113
|
+
target: str,
|
|
114
|
+
include: str | None,
|
|
115
|
+
exclude: str | None,
|
|
116
|
+
ast_mode: bool,
|
|
117
|
+
hybrid_mode: bool,
|
|
118
|
+
timeout: float,
|
|
119
|
+
memory_limit_mb: int,
|
|
120
|
+
) -> None:
|
|
121
|
+
path = Path(target)
|
|
122
|
+
if not path.exists():
|
|
123
|
+
raise DiscoveryError(f"Target path '{target}' does not exist.", details={"path": target})
|
|
124
|
+
if not path.is_file():
|
|
125
|
+
raise DiscoveryError("Target must be a Python module file.", details={"path": target})
|
|
126
|
+
|
|
127
|
+
clear_module_cache()
|
|
128
|
+
|
|
129
|
+
method = _resolve_method(ast_mode, hybrid_mode)
|
|
130
|
+
discovery = discover_models(
|
|
131
|
+
path,
|
|
132
|
+
include=split_patterns(include),
|
|
133
|
+
exclude=split_patterns(exclude),
|
|
134
|
+
method=method,
|
|
135
|
+
timeout=timeout,
|
|
136
|
+
memory_limit_mb=memory_limit_mb,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if discovery.errors:
|
|
140
|
+
raise DiscoveryError("; ".join(discovery.errors))
|
|
141
|
+
|
|
142
|
+
for warning in discovery.warnings:
|
|
143
|
+
if warning.strip():
|
|
144
|
+
typer.secho(f"warning: {warning.strip()}", err=True, fg=typer.colors.YELLOW)
|
|
145
|
+
|
|
146
|
+
if not discovery.models:
|
|
147
|
+
typer.echo("No models discovered.")
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
registry = create_default_registry(load_plugins=True)
|
|
151
|
+
builder = StrategyBuilder(registry, plugin_manager=get_plugin_manager())
|
|
152
|
+
|
|
153
|
+
reports: list[ModelReport] = []
|
|
154
|
+
for model_info in discovery.models:
|
|
155
|
+
try:
|
|
156
|
+
model_cls = load_model_class(model_info)
|
|
157
|
+
except RuntimeError as exc:
|
|
158
|
+
raise DiscoveryError(str(exc)) from exc
|
|
159
|
+
reports.append(_analyse_model(model_cls, builder))
|
|
160
|
+
|
|
161
|
+
_render_report(reports)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _analyse_model(model: type[BaseModel], builder: StrategyBuilder) -> ModelReport:
|
|
165
|
+
total_fields = 0
|
|
166
|
+
covered_fields = 0
|
|
167
|
+
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
|
+
)
|
|
179
|
+
|
|
180
|
+
summaries = summarize_model_fields(model)
|
|
181
|
+
|
|
182
|
+
for field_name, strategy in strategies.items():
|
|
183
|
+
total_fields += 1
|
|
184
|
+
summary = summaries[field_name]
|
|
185
|
+
covered, field_issues = _strategy_status(summary, strategy)
|
|
186
|
+
if covered:
|
|
187
|
+
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)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _strategy_status(summary: FieldSummary, strategy: StrategyResult) -> tuple[bool, list[str]]:
|
|
194
|
+
if isinstance(strategy, UnionStrategy):
|
|
195
|
+
issue_messages: list[str] = []
|
|
196
|
+
covered = True
|
|
197
|
+
for choice in strategy.choices:
|
|
198
|
+
choice_ok, choice_issues = _strategy_status(choice.summary, choice)
|
|
199
|
+
if not choice_ok:
|
|
200
|
+
covered = False
|
|
201
|
+
issue_messages.extend(choice_issues)
|
|
202
|
+
return covered, issue_messages
|
|
203
|
+
|
|
204
|
+
messages: list[str] = []
|
|
205
|
+
if strategy.enum_values:
|
|
206
|
+
return True, messages
|
|
207
|
+
|
|
208
|
+
if summary.type in {"model", "dataclass"}:
|
|
209
|
+
return True, messages
|
|
210
|
+
|
|
211
|
+
if strategy.provider_ref is None:
|
|
212
|
+
messages.append(f"no provider for type '{summary.type}'")
|
|
213
|
+
return False, messages
|
|
214
|
+
|
|
215
|
+
if summary.type == "any":
|
|
216
|
+
messages.append("falls back to generic type")
|
|
217
|
+
return True, messages
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _render_report(reports: list[ModelReport]) -> None:
|
|
221
|
+
for report in reports:
|
|
222
|
+
covered, total = report.coverage
|
|
223
|
+
coverage_pct = (covered / total * 100) if total else 100.0
|
|
224
|
+
typer.echo(f"Model: {report.model.__module__}.{report.model.__name__}")
|
|
225
|
+
typer.echo(f" Coverage: {covered}/{total} fields ({coverage_pct:.0f}%)")
|
|
226
|
+
if report.issues:
|
|
227
|
+
typer.echo(" Issues:")
|
|
228
|
+
for issue in report.issues:
|
|
229
|
+
typer.echo(f" - {issue}")
|
|
230
|
+
else:
|
|
231
|
+
typer.echo(" Issues: none")
|
|
232
|
+
typer.echo("")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
__all__ = ["app"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Grouping for generation-related CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .explain import app as explain_app
|
|
8
|
+
from .fixtures import register as register_fixtures
|
|
9
|
+
from .json import register as register_json
|
|
10
|
+
from .schema import register as register_schema
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Generate data artifacts from Pydantic models.")
|
|
13
|
+
|
|
14
|
+
register_json(app)
|
|
15
|
+
register_schema(app)
|
|
16
|
+
register_fixtures(app)
|
|
17
|
+
app.add_typer(
|
|
18
|
+
explain_app,
|
|
19
|
+
name="explain",
|
|
20
|
+
help="Explain generation strategy per model field.",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = ["app"]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Shared helpers for generation CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import importlib.util
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import ModuleType
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
from pydantic_fixturegen.core.errors import PFGError
|
|
18
|
+
from pydantic_fixturegen.core.introspect import (
|
|
19
|
+
IntrospectedModel,
|
|
20
|
+
IntrospectionResult,
|
|
21
|
+
discover,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"JSON_ERRORS_OPTION",
|
|
26
|
+
"clear_module_cache",
|
|
27
|
+
"discover_models",
|
|
28
|
+
"load_model_class",
|
|
29
|
+
"render_cli_error",
|
|
30
|
+
"split_patterns",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_module_cache: dict[str, ModuleType] = {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
JSON_ERRORS_OPTION = typer.Option(
|
|
38
|
+
False,
|
|
39
|
+
"--json-errors",
|
|
40
|
+
help="Emit structured JSON errors to stdout.",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def clear_module_cache() -> None:
|
|
45
|
+
"""Clear cached module imports used during CLI execution."""
|
|
46
|
+
|
|
47
|
+
_module_cache.clear()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def split_patterns(raw: str | None) -> list[str]:
|
|
51
|
+
if not raw:
|
|
52
|
+
return []
|
|
53
|
+
return [part.strip() for part in raw.split(",") if part.strip()]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
DiscoveryMethod = Literal["ast", "import", "hybrid"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def discover_models(
|
|
60
|
+
path: Path,
|
|
61
|
+
*,
|
|
62
|
+
include: Sequence[str] | None = None,
|
|
63
|
+
exclude: Sequence[str] | None = None,
|
|
64
|
+
method: DiscoveryMethod = "import",
|
|
65
|
+
timeout: float = 5.0,
|
|
66
|
+
memory_limit_mb: int = 256,
|
|
67
|
+
) -> IntrospectionResult:
|
|
68
|
+
return discover(
|
|
69
|
+
[path],
|
|
70
|
+
method=method,
|
|
71
|
+
include=list(include or ()),
|
|
72
|
+
exclude=list(exclude or ()),
|
|
73
|
+
public_only=False,
|
|
74
|
+
safe_import_timeout=timeout,
|
|
75
|
+
safe_import_memory_limit_mb=memory_limit_mb,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_model_class(model_info: IntrospectedModel) -> type[BaseModel]:
|
|
80
|
+
module = _load_module(model_info.module, Path(model_info.locator))
|
|
81
|
+
attr = getattr(module, model_info.name, None)
|
|
82
|
+
if not isinstance(attr, type) or not issubclass(attr, BaseModel):
|
|
83
|
+
raise RuntimeError(
|
|
84
|
+
f"Attribute {model_info.name!r} in module "
|
|
85
|
+
f"{module.__name__} is not a Pydantic BaseModel."
|
|
86
|
+
)
|
|
87
|
+
return attr
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def render_cli_error(error: PFGError, *, json_errors: bool) -> None:
|
|
91
|
+
if json_errors:
|
|
92
|
+
payload = {"error": error.to_payload()}
|
|
93
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
94
|
+
else:
|
|
95
|
+
typer.secho(f"{error.kind}: {error}", err=True, fg=typer.colors.RED)
|
|
96
|
+
if error.hint:
|
|
97
|
+
typer.secho(f"hint: {error.hint}", err=True, fg=typer.colors.YELLOW)
|
|
98
|
+
raise typer.Exit(code=int(error.code))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _load_module(module_name: str, locator: Path) -> ModuleType:
|
|
102
|
+
module = _module_cache.get(module_name)
|
|
103
|
+
if module is not None:
|
|
104
|
+
return module
|
|
105
|
+
|
|
106
|
+
existing = sys.modules.get(module_name)
|
|
107
|
+
if existing is not None:
|
|
108
|
+
existing_path = getattr(existing, "__file__", None)
|
|
109
|
+
if existing_path and Path(existing_path).resolve() == locator.resolve():
|
|
110
|
+
_module_cache[module_name] = existing
|
|
111
|
+
return existing
|
|
112
|
+
|
|
113
|
+
return _import_module_by_path(module_name, locator)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _import_module_by_path(module_name: str, path: Path) -> ModuleType:
|
|
117
|
+
if not path.exists():
|
|
118
|
+
raise RuntimeError(f"Could not locate module source at {path}.")
|
|
119
|
+
|
|
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)
|
|
123
|
+
|
|
124
|
+
unique_name = module_name
|
|
125
|
+
if module_name in sys.modules:
|
|
126
|
+
unique_name = f"{module_name}_pfg_{len(_module_cache)}"
|
|
127
|
+
|
|
128
|
+
spec = importlib.util.spec_from_file_location(unique_name, path)
|
|
129
|
+
if spec is None or spec.loader is None:
|
|
130
|
+
raise RuntimeError(f"Failed to load module from {path}.")
|
|
131
|
+
|
|
132
|
+
module = importlib.util.module_from_spec(spec)
|
|
133
|
+
try:
|
|
134
|
+
spec.loader.exec_module(module)
|
|
135
|
+
except Exception as exc: # pragma: no cover - surface to caller
|
|
136
|
+
raise RuntimeError(f"Error importing module {path}: {exc}") from exc
|
|
137
|
+
|
|
138
|
+
_module_cache[module_name] = module
|
|
139
|
+
return module
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""CLI command to explain strategies for models and fields."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from pydantic_fixturegen.core.errors import DiscoveryError, PFGError
|
|
11
|
+
from pydantic_fixturegen.core.providers import create_default_registry
|
|
12
|
+
from pydantic_fixturegen.core.schema import summarize_model_fields
|
|
13
|
+
from pydantic_fixturegen.core.strategies import StrategyBuilder, StrategyResult, UnionStrategy
|
|
14
|
+
from pydantic_fixturegen.plugins.loader import get_plugin_manager
|
|
15
|
+
|
|
16
|
+
from ._common import (
|
|
17
|
+
JSON_ERRORS_OPTION,
|
|
18
|
+
clear_module_cache,
|
|
19
|
+
discover_models,
|
|
20
|
+
load_model_class,
|
|
21
|
+
render_cli_error,
|
|
22
|
+
split_patterns,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
PATH_ARGUMENT = typer.Argument(..., help="Path to a Python module containing Pydantic models.")
|
|
26
|
+
|
|
27
|
+
INCLUDE_OPTION = typer.Option(
|
|
28
|
+
None,
|
|
29
|
+
"--include",
|
|
30
|
+
"-i",
|
|
31
|
+
help="Comma-separated pattern(s) of fully-qualified model names to include.",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
EXCLUDE_OPTION = typer.Option(
|
|
35
|
+
None,
|
|
36
|
+
"--exclude",
|
|
37
|
+
"-e",
|
|
38
|
+
help="Comma-separated pattern(s) of fully-qualified model names to exclude.",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
app = typer.Typer(invoke_without_command=True, subcommand_metavar="")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def explain( # noqa: D401 - Typer callback
|
|
46
|
+
ctx: typer.Context,
|
|
47
|
+
path: str = PATH_ARGUMENT,
|
|
48
|
+
include: str | None = INCLUDE_OPTION,
|
|
49
|
+
exclude: str | None = EXCLUDE_OPTION,
|
|
50
|
+
json_errors: bool = JSON_ERRORS_OPTION,
|
|
51
|
+
) -> None:
|
|
52
|
+
_ = ctx # unused
|
|
53
|
+
try:
|
|
54
|
+
_execute_explain(
|
|
55
|
+
target=path,
|
|
56
|
+
include=include,
|
|
57
|
+
exclude=exclude,
|
|
58
|
+
)
|
|
59
|
+
except PFGError as exc:
|
|
60
|
+
render_cli_error(exc, json_errors=json_errors)
|
|
61
|
+
except ValueError as exc:
|
|
62
|
+
render_cli_error(DiscoveryError(str(exc)), json_errors=json_errors)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
app.callback(invoke_without_command=True)(explain)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _execute_explain(*, target: str, include: str | None, exclude: str | None) -> None:
|
|
69
|
+
path = Path(target)
|
|
70
|
+
if not path.exists():
|
|
71
|
+
raise ValueError(f"Target path '{target}' does not exist.")
|
|
72
|
+
if not path.is_file():
|
|
73
|
+
raise ValueError("Target must be a Python module file.")
|
|
74
|
+
|
|
75
|
+
clear_module_cache()
|
|
76
|
+
|
|
77
|
+
discovery = discover_models(
|
|
78
|
+
path,
|
|
79
|
+
include=split_patterns(include),
|
|
80
|
+
exclude=split_patterns(exclude),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if discovery.errors:
|
|
84
|
+
raise ValueError("; ".join(discovery.errors))
|
|
85
|
+
|
|
86
|
+
for warning in discovery.warnings:
|
|
87
|
+
if warning.strip():
|
|
88
|
+
typer.secho(f"warning: {warning.strip()}", err=True, fg=typer.colors.YELLOW)
|
|
89
|
+
|
|
90
|
+
if not discovery.models:
|
|
91
|
+
typer.echo("No models discovered.")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
registry = create_default_registry(load_plugins=True)
|
|
95
|
+
builder = StrategyBuilder(registry, plugin_manager=get_plugin_manager())
|
|
96
|
+
|
|
97
|
+
for model_info in discovery.models:
|
|
98
|
+
model_cls = load_model_class(model_info)
|
|
99
|
+
_render_model_report(model_cls, builder)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _render_model_report(model: type[BaseModel], builder: StrategyBuilder) -> None:
|
|
103
|
+
typer.echo(f"Model: {model.__module__}.{model.__name__}")
|
|
104
|
+
summaries = summarize_model_fields(model)
|
|
105
|
+
strategies = {}
|
|
106
|
+
field_failures: dict[str, str] = {}
|
|
107
|
+
for name, model_field in model.model_fields.items():
|
|
108
|
+
summary = summaries[name]
|
|
109
|
+
try:
|
|
110
|
+
strategies[name] = builder.build_field_strategy(
|
|
111
|
+
model,
|
|
112
|
+
name,
|
|
113
|
+
model_field.annotation,
|
|
114
|
+
summary,
|
|
115
|
+
)
|
|
116
|
+
except ValueError as exc:
|
|
117
|
+
field_failures[name] = str(exc)
|
|
118
|
+
|
|
119
|
+
for field_name, strategy in strategies.items():
|
|
120
|
+
summary = summaries[field_name]
|
|
121
|
+
typer.echo(f" Field: {field_name}")
|
|
122
|
+
typer.echo(f" Type: {summary.type}")
|
|
123
|
+
_render_strategy(strategy, indent=" ")
|
|
124
|
+
for field_name, message in field_failures.items():
|
|
125
|
+
typer.echo(f" Field: {field_name}")
|
|
126
|
+
typer.echo(" Type: unknown")
|
|
127
|
+
typer.echo(f" Issue: {message}")
|
|
128
|
+
typer.echo("")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _render_strategy(strategy: StrategyResult, indent: str) -> None:
|
|
132
|
+
if isinstance(strategy, UnionStrategy):
|
|
133
|
+
typer.echo(f"{indent}Union policy: {strategy.policy}")
|
|
134
|
+
for idx, choice in enumerate(strategy.choices, start=1):
|
|
135
|
+
typer.echo(f"{indent} Option {idx} -> type: {choice.summary.type}")
|
|
136
|
+
_render_strategy(choice, indent=f"{indent} ")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
typer.echo(f"{indent}Provider: {strategy.provider_name}")
|
|
140
|
+
if strategy.enum_values:
|
|
141
|
+
typer.echo(f"{indent}Enum values: {strategy.enum_values}")
|
|
142
|
+
typer.echo(f"{indent}p_none: {strategy.p_none}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
__all__ = ["app"]
|