pydantic-fixturegen 1.0.0__py3-none-any.whl → 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pydantic-fixturegen might be problematic. Click here for more details.

Files changed (39) hide show
  1. pydantic_fixturegen/api/__init__.py +137 -0
  2. pydantic_fixturegen/api/_runtime.py +726 -0
  3. pydantic_fixturegen/api/models.py +73 -0
  4. pydantic_fixturegen/cli/__init__.py +32 -1
  5. pydantic_fixturegen/cli/check.py +230 -0
  6. pydantic_fixturegen/cli/diff.py +992 -0
  7. pydantic_fixturegen/cli/doctor.py +188 -35
  8. pydantic_fixturegen/cli/gen/_common.py +134 -7
  9. pydantic_fixturegen/cli/gen/explain.py +597 -40
  10. pydantic_fixturegen/cli/gen/fixtures.py +244 -112
  11. pydantic_fixturegen/cli/gen/json.py +229 -138
  12. pydantic_fixturegen/cli/gen/schema.py +170 -85
  13. pydantic_fixturegen/cli/init.py +333 -0
  14. pydantic_fixturegen/cli/schema.py +45 -0
  15. pydantic_fixturegen/cli/watch.py +126 -0
  16. pydantic_fixturegen/core/config.py +137 -3
  17. pydantic_fixturegen/core/config_schema.py +178 -0
  18. pydantic_fixturegen/core/constraint_report.py +305 -0
  19. pydantic_fixturegen/core/errors.py +42 -0
  20. pydantic_fixturegen/core/field_policies.py +100 -0
  21. pydantic_fixturegen/core/generate.py +241 -37
  22. pydantic_fixturegen/core/io_utils.py +10 -2
  23. pydantic_fixturegen/core/path_template.py +197 -0
  24. pydantic_fixturegen/core/presets.py +73 -0
  25. pydantic_fixturegen/core/providers/temporal.py +10 -0
  26. pydantic_fixturegen/core/safe_import.py +146 -12
  27. pydantic_fixturegen/core/seed_freeze.py +176 -0
  28. pydantic_fixturegen/emitters/json_out.py +65 -16
  29. pydantic_fixturegen/emitters/pytest_codegen.py +68 -13
  30. pydantic_fixturegen/emitters/schema_out.py +27 -3
  31. pydantic_fixturegen/logging.py +114 -0
  32. pydantic_fixturegen/schemas/config.schema.json +244 -0
  33. pydantic_fixturegen-1.1.0.dist-info/METADATA +173 -0
  34. pydantic_fixturegen-1.1.0.dist-info/RECORD +57 -0
  35. pydantic_fixturegen-1.0.0.dist-info/METADATA +0 -280
  36. pydantic_fixturegen-1.0.0.dist-info/RECORD +0 -41
  37. {pydantic_fixturegen-1.0.0.dist-info → pydantic_fixturegen-1.1.0.dist-info}/WHEEL +0 -0
  38. {pydantic_fixturegen-1.0.0.dist-info → pydantic_fixturegen-1.1.0.dist-info}/entry_points.txt +0 -0
  39. {pydantic_fixturegen-1.0.0.dist-info → pydantic_fixturegen-1.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -3,24 +3,18 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
- from typing import Any
7
6
 
8
7
  import typer
9
8
 
10
- from pydantic_fixturegen.core.config import ConfigError, load_config
9
+ from pydantic_fixturegen.api._runtime import generate_schema_artifacts
10
+ from pydantic_fixturegen.api.models import SchemaGenerationResult
11
+ from pydantic_fixturegen.core.config import ConfigError
11
12
  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
- )
13
+ from pydantic_fixturegen.core.path_template import OutputTemplate
14
+
15
+ from ...logging import Logger, get_logger
16
+ from ..watch import gather_default_watch_paths, run_with_watch
17
+ from ._common import JSON_ERRORS_OPTION, render_cli_error
24
18
 
