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,164 @@
|
|
|
1
|
+
"""CLI command for listing Pydantic models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from pydantic_fixturegen.core.errors import DiscoveryError, PFGError, UnsafeImportError
|
|
10
|
+
from pydantic_fixturegen.core.introspect import DiscoveryMethod, IntrospectionResult, discover
|
|
11
|
+
|
|
12
|
+
from .gen._common import JSON_ERRORS_OPTION, render_cli_error, split_patterns
|
|
13
|
+
|
|
14
|
+
PATH_ARGUMENT = typer.Argument(
|
|
15
|
+
...,
|
|
16
|
+
help="Python module file to inspect.",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
INCLUDE_OPTION = typer.Option(
|
|
20
|
+
None,
|
|
21
|
+
"--include",
|
|
22
|
+
"-i",
|
|
23
|
+
help="Comma-separated glob pattern(s) of fully-qualified model names to include.",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
EXCLUDE_OPTION = typer.Option(
|
|
27
|
+
None,
|
|
28
|
+
"--exclude",
|
|
29
|
+
"-e",
|
|
30
|
+
help="Comma-separated glob pattern(s) of fully-qualified model names to exclude.",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
PUBLIC_OPTION = typer.Option(
|
|
34
|
+
False,
|
|
35
|
+
"--public-only",
|
|
36
|
+
help="Only list public models (respects __all__).",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
AST_OPTION = typer.Option(
|
|
40
|
+
False,
|
|
41
|
+
"--ast",
|
|
42
|
+
help="Use AST discovery only (no imports executed).",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
HYBRID_OPTION = typer.Option(
|
|
46
|
+
False,
|
|
47
|
+
"--hybrid",
|
|
48
|
+
help="Combine AST and safe import discovery.",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
TIMEOUT_OPTION = typer.Option(
|
|
52
|
+
5.0,
|
|
53
|
+
"--timeout",
|
|
54
|
+
min=0.1,
|
|
55
|
+
help="Timeout in seconds for safe import execution.",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
MEMORY_LIMIT_OPTION = typer.Option(
|
|
59
|
+
256,
|
|
60
|
+
"--memory-limit-mb",
|
|
61
|
+
min=1,
|
|
62
|
+
help="Memory limit in megabytes for safe import subprocess.",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
app = typer.Typer(invoke_without_command=True, subcommand_metavar="")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_method(ast_mode: bool, hybrid_mode: bool) -> DiscoveryMethod:
|
|
70
|
+
if ast_mode and hybrid_mode:
|
|
71
|
+
raise DiscoveryError("Choose only one of --ast or --hybrid.")
|
|
72
|
+
if hybrid_mode:
|
|
73
|
+
return "hybrid"
|
|
74
|
+
if ast_mode:
|
|
75
|
+
return "ast"
|
|
76
|
+
return "import"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def list_models( # noqa: D401 - Typer callback
|
|
80
|
+
ctx: typer.Context,
|
|
81
|
+
path: str = PATH_ARGUMENT,
|
|
82
|
+
include: str | None = INCLUDE_OPTION,
|
|
83
|
+
exclude: str | None = EXCLUDE_OPTION,
|
|
84
|
+
public_only: bool = PUBLIC_OPTION,
|
|
85
|
+
ast_mode: bool = AST_OPTION,
|
|
86
|
+
hybrid_mode: bool = HYBRID_OPTION,
|
|
87
|
+
timeout: float = TIMEOUT_OPTION,
|
|
88
|
+
memory_limit_mb: int = MEMORY_LIMIT_OPTION,
|
|
89
|
+
json_errors: bool = JSON_ERRORS_OPTION,
|
|
90
|
+
) -> None:
|
|
91
|
+
_ = ctx # unused
|
|
92
|
+
try:
|
|
93
|
+
_execute_list_command(
|
|
94
|
+
target=path,
|
|
95
|
+
include=include,
|
|
96
|
+
exclude=exclude,
|
|
97
|
+
public_only=public_only,
|
|
98
|
+
ast_mode=ast_mode,
|
|
99
|
+
hybrid_mode=hybrid_mode,
|
|
100
|
+
timeout=timeout,
|
|
101
|
+
memory_limit_mb=memory_limit_mb,
|
|
102
|
+
)
|
|
103
|
+
except PFGError as exc:
|
|
104
|
+
render_cli_error(exc, json_errors=json_errors)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
app.callback(invoke_without_command=True)(list_models)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _execute_list_command(
|
|
111
|
+
*,
|
|
112
|
+
target: str,
|
|
113
|
+
include: str | None,
|
|
114
|
+
exclude: str | None,
|
|
115
|
+
public_only: bool,
|
|
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
|
+
method = _resolve_method(ast_mode, hybrid_mode)
|
|
128
|
+
|
|
129
|
+
result = discover(
|
|
130
|
+
[path],
|
|
131
|
+
method=method,
|
|
132
|
+
include=_split_patterns(include),
|
|
133
|
+
exclude=_split_patterns(exclude),
|
|
134
|
+
public_only=public_only,
|
|
135
|
+
safe_import_timeout=timeout,
|
|
136
|
+
safe_import_memory_limit_mb=memory_limit_mb,
|
|
137
|
+
)
|
|
138
|
+
_render_result(result)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _render_result(result: IntrospectionResult) -> None:
|
|
142
|
+
for warning in result.warnings:
|
|
143
|
+
if warning.strip():
|
|
144
|
+
typer.secho(f"warning: {warning.strip()}", err=True, fg=typer.colors.YELLOW)
|
|
145
|
+
|
|
146
|
+
if result.errors:
|
|
147
|
+
message = "; ".join(result.errors)
|
|
148
|
+
if any("network" in error.lower() for error in result.errors):
|
|
149
|
+
raise UnsafeImportError(message)
|
|
150
|
+
raise DiscoveryError(message)
|
|
151
|
+
|
|
152
|
+
if not result.models:
|
|
153
|
+
typer.echo("No models discovered.")
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
for model in result.models:
|
|
157
|
+
typer.echo(f"{model.qualname} [{model.discovery}]")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _split_patterns(option_value: str | None) -> list[str]:
|
|
161
|
+
return split_patterns(option_value)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
__all__ = ["app"]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Core utilities for pydantic-fixturegen."""
|
|
2
|
+
|
|
3
|
+
from .ast_discover import AstDiscoveryResult, AstModel, discover_models
|
|
4
|
+
from .config import (
|
|
5
|
+
AppConfig,
|
|
6
|
+
ConfigError,
|
|
7
|
+
EmittersConfig,
|
|
8
|
+
JsonConfig,
|
|
9
|
+
PytestEmitterConfig,
|
|
10
|
+
load_config,
|
|
11
|
+
)
|
|
12
|
+
from .errors import (
|
|
13
|
+
DiscoveryError,
|
|
14
|
+
EmitError,
|
|
15
|
+
ErrorCode,
|
|
16
|
+
MappingError,
|
|
17
|
+
PFGError,
|
|
18
|
+
UnsafeImportError,
|
|
19
|
+
)
|
|
20
|
+
from .generate import GenerationConfig, InstanceGenerator
|
|
21
|
+
from .introspect import IntrospectedModel, IntrospectionResult
|
|
22
|
+
from .introspect import discover as introspect
|
|
23
|
+
from .io_utils import WriteResult, write_atomic_bytes, write_atomic_text
|
|
24
|
+
from .providers import (
|
|
25
|
+
ProviderRef,
|
|
26
|
+
ProviderRegistry,
|
|
27
|
+
create_default_registry,
|
|
28
|
+
register_collection_providers,
|
|
29
|
+
register_identifier_providers,
|
|
30
|
+
register_numeric_providers,
|
|
31
|
+
register_string_providers,
|
|
32
|
+
register_temporal_providers,
|
|
33
|
+
)
|
|
34
|
+
from .providers.collections import generate_collection
|
|
35
|
+
from .providers.identifiers import generate_identifier
|
|
36
|
+
from .providers.numbers import generate_numeric
|
|
37
|
+
from .providers.strings import generate_string
|
|
38
|
+
from .providers.temporal import generate_temporal
|
|
39
|
+
from .safe_import import SafeImportResult, safe_import_models
|
|
40
|
+
from .schema import (
|
|
41
|
+
FieldConstraints,
|
|
42
|
+
FieldSummary,
|
|
43
|
+
extract_constraints,
|
|
44
|
+
extract_model_constraints,
|
|
45
|
+
summarize_field,
|
|
46
|
+
summarize_model_fields,
|
|
47
|
+
)
|
|
48
|
+
from .seed import SeedManager
|
|
49
|
+
from .strategies import Strategy, StrategyBuilder, UnionStrategy
|
|
50
|
+
from .version import build_artifact_header, get_tool_version
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"AppConfig",
|
|
54
|
+
"AstDiscoveryResult",
|
|
55
|
+
"AstModel",
|
|
56
|
+
"ConfigError",
|
|
57
|
+
"EmittersConfig",
|
|
58
|
+
"JsonConfig",
|
|
59
|
+
"PytestEmitterConfig",
|
|
60
|
+
"SafeImportResult",
|
|
61
|
+
"SeedManager",
|
|
62
|
+
"WriteResult",
|
|
63
|
+
"FieldConstraints",
|
|
64
|
+
"FieldSummary",
|
|
65
|
+
"ProviderRef",
|
|
66
|
+
"ProviderRegistry",
|
|
67
|
+
"Strategy",
|
|
68
|
+
"StrategyBuilder",
|
|
69
|
+
"UnionStrategy",
|
|
70
|
+
"create_default_registry",
|
|
71
|
+
"generate_collection",
|
|
72
|
+
"generate_identifier",
|
|
73
|
+
"generate_numeric",
|
|
74
|
+
"generate_string",
|
|
75
|
+
"generate_temporal",
|
|
76
|
+
"GenerationConfig",
|
|
77
|
+
"InstanceGenerator",
|
|
78
|
+
"register_string_providers",
|
|
79
|
+
"register_numeric_providers",
|
|
80
|
+
"register_collection_providers",
|
|
81
|
+
"register_identifier_providers",
|
|
82
|
+
"register_temporal_providers",
|
|
83
|
+
"build_artifact_header",
|
|
84
|
+
"discover_models",
|
|
85
|
+
"introspect",
|
|
86
|
+
"IntrospectedModel",
|
|
87
|
+
"IntrospectionResult",
|
|
88
|
+
"get_tool_version",
|
|
89
|
+
"load_config",
|
|
90
|
+
"extract_constraints",
|
|
91
|
+
"extract_model_constraints",
|
|
92
|
+
"summarize_field",
|
|
93
|
+
"summarize_model_fields",
|
|
94
|
+
"safe_import_models",
|
|
95
|
+
"write_atomic_text",
|
|
96
|
+
"write_atomic_bytes",
|
|
97
|
+
"PFGError",
|
|
98
|
+
"DiscoveryError",
|
|
99
|
+
"MappingError",
|
|
100
|
+
"EmitError",
|
|
101
|
+
"UnsafeImportError",
|
|
102
|
+
"ErrorCode",
|
|
103
|
+
]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""AST-based discovery of Pydantic model classes without executing modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class AstModel:
|
|
13
|
+
module: str
|
|
14
|
+
name: str
|
|
15
|
+
qualname: str
|
|
16
|
+
path: Path
|
|
17
|
+
lineno: int
|
|
18
|
+
is_public: bool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class AstDiscoveryResult:
|
|
23
|
+
models: list[AstModel]
|
|
24
|
+
warnings: list[str]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
PYDANTIC_BASES = {
|
|
28
|
+
("pydantic", "BaseModel"),
|
|
29
|
+
("pydantic", "RootModel"),
|
|
30
|
+
("pydantic.v1", "BaseModel"),
|
|
31
|
+
("pydantic.v1", "RootModel"),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def discover_models(
|
|
36
|
+
paths: Iterable[Path | str],
|
|
37
|
+
*,
|
|
38
|
+
infer_module: bool = False,
|
|
39
|
+
public_only: bool = False,
|
|
40
|
+
) -> AstDiscoveryResult:
|
|
41
|
+
"""Discover Pydantic models from the given Python source files using AST parsing."""
|
|
42
|
+
models: list[AstModel] = []
|
|
43
|
+
warnings: list[str] = []
|
|
44
|
+
|
|
45
|
+
for raw_path in paths:
|
|
46
|
+
path = Path(raw_path)
|
|
47
|
+
try:
|
|
48
|
+
source = path.read_text(encoding="utf-8")
|
|
49
|
+
except OSError as exc:
|
|
50
|
+
warnings.append(f"Failed to read {path}: {exc}")
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
tree = ast.parse(source, filename=str(path))
|
|
55
|
+
except SyntaxError as exc:
|
|
56
|
+
warnings.append(f"Failed to parse {path}: {exc}")
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
resolver = _ImportResolver()
|
|
60
|
+
resolver.visit(tree)
|
|
61
|
+
|
|
62
|
+
module_name = path.stem if infer_module else "unknown"
|
|
63
|
+
public_names = _extract_public_names(tree)
|
|
64
|
+
|
|
65
|
+
for class_node in [node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)]:
|
|
66
|
+
if not _is_pydantic_model(class_node, resolver):
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
if public_names is not None:
|
|
70
|
+
is_public = class_node.name in public_names
|
|
71
|
+
else:
|
|
72
|
+
is_public = not class_node.name.startswith("_")
|
|
73
|
+
if public_only and not is_public:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
model = AstModel(
|
|
77
|
+
module=module_name,
|
|
78
|
+
name=class_node.name,
|
|
79
|
+
qualname=f"{module_name}.{class_node.name}",
|
|
80
|
+
path=path,
|
|
81
|
+
lineno=class_node.lineno,
|
|
82
|
+
is_public=is_public,
|
|
83
|
+
)
|
|
84
|
+
models.append(model)
|
|
85
|
+
|
|
86
|
+
models.sort(key=lambda m: (m.module, m.name))
|
|
87
|
+
return AstDiscoveryResult(models=models, warnings=warnings)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _ImportResolver(ast.NodeVisitor):
|
|
91
|
+
def __init__(self) -> None:
|
|
92
|
+
self.aliases: dict[str, tuple[str, str]] = {}
|
|
93
|
+
|
|
94
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
95
|
+
for alias in node.names:
|
|
96
|
+
name = alias.asname or alias.name
|
|
97
|
+
base_name = alias.name.split(".")[0]
|
|
98
|
+
for module, _ in PYDANTIC_BASES:
|
|
99
|
+
if base_name == module:
|
|
100
|
+
self.aliases[name] = (module, "*")
|
|
101
|
+
|
|
102
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
103
|
+
module = node.module
|
|
104
|
+
if module is None:
|
|
105
|
+
return
|
|
106
|
+
base_module = module.split(".")[0]
|
|
107
|
+
for alias in node.names:
|
|
108
|
+
target_name = alias.asname or alias.name
|
|
109
|
+
qual = alias.name
|
|
110
|
+
if module in {"pydantic", "pydantic.v1"} and qual in {"BaseModel", "RootModel"}:
|
|
111
|
+
self.aliases[target_name] = (module, qual)
|
|
112
|
+
elif base_module in {"pydantic", "pydantic.v1"}:
|
|
113
|
+
self.aliases[target_name] = (base_module, "*")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _extract_public_names(tree: ast.AST) -> set[str] | None:
|
|
117
|
+
for node in tree.body if isinstance(tree, ast.Module) else []:
|
|
118
|
+
if isinstance(node, ast.Assign):
|
|
119
|
+
for target in node.targets:
|
|
120
|
+
if (
|
|
121
|
+
isinstance(target, ast.Name)
|
|
122
|
+
and target.id == "__all__"
|
|
123
|
+
and isinstance(node.value, (ast.List, ast.Tuple))
|
|
124
|
+
):
|
|
125
|
+
names: set[str] = set()
|
|
126
|
+
for element in node.value.elts:
|
|
127
|
+
if isinstance(element, ast.Constant) and isinstance(element.value, str):
|
|
128
|
+
names.add(element.value)
|
|
129
|
+
return names
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _is_pydantic_model(node: ast.ClassDef, resolver: _ImportResolver) -> bool:
|
|
134
|
+
for base in node.bases:
|
|
135
|
+
module_class = _resolve_base(base, resolver)
|
|
136
|
+
if module_class is None:
|
|
137
|
+
continue
|
|
138
|
+
module, class_name = module_class
|
|
139
|
+
if (module, class_name) in PYDANTIC_BASES:
|
|
140
|
+
return True
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _resolve_base(base: ast.expr, resolver: _ImportResolver) -> tuple[str, str] | None:
|
|
145
|
+
if isinstance(base, ast.Name):
|
|
146
|
+
return resolver.aliases.get(base.id)
|
|
147
|
+
if isinstance(base, ast.Attribute):
|
|
148
|
+
parts = _flatten_attribute(base)
|
|
149
|
+
if len(parts) >= 2:
|
|
150
|
+
module_candidate = parts[0]
|
|
151
|
+
class_candidate = parts[-1]
|
|
152
|
+
alias = resolver.aliases.get(module_candidate)
|
|
153
|
+
if alias:
|
|
154
|
+
module, _ = alias
|
|
155
|
+
return (module, class_candidate)
|
|
156
|
+
if module_candidate in {"pydantic", "pydantic.v1"}:
|
|
157
|
+
return (module_candidate, class_candidate)
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _flatten_attribute(attr: ast.Attribute) -> list[str]:
|
|
162
|
+
parts: list[str] = []
|
|
163
|
+
current: ast.AST | None = attr
|
|
164
|
+
while isinstance(current, ast.Attribute):
|
|
165
|
+
parts.insert(0, current.attr)
|
|
166
|
+
current = current.value
|
|
167
|
+
if isinstance(current, ast.Name):
|
|
168
|
+
parts.insert(0, current.id)
|
|
169
|
+
return parts
|