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.

Files changed (41) hide show
  1. pydantic_fixturegen/__init__.py +7 -0
  2. pydantic_fixturegen/cli/__init__.py +85 -0
  3. pydantic_fixturegen/cli/doctor.py +235 -0
  4. pydantic_fixturegen/cli/gen/__init__.py +23 -0
  5. pydantic_fixturegen/cli/gen/_common.py +139 -0
  6. pydantic_fixturegen/cli/gen/explain.py +145 -0
  7. pydantic_fixturegen/cli/gen/fixtures.py +283 -0
  8. pydantic_fixturegen/cli/gen/json.py +262 -0
  9. pydantic_fixturegen/cli/gen/schema.py +164 -0
  10. pydantic_fixturegen/cli/list.py +164 -0
  11. pydantic_fixturegen/core/__init__.py +103 -0
  12. pydantic_fixturegen/core/ast_discover.py +169 -0
  13. pydantic_fixturegen/core/config.py +440 -0
  14. pydantic_fixturegen/core/errors.py +136 -0
  15. pydantic_fixturegen/core/generate.py +311 -0
  16. pydantic_fixturegen/core/introspect.py +141 -0
  17. pydantic_fixturegen/core/io_utils.py +77 -0
  18. pydantic_fixturegen/core/providers/__init__.py +32 -0
  19. pydantic_fixturegen/core/providers/collections.py +74 -0
  20. pydantic_fixturegen/core/providers/identifiers.py +68 -0
  21. pydantic_fixturegen/core/providers/numbers.py +133 -0
  22. pydantic_fixturegen/core/providers/registry.py +98 -0
  23. pydantic_fixturegen/core/providers/strings.py +109 -0
  24. pydantic_fixturegen/core/providers/temporal.py +42 -0
  25. pydantic_fixturegen/core/safe_import.py +403 -0
  26. pydantic_fixturegen/core/schema.py +320 -0
  27. pydantic_fixturegen/core/seed.py +154 -0
  28. pydantic_fixturegen/core/strategies.py +193 -0
  29. pydantic_fixturegen/core/version.py +52 -0
  30. pydantic_fixturegen/emitters/__init__.py +15 -0
  31. pydantic_fixturegen/emitters/json_out.py +373 -0
  32. pydantic_fixturegen/emitters/pytest_codegen.py +365 -0
  33. pydantic_fixturegen/emitters/schema_out.py +84 -0
  34. pydantic_fixturegen/plugins/builtin.py +45 -0
  35. pydantic_fixturegen/plugins/hookspecs.py +59 -0
  36. pydantic_fixturegen/plugins/loader.py +72 -0
  37. pydantic_fixturegen-1.0.0.dist-info/METADATA +280 -0
  38. pydantic_fixturegen-1.0.0.dist-info/RECORD +41 -0
  39. pydantic_fixturegen-1.0.0.dist-info/WHEEL +4 -0
  40. pydantic_fixturegen-1.0.0.dist-info/entry_points.txt +5 -0
  41. 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"]