25
19
  TARGET_ARGUMENT = typer.Argument(
26
20
  ...,
@@ -55,6 +49,19 @@ EXCLUDE_OPTION = typer.Option(
55
49
  help="Comma-separated pattern(s) of fully-qualified model names to exclude.",
56
50
  )
57
51
 
52
+ WATCH_OPTION = typer.Option(
53
+ False,
54
+ "--watch",
55
+ help="Watch source files and regenerate when changes are detected.",
56
+ )
57
+
58
+ WATCH_DEBOUNCE_OPTION = typer.Option(
59
+ 0.5,
60
+ "--watch-debounce",
61
+ min=0.1,
62
+ help="Debounce interval in seconds for filesystem events.",
63
+ )
64
+
58
65
 
59
66
  def register(app: typer.Typer) -> None:
60
67
  @app.command("schema")
@@ -65,100 +72,178 @@ def register(app: typer.Typer) -> None:
65
72
  include: str | None = INCLUDE_OPTION,
66
73
  exclude: str | None = EXCLUDE_OPTION,
67
74
  json_errors: bool = JSON_ERRORS_OPTION,
75
+ watch: bool = WATCH_OPTION,
76
+ watch_debounce: float = WATCH_DEBOUNCE_OPTION,
68
77
  ) -> None:
78
+ logger = get_logger()
79
+
69
80
  try:
70
- _execute_schema_command(
71
- target=target,
72
- out=out,
73
- indent=indent,
74
- include=include,
75
- exclude=exclude,
76
- )
81
+ output_template = OutputTemplate(str(out))
77
82
  except PFGError as exc:
78
83
  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)
84
+ return
85
+
86
+ watch_output: Path | None = None
87
+ watch_extra: list[Path] | None = None
88
+ if output_template.has_dynamic_directories():
89
+ watch_extra = [output_template.watch_parent()]
90
+ else:
91
+ watch_output = output_template.preview_path()
92
+
93
+ def invoke(exit_app: bool) -> None:
94
+ try:
95
+ _execute_schema_command(
96
+ target=target,
97
+ output_template=output_template,
98
+ indent=indent,
99
+ include=include,
100
+ exclude=exclude,
101
+ )
102
+ except PFGError as exc:
103
+ render_cli_error(exc, json_errors=json_errors, exit_app=exit_app)
104
+ except ConfigError as exc:
105
+ render_cli_error(
106
+ DiscoveryError(str(exc)),
107
+ json_errors=json_errors,
108
+ exit_app=exit_app,
109
+ )
110
+ except Exception as exc: # pragma: no cover - defensive
111
+ render_cli_error(
112
+ EmitError(str(exc)),
113
+ json_errors=json_errors,
114
+ exit_app=exit_app,
115
+ )
116
+
117
+ if watch:
118
+ watch_paths = gather_default_watch_paths(
119
+ Path(target),
120
+ output=watch_output,
121
+ extra=watch_extra,
122
+ )
123
+ try:
124
+ logger.debug(
125
+ "Entering watch loop",
126
+ event="watch_loop_enter",
127
+ target=str(target),
128
+ output=str(watch_output or output_template.preview_path()),
129
+ debounce=watch_debounce,
130
+ )
131
+ run_with_watch(lambda: invoke(exit_app=False), watch_paths, debounce=watch_debounce)
132
+ except PFGError as exc:
133
+ render_cli_error(exc, json_errors=json_errors)
134
+ else:
135
+ invoke(exit_app=True)
83
136
 
84
137
 
85
138
  def _execute_schema_command(
86
139
  *,
87
140
  target: str,
88
- out: Path,
141
+ output_template: OutputTemplate,
89
142
  indent: int | None,
90
143
  include: str | None,
91
144
  exclude: str | None,
92
145
  ) -> 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
- )
146
+ logger = get_logger()
117
147
 
118
- if discovery.errors:
119
- raise DiscoveryError("; ".join(discovery.errors))
148
+ include_patterns = [include] if include else None
149
+ exclude_patterns = [exclude] if exclude else None
120
150
 
