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,283 @@
|
|
|
1
|
+
"""CLI command for emitting pytest fixtures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal, cast
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from pydantic_fixturegen.core.config import ConfigError, load_config
|
|
11
|
+
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
|
+
)
|
|
25
|
+
|
|
26
|
+
STYLE_CHOICES = {"functions", "factory", "class"}
|
|
27
|
+
SCOPE_CHOICES = {"function", "module", "session"}
|
|
28
|
+
RETURN_CHOICES = {"model", "dict"}
|
|
29
|
+
|
|
30
|
+
StyleLiteral = Literal["functions", "factory", "class"]
|
|
31
|
+
ReturnLiteral = Literal["model", "dict"]
|
|
32
|
+
DEFAULT_RETURN: ReturnLiteral = "model"
|
|
33
|
+
|
|
34
|
+
TARGET_ARGUMENT = typer.Argument(
|
|
35
|
+
...,
|
|
36
|
+
help="Path to a Python module containing Pydantic models.",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
OUT_OPTION = typer.Option(
|
|
40
|
+
...,
|
|
41
|
+
"--out",
|
|
42
|
+
"-o",
|
|
43
|
+
help="Output file path for generated fixtures.",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
STYLE_OPTION = typer.Option(
|
|
47
|
+
None,
|
|
48
|
+
"--style",
|
|
49
|
+
help="Fixture style (functions, factory, class).",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
SCOPE_OPTION = typer.Option(
|
|
53
|
+
None,
|
|
54
|
+
"--scope",
|
|
55
|
+
help="Fixture scope (function, module, session).",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
CASES_OPTION = typer.Option(
|
|
59
|
+
1,
|
|
60
|
+
"--cases",
|
|
61
|
+
min=1,
|
|
62
|
+
help="Number of cases per fixture (parametrization size).",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
RETURN_OPTION = typer.Option(
|
|
66
|
+
None,
|
|
67
|
+
"--return-type",
|
|
68
|
+
help="Return type: model or dict.",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
SEED_OPTION = typer.Option(
|
|
72
|
+
None,
|
|
73
|
+
"--seed",
|
|
74
|
+
help="Seed override for deterministic generation.",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
P_NONE_OPTION = typer.Option(
|
|
78
|
+
None,
|
|
79
|
+
"--p-none",
|
|
80
|
+
min=0.0,
|
|
81
|
+
max=1.0,
|
|
82
|
+
help="Probability of None for optional fields.",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
INCLUDE_OPTION = typer.Option(
|
|
86
|
+
None,
|
|
87
|
+
"--include",
|
|
88
|
+
"-i",
|
|
89
|
+
help="Comma-separated pattern(s) of fully-qualified model names to include.",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
EXCLUDE_OPTION = typer.Option(
|
|
93
|
+
None,
|
|
94
|
+
"--exclude",
|
|
95
|
+
"-e",
|
|
96
|
+
help="Comma-separated pattern(s) of fully-qualified model names to exclude.",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def register(app: typer.Typer) -> None:
|
|
101
|
+
@app.command("fixtures")
|
|
102
|
+
def gen_fixtures( # noqa: PLR0915 - CLI mirrors documented parameters
|
|
103
|
+
target: str = TARGET_ARGUMENT,
|
|
104
|
+
out: Path = OUT_OPTION,
|
|
105
|
+
style: str | None = STYLE_OPTION,
|
|
106
|
+
scope: str | None = SCOPE_OPTION,
|
|
107
|
+
cases: int = CASES_OPTION,
|
|
108
|
+
return_type: str | None = RETURN_OPTION,
|
|
109
|
+
seed: int | None = SEED_OPTION,
|
|
110
|
+
p_none: float | None = P_NONE_OPTION,
|
|
111
|
+
include: str | None = INCLUDE_OPTION,
|
|
112
|
+
exclude: str | None = EXCLUDE_OPTION,
|
|
113
|
+
json_errors: bool = JSON_ERRORS_OPTION,
|
|
114
|
+
) -> None:
|
|
115
|
+
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
|
+
)
|
|
128
|
+
except PFGError as exc:
|
|
129
|
+
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)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _execute_fixtures_command(
|
|
137
|
+
*,
|
|
138
|
+
target: str,
|
|
139
|
+
out: Path,
|
|
140
|
+
style: str | None,
|
|
141
|
+
scope: str | None,
|
|
142
|
+
cases: int,
|
|
143
|
+
return_type: str | None,
|
|
144
|
+
seed: int | None,
|
|
145
|
+
p_none: float | None,
|
|
146
|
+
include: str | None,
|
|
147
|
+
exclude: str | None,
|
|
148
|
+
) -> 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})
|
|
154
|
+
|
|
155
|
+
style_value = _coerce_style(style)
|
|
156
|
+
scope_value = _coerce_scope(scope)
|
|
157
|
+
return_type_value = _coerce_return_type(return_type)
|
|
158
|
+
|
|
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,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if discovery.errors:
|
|
188
|
+
raise DiscoveryError("; ".join(discovery.errors))
|
|
189
|
+
|
|
190
|
+
for warning in discovery.warnings:
|
|
191
|
+
if warning.strip():
|
|
192
|
+
typer.secho(warning.strip(), err=True, fg=typer.colors.YELLOW)
|
|
193
|
+
|
|
194
|
+
if not discovery.models:
|
|
195
|
+
raise DiscoveryError("No models discovered.")
|
|
196
|
+
|
|
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,
|
|
217
|
+
)
|
|
218
|
+
|
|
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
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
result = emit_pytest_fixtures(
|
|
234
|
+
model_classes,
|
|
235
|
+
output_path=out,
|
|
236
|
+
config=pytest_config,
|
|
237
|
+
)
|
|
238
|
+
except Exception as exc:
|
|
239
|
+
raise EmitError(str(exc)) from exc
|
|
240
|
+
|
|
241
|
+
message = str(out)
|
|
242
|
+
if result.skipped:
|
|
243
|
+
message += " (unchanged)"
|
|
244
|
+
typer.echo(message)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
__all__ = ["register"]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _coerce_style(value: str | None) -> StyleLiteral | None:
|
|
251
|
+
if value is None:
|
|
252
|
+
return None
|
|
253
|
+
lowered = value.strip().lower()
|
|
254
|
+
if lowered not in STYLE_CHOICES:
|
|
255
|
+
raise DiscoveryError(
|
|
256
|
+
f"Invalid style '{value}'.",
|
|
257
|
+
details={"style": value},
|
|
258
|
+
)
|
|
259
|
+
return cast(StyleLiteral, lowered)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _coerce_scope(value: str | None) -> str | None:
|
|
263
|
+
if value is None:
|
|
264
|
+
return None
|
|
265
|
+
lowered = value.strip().lower()
|
|
266
|
+
if lowered not in SCOPE_CHOICES:
|
|
267
|
+
raise DiscoveryError(
|
|
268
|
+
f"Invalid scope '{value}'.",
|
|
269
|
+
details={"scope": value},
|
|
270
|
+
)
|
|
271
|
+
return lowered
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _coerce_return_type(value: str | None) -> ReturnLiteral | None:
|
|
275
|
+
if value is None:
|
|
276
|
+
return None
|
|
277
|
+
lowered = value.strip().lower()
|
|
278
|
+
if lowered not in RETURN_CHOICES:
|
|
279
|
+
raise DiscoveryError(
|
|
280
|
+
f"Invalid return type '{value}'.",
|
|
281
|
+
details={"return_type": value},
|
|
282
|
+
)
|
|
283
|
+
return cast(ReturnLiteral, lowered)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""CLI command for generating JSON/JSONL samples."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from pydantic_fixturegen.core.config import AppConfig, ConfigError, load_config
|
|
12
|
+
from pydantic_fixturegen.core.errors import DiscoveryError, EmitError, MappingError, PFGError
|
|
13
|
+
from pydantic_fixturegen.core.generate import GenerationConfig, InstanceGenerator
|
|
14
|
+
from pydantic_fixturegen.core.seed import SeedManager
|
|
15
|
+
from pydantic_fixturegen.emitters.json_out import emit_json_samples
|
|
16
|
+
from pydantic_fixturegen.plugins.hookspecs import EmitterContext
|
|
17
|
+
from pydantic_fixturegen.plugins.loader import emit_artifact, load_entrypoint_plugins
|
|
18
|
+
|
|
19
|
+
from ._common import (
|
|
20
|
+
JSON_ERRORS_OPTION,
|
|
21
|
+
clear_module_cache,
|
|
22
|
+
discover_models,
|
|
23
|
+
load_model_class,
|
|
24
|
+
render_cli_error,
|
|
25
|
+
split_patterns,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
TARGET_ARGUMENT = typer.Argument(
|
|
29
|
+
...,
|
|
30
|
+
help="Path to a Python module containing Pydantic models.",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
OUT_OPTION = typer.Option(
|
|
34
|
+
...,
|
|
35
|
+
"--out",
|
|
36
|
+
"-o",
|
|
37
|
+
help="Output file path (single file or shard prefix).",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
COUNT_OPTION = typer.Option(
|
|
41
|
+
1,
|
|
42
|
+
"--n",
|
|
43
|
+
"-n",
|
|
44
|
+
min=1,
|
|
45
|
+
help="Number of samples to generate.",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
JSONL_OPTION = typer.Option(
|
|
49
|
+
False,
|
|
50
|
+
"--jsonl",
|
|
51
|
+
help="Emit newline-delimited JSON instead of a JSON array.",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
INDENT_OPTION = typer.Option(
|
|
55
|
+
None,
|
|
56
|
+
"--indent",
|
|
57
|
+
min=0,
|
|
58
|
+
help="Indentation level for JSON output (overrides config).",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
ORJSON_OPTION = typer.Option(
|
|
62
|
+
None,
|
|
63
|
+
"--orjson/--no-orjson",
|
|
64
|
+
help="Toggle orjson serialization (overrides config).",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
SHARD_OPTION = typer.Option(
|
|
68
|
+
None,
|
|
69
|
+
"--shard-size",
|
|
70
|
+
min=1,
|
|
71
|
+
help="Maximum number of records per shard (JSONL or JSON).",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
INCLUDE_OPTION = typer.Option(
|
|
75
|
+
None,
|
|
76
|
+
"--include",
|
|
77
|
+
"-i",
|
|
78
|
+
help="Comma-separated pattern(s) of fully-qualified model names to include.",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
EXCLUDE_OPTION = typer.Option(
|
|
82
|
+
None,
|
|
83
|
+
"--exclude",
|
|
84
|
+
"-e",
|
|
85
|
+
help="Comma-separated pattern(s) of fully-qualified model names to exclude.",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
SEED_OPTION = typer.Option(
|
|
89
|
+
None,
|
|
90
|
+
"--seed",
|
|
91
|
+
help="Seed override for deterministic generation.",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def register(app: typer.Typer) -> None:
|
|
96
|
+
@app.command("json")
|
|
97
|
+
def gen_json( # noqa: PLR0913 - CLI surface mirrors documented parameters
|
|
98
|
+
target: str = TARGET_ARGUMENT,
|
|
99
|
+
out: Path = OUT_OPTION,
|
|
100
|
+
count: int = COUNT_OPTION,
|
|
101
|
+
jsonl: bool = JSONL_OPTION,
|
|
102
|
+
indent: int | None = INDENT_OPTION,
|
|
103
|
+
use_orjson: bool | None = ORJSON_OPTION,
|
|
104
|
+
shard_size: int | None = SHARD_OPTION,
|
|
105
|
+
include: str | None = INCLUDE_OPTION,
|
|
106
|
+
exclude: str | None = EXCLUDE_OPTION,
|
|
107
|
+
seed: int | None = SEED_OPTION,
|
|
108
|
+
json_errors: bool = JSON_ERRORS_OPTION,
|
|
109
|
+
) -> None:
|
|
110
|
+
try:
|
|
111
|
+
_execute_json_command(
|
|
112
|
+
target=target,
|
|
113
|
+
out=out,
|
|
114
|
+
count=count,
|
|
115
|
+
jsonl=jsonl,
|
|
116
|
+
indent=indent,
|
|
117
|
+
use_orjson=use_orjson,
|
|
118
|
+
shard_size=shard_size,
|
|
119
|
+
include=include,
|
|
120
|
+
exclude=exclude,
|
|
121
|
+
seed=seed,
|
|
122
|
+
)
|
|
123
|
+
except PFGError as exc:
|
|
124
|
+
render_cli_error(exc, json_errors=json_errors)
|
|
125
|
+
except ConfigError as exc:
|
|
126
|
+
render_cli_error(DiscoveryError(str(exc)), json_errors=json_errors)
|
|
127
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
128
|
+
render_cli_error(EmitError(str(exc)), json_errors=json_errors)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _execute_json_command(
|
|
132
|
+
*,
|
|
133
|
+
target: str,
|
|
134
|
+
out: Path,
|
|
135
|
+
count: int,
|
|
136
|
+
jsonl: bool,
|
|
137
|
+
indent: int | None,
|
|
138
|
+
use_orjson: bool | None,
|
|
139
|
+
shard_size: int | None,
|
|
140
|
+
include: str | None,
|
|
141
|
+
exclude: str | None,
|
|
142
|
+
seed: int | None,
|
|
143
|
+
) -> None:
|
|
144
|
+
path = Path(target)
|
|
145
|
+
if not path.exists():
|
|
146
|
+
raise DiscoveryError(f"Target path '{target}' does not exist.", details={"path": target})
|
|
147
|
+
if not path.is_file():
|
|
148
|
+
raise DiscoveryError("Target must be a Python module file.", details={"path": target})
|
|
149
|
+
|
|
150
|
+
clear_module_cache()
|
|
151
|
+
load_entrypoint_plugins()
|
|
152
|
+
|
|
153
|
+
cli_overrides: dict[str, Any] = {}
|
|
154
|
+
if seed is not None:
|
|
155
|
+
cli_overrides["seed"] = seed
|
|
156
|
+
json_overrides: dict[str, Any] = {}
|
|
157
|
+
if indent is not None:
|
|
158
|
+
json_overrides["indent"] = indent
|
|
159
|
+
if use_orjson is not None:
|
|
160
|
+
json_overrides["orjson"] = use_orjson
|
|
161
|
+
if json_overrides:
|
|
162
|
+
cli_overrides["json"] = json_overrides
|
|
163
|
+
if include:
|
|
164
|
+
cli_overrides["include"] = split_patterns(include)
|
|
165
|
+
if exclude:
|
|
166
|
+
cli_overrides["exclude"] = split_patterns(exclude)
|
|
167
|
+
|
|
168
|
+
app_config = load_config(root=Path.cwd(), cli=cli_overrides if cli_overrides else None)
|
|
169
|
+
|
|
170
|
+
discovery = discover_models(
|
|
171
|
+
path,
|
|
172
|
+
include=app_config.include,
|
|
173
|
+
exclude=app_config.exclude,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if discovery.errors:
|
|
177
|
+
raise DiscoveryError("; ".join(discovery.errors))
|
|
178
|
+
|
|
179
|
+
for warning in discovery.warnings:
|
|
180
|
+
if warning.strip():
|
|
181
|
+
typer.secho(warning.strip(), err=True, fg=typer.colors.YELLOW)
|
|
182
|
+
|
|
183
|
+
if not discovery.models:
|
|
184
|
+
raise DiscoveryError("No models discovered.")
|
|
185
|
+
|
|
186
|
+
if len(discovery.models) > 1:
|
|
187
|
+
names = ", ".join(model.qualname for model in discovery.models)
|
|
188
|
+
raise DiscoveryError(
|
|
189
|
+
f"Multiple models discovered ({names}). Use --include/--exclude to narrow selection.",
|
|
190
|
+
details={"models": names},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
target_model = discovery.models[0]
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
model_cls = load_model_class(target_model)
|
|
197
|
+
except RuntimeError as exc:
|
|
198
|
+
raise DiscoveryError(str(exc)) from exc
|
|
199
|
+
|
|
200
|
+
generator = _build_instance_generator(app_config)
|
|
201
|
+
|
|
202
|
+
def sample_factory() -> BaseModel:
|
|
203
|
+
instance = generator.generate_one(model_cls)
|
|
204
|
+
if instance is None:
|
|
205
|
+
raise MappingError(
|
|
206
|
+
f"Failed to generate instance for {target_model.qualname}.",
|
|
207
|
+
details={"model": target_model.qualname},
|
|
208
|
+
)
|
|
209
|
+
return instance
|
|
210
|
+
|
|
211
|
+
indent_value = indent if indent is not None else app_config.json.indent
|
|
212
|
+
use_orjson_value = use_orjson if use_orjson is not None else app_config.json.orjson
|
|
213
|
+
|
|
214
|
+
context = EmitterContext(
|
|
215
|
+
models=(model_cls,),
|
|
216
|
+
output=out,
|
|
217
|
+
parameters={
|
|
218
|
+
"count": count,
|
|
219
|
+
"jsonl": jsonl,
|
|
220
|
+
"indent": indent_value,
|
|
221
|
+
"shard_size": shard_size,
|
|
222
|
+
"use_orjson": use_orjson_value,
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
if emit_artifact("json", context):
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
paths = emit_json_samples(
|
|
230
|
+
sample_factory,
|
|
231
|
+
output_path=out,
|
|
232
|
+
count=count,
|
|
233
|
+
jsonl=jsonl,
|
|
234
|
+
indent=indent_value,
|
|
235
|
+
shard_size=shard_size,
|
|
236
|
+
use_orjson=use_orjson_value,
|
|
237
|
+
ensure_ascii=False,
|
|
238
|
+
)
|
|
239
|
+
except RuntimeError as exc:
|
|
240
|
+
raise EmitError(str(exc)) from exc
|
|
241
|
+
|
|
242
|
+
for emitted_path in paths:
|
|
243
|
+
typer.echo(str(emitted_path))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _build_instance_generator(app_config: AppConfig) -> InstanceGenerator:
|
|
247
|
+
seed_value: int | None = None
|
|
248
|
+
if app_config.seed is not None:
|
|
249
|
+
seed_value = SeedManager(seed=app_config.seed).normalized_seed
|
|
250
|
+
|
|
251
|
+
p_none = app_config.p_none if app_config.p_none is not None else 0.0
|
|
252
|
+
gen_config = GenerationConfig(
|
|
253
|
+
seed=seed_value,
|
|
254
|
+
enum_policy=app_config.enum_policy,
|
|
255
|
+
union_policy=app_config.union_policy,
|
|
256
|
+
default_p_none=p_none,
|
|
257
|
+
optional_p_none=p_none,
|
|
258
|
+
)
|
|
259
|
+
return InstanceGenerator(config=gen_config)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
__all__ = ["register"]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""CLI command for emitting JSON schema files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from pydantic_fixturegen.core.config import ConfigError, load_config
|
|
11
|
+
from pydantic_fixturegen.core.errors import DiscoveryError, EmitError, PFGError
|
|
12
|
+
from pydantic_fixturegen.emitters.schema_out import emit_model_schema, emit_models_schema
|
|
13
|
+
from pydantic_fixturegen.plugins.hookspecs import EmitterContext
|
|
14
|
+
from pydantic_fixturegen.plugins.loader import emit_artifact, load_entrypoint_plugins
|
|
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
|
+
TARGET_ARGUMENT = typer.Argument(
|
|
26
|
+
...,
|
|
27
|
+
help="Path to a Python module containing Pydantic models.",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
OUT_OPTION = typer.Option(
|
|
31
|
+
...,
|
|
32
|
+
"--out",
|
|
33
|
+
"-o",
|
|
34
|
+
help="Output file path for the generated schema.",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
INDENT_OPTION = typer.Option(
|
|
38
|
+
None,
|
|
39
|
+
"--indent",
|
|
40
|
+
min=0,
|
|
41
|
+
help="Indentation level for JSON output (overrides config).",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
INCLUDE_OPTION = typer.Option(
|
|
45
|
+
None,
|
|
46
|
+
"--include",
|
|
47
|
+
"-i",
|
|
48
|
+
help="Comma-separated pattern(s) of fully-qualified model names to include.",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
EXCLUDE_OPTION = typer.Option(
|
|
52
|
+
None,
|
|
53
|
+
"--exclude",
|
|
54
|
+
"-e",
|
|
55
|
+
help="Comma-separated pattern(s) of fully-qualified model names to exclude.",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def register(app: typer.Typer) -> None:
|
|
60
|
+
@app.command("schema")
|
|
61
|
+
def gen_schema( # noqa: PLR0913
|
|
62
|
+
target: str = TARGET_ARGUMENT,
|
|
63
|
+
out: Path = OUT_OPTION,
|
|
64
|
+
indent: int | None = INDENT_OPTION,
|
|
65
|
+
include: str | None = INCLUDE_OPTION,
|
|
66
|
+
exclude: str | None = EXCLUDE_OPTION,
|
|
67
|
+
json_errors: bool = JSON_ERRORS_OPTION,
|
|
68
|
+
) -> None:
|
|
69
|
+
try:
|
|
70
|
+
_execute_schema_command(
|
|
71
|
+
target=target,
|
|
72
|
+
out=out,
|
|
73
|
+
indent=indent,
|
|
74
|
+
include=include,
|
|
75
|
+
exclude=exclude,
|
|
76
|
+
)
|
|
77
|
+
except PFGError as exc:
|
|
78
|
+
render_cli_error(exc, json_errors=json_errors)
|
|
79
|
+
except ConfigError as exc:
|
|
80
|
+
render_cli_error(DiscoveryError(str(exc)), json_errors=json_errors)
|
|
81
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
82
|
+
render_cli_error(EmitError(str(exc)), json_errors=json_errors)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _execute_schema_command(
|
|
86
|
+
*,
|
|
87
|
+
target: str,
|
|
88
|
+
out: Path,
|
|
89
|
+
indent: int | None,
|
|
90
|
+
include: str | None,
|
|
91
|
+
exclude: str | None,
|
|
92
|
+
) -> None:
|
|
93
|
+
path = Path(target)
|
|
94
|
+
if not path.exists():
|
|
95
|
+
raise DiscoveryError(f"Target path '{target}' does not exist.", details={"path": target})
|
|
96
|
+
if not path.is_file():
|
|
97
|
+
raise DiscoveryError("Target must be a Python module file.", details={"path": target})
|
|
98
|
+
|
|
99
|
+
clear_module_cache()
|
|
100
|
+
load_entrypoint_plugins()
|
|
101
|
+
|
|
102
|
+
cli_overrides: dict[str, Any] = {}
|
|
103
|
+
if indent is not None:
|
|
104
|
+
cli_overrides.setdefault("json", {})["indent"] = indent
|
|
105
|
+
if include:
|
|
106
|
+
cli_overrides["include"] = split_patterns(include)
|
|
107
|
+
if exclude:
|
|
108
|
+
cli_overrides["exclude"] = split_patterns(exclude)
|
|
109
|
+
|
|
110
|
+
app_config = load_config(root=Path.cwd(), cli=cli_overrides if cli_overrides else None)
|
|
111
|
+
|
|
112
|
+
discovery = discover_models(
|
|
113
|
+
path,
|
|
114
|
+
include=app_config.include,
|
|
115
|
+
exclude=app_config.exclude,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if discovery.errors:
|
|
119
|
+
raise DiscoveryError("; ".join(discovery.errors))
|
|
120
|
+
|
|
121
|
+
for warning in discovery.warnings:
|
|
122
|
+
if warning.strip():
|
|
123
|
+
typer.secho(warning.strip(), err=True, fg=typer.colors.YELLOW)
|
|
124
|
+
|
|
125
|
+
if not discovery.models:
|
|
126
|
+
raise DiscoveryError("No models discovered.")
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
model_classes = [load_model_class(model) for model in discovery.models]
|
|
130
|
+
except RuntimeError as exc:
|
|
131
|
+
raise DiscoveryError(str(exc)) from exc
|
|
132
|
+
|
|
133
|
+
indent_value = indent if indent is not None else app_config.json.indent
|
|
134
|
+
|
|
135
|
+
context = EmitterContext(
|
|
136
|
+
models=tuple(model_classes),
|
|
137
|
+
output=out,
|
|
138
|
+
parameters={"indent": indent_value},
|
|
139
|
+
)
|
|
140
|
+
if emit_artifact("schema", context):
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
if len(model_classes) == 1:
|
|
145
|
+
emitted_path = emit_model_schema(
|
|
146
|
+
model_classes[0],
|
|
147
|
+
output_path=out,
|
|
148
|
+
indent=indent_value,
|
|
149
|
+
ensure_ascii=False,
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
emitted_path = emit_models_schema(
|
|
153
|
+
model_classes,
|
|
154
|
+
output_path=out,
|
|
155
|
+
indent=indent_value,
|
|
156
|
+
ensure_ascii=False,
|
|
157
|
+
)
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
raise EmitError(str(exc)) from exc
|
|
160
|
+
|
|
161
|
+
typer.echo(str(emitted_path))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
__all__ = ["register"]
|