121
- for warning in discovery.warnings:
122
- if warning.strip():
123
- typer.secho(warning.strip(), err=True, fg=typer.colors.YELLOW)
151
+ try:
152
+ result = generate_schema_artifacts(
153
+ target=target,
154
+ output_template=output_template,
155
+ indent=indent,
156
+ include=include_patterns,
157
+ exclude=exclude_patterns,
158
+ logger=logger,
159
+ )
160
+ except PFGError as exc:
161
+ _handle_schema_error(logger, exc)
162
+ raise
163
+ except Exception as exc: # pragma: no cover - defensive
164
+ if isinstance(exc, ConfigError):
165
+ raise
166
+ raise EmitError(str(exc)) from exc
167
+
168
+ if _log_schema_snapshot(logger, result):
169
+ return
124
170
 
125
- if not discovery.models:
126
- raise DiscoveryError("No models discovered.")
171
+ typer.echo(str(result.path))
127
172
 
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
173
 
133
- indent_value = indent if indent is not None else app_config.json.indent
174
+ def _log_schema_snapshot(logger: Logger, result: SchemaGenerationResult) -> bool:
175
+ config_snapshot = result.config
176
+ anchor_iso = config_snapshot.time_anchor.isoformat() if config_snapshot.time_anchor else None
134
177
 
135
- context = EmitterContext(
136
- models=tuple(model_classes),
137
- output=out,
138
- parameters={"indent": indent_value},
178
+ logger.debug(
179
+ "Loaded configuration",
180
+ event="config_loaded",
181
+ include=list(config_snapshot.include),
182
+ exclude=list(config_snapshot.exclude),
183
+ time_anchor=anchor_iso,
139
184
  )
140
- if emit_artifact("schema", context):
141
- return
142
185
 
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,
186
+ if anchor_iso:
187
+ logger.info(
188
+ "Using temporal anchor",
189
+ event="temporal_anchor_set",
190
+ time_anchor=anchor_iso,
191
+ )
192
+
193
+ for warning in result.warnings:
194
+ logger.warn(
195
+ warning,
196
+ event="discovery_warning",
197
+ warning=warning,
198
+ )
199
+
200
+ if result.delegated:
201
+ logger.info(
202
+ "Schema generation handled by plugin",
203
+ event="schema_generation_delegated",
204
+ output=str(result.base_output),
205
+ time_anchor=anchor_iso,
206
+ )
207
+ return True
208
+
209
+ logger.info(
210
+ "Schema generation complete",
211
+ event="schema_generation_complete",
212
+ output=str(result.path),
213
+ models=[model.__name__ for model in result.models],
214
+ time_anchor=anchor_iso,
215
+ )
216
+ return False
217
+
218
+
219
+ def _handle_schema_error(logger: Logger, exc: PFGError) -> None:
220
+ details = getattr(exc, "details", {}) or {}
221
+ config_info = details.get("config")
222
+ anchor_iso = None
223
+ if isinstance(config_info, dict):
224
+ anchor_iso = config_info.get("time_anchor")
225
+ logger.debug(
226
+ "Loaded configuration",
227
+ event="config_loaded",
228
+ include=config_info.get("include", []),
229
+ exclude=config_info.get("exclude", []),
230
+ time_anchor=anchor_iso,
231
+ )
232
+ if anchor_iso:
233
+ logger.info(
234
+ "Using temporal anchor",
235
+ event="temporal_anchor_set",
236
+ time_anchor=anchor_iso,
157
237
  )
158
- except Exception as exc:
159
- raise EmitError(str(exc)) from exc
160
238
 
161
- typer.echo(str(emitted_path))
239
+ warnings = details.get("warnings") or []
240
+ for warning in warnings:
241
+ if isinstance(warning, str):
242
+ logger.warn(
243
+ warning,
244
+ event="discovery_warning",
245
+ warning=warning,
246
+ )
162
247
 
163
248
 
164
249
  __all__ = ["register"]
@@ -0,0 +1,333 @@
1
+ """CLI command for scaffolding configuration and directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import textwrap
6
+ from collections.abc import Iterable
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import typer
11
+
12
+ from pydantic_fixturegen.core.config import DEFAULT_CONFIG, ENUM_POLICIES, UNION_POLICIES
13
+ from pydantic_fixturegen.core.io_utils import WriteResult, write_atomic_text
14
+ from pydantic_fixturegen.core.seed import DEFAULT_LOCALE
15
+ from pydantic_fixturegen.core.version import get_tool_version
16
+
17
+ app = typer.Typer(help="Scaffold configuration and recommended directories.")
18
+
19
+ DEFAULT_SEED = 42
20
+ DEFAULT_UNION_POLICY = "weighted"
21
+ DEFAULT_ENUM_POLICY = "random"
22
+ DEFAULT_JSON_INDENT = 2
23
+ DEFAULT_JSON_ORJSON = False
24
+ DEFAULT_PYTEST_STYLE = DEFAULT_CONFIG.emitters.pytest.style
25
+ DEFAULT_PYTEST_SCOPE = "module"
26
+ DEFAULT_FIXTURE_DIR = Path("tests/fixtures")
27
+ PYTEST_STYLES = {"functions", "factory", "class"}
28
+ PYTEST_SCOPES = {"function", "module", "session"}
29
+
30
+
31
+ DIRECTORY_ARGUMENT = typer.Argument(
32
+ Path("."),
33
+ exists=False,
34
+ file_okay=False,
35
+ dir_okay=True,
36
+ resolve_path=True,
37
+ help="Project root to scaffold.",
38
+ )
39
+ PYPROJECT_OPTION = typer.Option(
40
+ True,
41
+ "--pyproject/--no-pyproject",
42
+ help="Write or update pyproject.toml with recommended configuration.",
43
+ )
44
+ YAML_OPTION = typer.Option(
45
+ False,
46
+ "--yaml/--no-yaml",
47
+ help="Also emit pydantic-fixturegen.yaml alongside pyproject.toml.",
48
+ )
49
+ YAML_PATH_OPTION = typer.Option(
50
+ None,
51
+ help="Custom path for YAML configuration (defaults to ./pydantic-fixturegen.yaml).",
52
+ )
53
+ FORCE_OPTION = typer.Option(False, "--force", help="Overwrite existing configuration blocks.")
54
+ SEED_OPTION = typer.Option(
55
+ DEFAULT_SEED,
56
+ help="Seed to set in generated configuration (use -1 to remove).",
57
+ )
58
+ LOCALE_OPTION = typer.Option(DEFAULT_LOCALE, help="Default Faker locale to configure.")
59
+ UNION_OPTION = typer.Option(
60
+ DEFAULT_UNION_POLICY,
61
+ help="Union resolution policy to configure.",
62
+ )
63
+ ENUM_OPTION = typer.Option(
64
+ DEFAULT_ENUM_POLICY,
65
+ help="Enum sampling policy to configure.",
66
+ )
67
+ JSON_INDENT_OPTION = typer.Option(
68
+ DEFAULT_JSON_INDENT,
69
+ min=0,
70
+ help="Default JSON indentation for emitters.",
71
+ )
72
+ JSON_ORJSON_OPTION = typer.Option(
73
+ DEFAULT_JSON_ORJSON,
74
+ "--json-orjson/--no-json-orjson",
75
+ help="Enable or disable orjson for JSON emitter.",
76
+ )
77
+ PYTEST_STYLE_OPTION = typer.Option(
78
+ DEFAULT_PYTEST_STYLE,
79
+ help="Default pytest fixture style.",
80
+ )
81
+ PYTEST_SCOPE_OPTION = typer.Option(
82
+ DEFAULT_PYTEST_SCOPE,
83
+ help="Default pytest fixture scope.",
84
+ )
85
+ FIXTURES_DIR_OPTION = typer.Option(
86
+ str(DEFAULT_FIXTURE_DIR),
87
+ help="Directory to create for generated pytest fixtures.",
88
+ )
89
+ GITKEEP_OPTION = typer.Option(
90
+ True,
91
+ "--gitkeep/--no-gitkeep",
92
+ help="Create an empty .gitkeep in the fixtures directory.",
93
+ )
94
+
95
+
96
+ @dataclass(slots=True)
97
+ class InitConfig:
98
+ seed: int | None
99
+ locale: str
100
+ union_policy: str
101
+ enum_policy: str
102
+ json_indent: int
103
+ json_orjson: bool
104
+ pytest_style: str
105
+ pytest_scope: str
106
+
107
+ def as_pyproject_snippet(self) -> str:
108
+ header = f"# Generated by pydantic-fixturegen {get_tool_version()} via `pfg init`"
109
+ lines: list[str] = [header, "", "[tool.pydantic_fixturegen]"]
110
+
111
+ if self.seed is not None:
112
+ lines.append(f"seed = {self.seed}")
113
+ lines.append(f'locale = "{self.locale}"')
114
+ lines.append(f'union_policy = "{self.union_policy}"')
115
+ lines.append(f'enum_policy = "{self.enum_policy}"')
116
+
117
+ lines.extend(
118
+ [
119
+ "",
120
+ "[tool.pydantic_fixturegen.json]",
121
+ f"indent = {self.json_indent}",
122
+ f"orjson = {'true' if self.json_orjson else 'false'}",
123
+ ]
124
+ )
125
+
126
+ lines.extend(
127
+ [
128
+ "",
129
+ "[tool.pydantic_fixturegen.emitters.pytest]",
130
+ f'style = "{self.pytest_style}"',
131
+ f'scope = "{self.pytest_scope}"',
132
+ ]
133
+ )
134
+
135
+ return "\n".join(lines).rstrip() + "\n"
136
+
137
+ def as_yaml(self) -> str:
138
+ content = textwrap.dedent(
139
+ f"""
140
+ seed: {self.seed if self.seed is not None else "null"}
141
+ locale: {self.locale}
142
+ union_policy: {self.union_policy}
143
+ enum_policy: {self.enum_policy}
144
+ json:
145
+ indent: {self.json_indent}
146
+ orjson: {"true" if self.json_orjson else "false"}
147
+ emitters:
148
+ pytest:
149
+ style: {self.pytest_style}
150
+ scope: {self.pytest_scope}
151
+ """
152
+ ).strip()
153
+ return content + "\n"
154
+
155
+
156
+ def _strip_pyproject_section(content: str) -> str:
157
+ lines = content.splitlines()
158
+ kept: list[str] = []
159
+ skip = False
160
+
161
+ for line in lines:
162
+ stripped = line.strip()
163
+ if stripped.startswith("[") and stripped.endswith("]"):
164
+ header = stripped.strip("[]").strip()
165
+ skip = header.startswith("tool.pydantic_fixturegen")
166
+ if skip:
167
+ continue
168
+ if not skip:
169
+ kept.append(line)
170
+
171
+ cleaned = "\n".join(kept)
172
+ while "\n\n\n" in cleaned:
173
+ cleaned = cleaned.replace("\n\n\n", "\n\n")
174
+ return cleaned.rstrip()
175
+
176
+
177
+ def _validate_choice(value: str, allowed: Iterable[str], name: str) -> str:
178
+ normalized = value.lower()
179
+ if normalized not in {item.lower() for item in allowed}:
180
+ choices = ", ".join(sorted(allowed))
181
+ raise typer.BadParameter(f"{name} must be one of: {choices}.")
182
+ return normalized
183
+
184
+
185
+ def _ensure_directory(path: Path) -> bool:
186
+ if path.exists() and not path.is_dir():
187
+ raise typer.BadParameter(f"Cannot create directory: {path} already exists as a file.")
188
+ if path.exists():
189
+ return False
190
+ path.mkdir(parents=True, exist_ok=True)
191
+ return True
192
+
193
+
194
+ def _format_relative(path: Path, root: Path) -> str:
195
+ try:
196
+ relative = path.relative_to(root)
197
+ except ValueError:
198
+ return str(path)
199
+ return relative.as_posix()
200
+
201
+
202
+ def _write_pyproject(root: Path, config: InitConfig, *, force: bool) -> WriteResult | None:
203
+ path = root / "pyproject.toml"
204
+ snippet = config.as_pyproject_snippet()
205
+
206
+ if path.exists():
207
+ if not path.is_file():
208
+ raise typer.BadParameter("pyproject.toml exists but is not a file.")
209
+ existing = path.read_text(encoding="utf-8")
210
+ if "tool.pydantic_fixturegen" in existing and not force:
211
+ return WriteResult(
212
+ path=path,
213
+ wrote=False,
214
+ skipped=True,
215
+ reason="pyproject already contains configuration",
216
+ )
217
+ if force:
218
+ existing = _strip_pyproject_section(existing)
219
+ merged = existing.rstrip()
220
+ new_content = f"{merged}\n\n{snippet}" if merged else snippet
221
+ else:
222
+ new_content = snippet
223
+
224
+ return write_atomic_text(path, new_content, hash_compare=True)
225
+
226
+
227
+ def _write_yaml(target_path: Path, config: InitConfig, *, force: bool) -> WriteResult | None:
228
+ if target_path.exists():
229
+ if not target_path.is_file():
230
+ raise typer.BadParameter(f"{target_path} exists but is not a file.")
231
+ if not force:
232
+ return WriteResult(
233
+ path=target_path,
234
+ wrote=False,
235
+ skipped=True,
236
+ reason="YAML configuration already exists",
237
+ )
238
+
239
+ content = config.as_yaml()
240
+ return write_atomic_text(target_path, content, hash_compare=True)
241
+
242
+
243
+ @app.command()
244
+ def init( # noqa: PLR0913 - CLI surfaces configuration knobs
245
+ directory: Path = DIRECTORY_ARGUMENT,
246
+ pyproject: bool = PYPROJECT_OPTION,
247
+ yaml_config: bool = YAML_OPTION,
248
+ yaml_path: Path | None = YAML_PATH_OPTION,
249
+ force: bool = FORCE_OPTION,
250
+ seed: int | None = SEED_OPTION,
251
+ locale: str = LOCALE_OPTION,
252
+ union_policy: str = UNION_OPTION,
253
+ enum_policy: str = ENUM_OPTION,
254
+ json_indent: int = JSON_INDENT_OPTION,
255
+ json_orjson: bool = JSON_ORJSON_OPTION,
256
+ pytest_style: str = PYTEST_STYLE_OPTION,
257
+ pytest_scope: str = PYTEST_SCOPE_OPTION,
258
+ fixtures_dir: str = FIXTURES_DIR_OPTION,
259
+ gitkeep: bool = GITKEEP_OPTION,
260
+ ) -> None:
261
+ """Create baseline configuration files and fixture directories."""
262
+
263
+ if not pyproject and not yaml_config:
264
+ typer.secho("Nothing to scaffold: enable --pyproject and/or --yaml.", fg=typer.colors.RED)
265
+ raise typer.Exit(code=1)
266
+
267
+ normalized_union = _validate_choice(union_policy, UNION_POLICIES, "union_policy")
268
+ normalized_enum = _validate_choice(enum_policy, ENUM_POLICIES, "enum_policy")
269
+ normalized_style = _validate_choice(pytest_style, PYTEST_STYLES, "pytest_style")
270
+ normalized_scope = _validate_choice(pytest_scope, PYTEST_SCOPES, "pytest_scope")
271
+
272
+ seed_value: int | None = None if seed is not None and seed < 0 else seed
273
+
274
+ config = InitConfig(
275
+ seed=seed_value,
276
+ locale=locale,
277
+ union_policy=normalized_union,
278
+ enum_policy=normalized_enum,
279
+ json_indent=json_indent,
280
+ json_orjson=json_orjson,
281
+ pytest_style=normalized_style,
282
+ pytest_scope=normalized_scope,
283
+ )
284
+
285
+ actions: list[str] = []
286
+
287
+ if pyproject:
288
+ result = _write_pyproject(directory, config, force=force)
289
+ if result:
290
+ if result.wrote:
291
+ actions.append(f"Updated {_format_relative(result.path, directory)}")
292
+ elif result.skipped:
293
+ actions.append(
294
+ f"Skipped {_format_relative(result.path, directory)} ({result.reason})"
295
+ )
296
+
297
+ if yaml_config:
298
+ if yaml_path and not yaml_path.is_absolute():
299
+ yaml_target = directory / yaml_path
300
+ elif yaml_path:
301
+ yaml_target = yaml_path
302
+ else:
303
+ yaml_target = directory / "pydantic-fixturegen.yaml"
304
+
305
+ result = _write_yaml(yaml_target, config, force=force)
306
+ if result:
307
+ if result.wrote:
308
+ actions.append(f"Updated {_format_relative(result.path, directory)}")
309
+ elif result.skipped:
310
+ actions.append(
311
+ f"Skipped {_format_relative(result.path, directory)} ({result.reason})"
312
+ )
313
+
314
+ fixtures_path = Path(fixtures_dir)
315
+ fixtures_directory = fixtures_path if fixtures_path.is_absolute() else directory / fixtures_path
316
+ created = _ensure_directory(fixtures_directory)
317
+ if created:
318
+ actions.append(f"Created directory {_format_relative(fixtures_directory, directory)}")
319
+
320
+ if gitkeep:
321
+ gitkeep_path = fixtures_directory / ".gitkeep"
322
+ result = write_atomic_text(gitkeep_path, "", hash_compare=True)
323
+ if result.wrote:
324
+ actions.append(f"Created {_format_relative(gitkeep_path, directory)}")
325
+ else:
326
+ actions.append(f"Ensured {_format_relative(gitkeep_path, directory)}")
327
+
328
+ if not actions:
329
+ typer.secho("No changes were necessary.", fg=typer.colors.YELLOW)
330
+ else:
331
+ typer.secho("Scaffolding complete:", fg=typer.colors.GREEN)
332
+ for action in actions:
333
+ typer.echo(f" - {action}")
@@ -0,0 +1,45 @@
1
+ """Schema utilities exposed via the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from pydantic_fixturegen.core.config_schema import get_config_schema_json
10
+
11
+ OUT_OPTION = typer.Option(None, "--out", "-o", help="File path to write the schema.")
12
+ PRETTY_OPTION = typer.Option(
13
+ True,
14
+ "--pretty/--compact",
15
+ help="Pretty-print output with indentation.",
16
+ )
17
+
18
+
19
+ app = typer.Typer(help="Inspect and export JSON Schemas.")
20
+
21
+
22
+ @app.callback()
23
+ def schema_root() -> None: # noqa: D401 - CLI callback
24
+ """Schema command group."""
25
+
26
+
27
+ @app.command("config")
28
+ def schema_config( # noqa: D401 - CLI command
29
+ out: Path | None = OUT_OPTION,
30
+ pretty: bool = PRETTY_OPTION,
31
+ ) -> None:
32
+ """Emit the JSON Schema that describes project configuration."""
33
+
34
+ indent = 2 if pretty else None
35
+ payload = get_config_schema_json(indent=indent)
36
+
37
+ if out is not None:
38
+ out.parent.mkdir(parents=True, exist_ok=True)
39
+ out.write_text(payload, encoding="utf-8")
40
+ typer.echo(f"Wrote {out}")
41
+ else:
42
+ typer.echo(payload.rstrip())
43
+
44
+
45
+ __all__ = ["app"]