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.
- pydantic_fixturegen/api/__init__.py +137 -0
- pydantic_fixturegen/api/_runtime.py +726 -0
- pydantic_fixturegen/api/models.py +73 -0
- pydantic_fixturegen/cli/__init__.py +32 -1
- pydantic_fixturegen/cli/check.py +230 -0
- pydantic_fixturegen/cli/diff.py +992 -0
- pydantic_fixturegen/cli/doctor.py +188 -35
- pydantic_fixturegen/cli/gen/_common.py +134 -7
- pydantic_fixturegen/cli/gen/explain.py +597 -40
- pydantic_fixturegen/cli/gen/fixtures.py +244 -112
- pydantic_fixturegen/cli/gen/json.py +229 -138
- pydantic_fixturegen/cli/gen/schema.py +170 -85
- pydantic_fixturegen/cli/init.py +333 -0
- pydantic_fixturegen/cli/schema.py +45 -0
- pydantic_fixturegen/cli/watch.py +126 -0
- pydantic_fixturegen/core/config.py +137 -3
- pydantic_fixturegen/core/config_schema.py +178 -0
- pydantic_fixturegen/core/constraint_report.py +305 -0
- pydantic_fixturegen/core/errors.py +42 -0
- pydantic_fixturegen/core/field_policies.py +100 -0
- pydantic_fixturegen/core/generate.py +241 -37
- pydantic_fixturegen/core/io_utils.py +10 -2
- pydantic_fixturegen/core/path_template.py +197 -0
- pydantic_fixturegen/core/presets.py +73 -0
- pydantic_fixturegen/core/providers/temporal.py +10 -0
- pydantic_fixturegen/core/safe_import.py +146 -12
- pydantic_fixturegen/core/seed_freeze.py +176 -0
- pydantic_fixturegen/emitters/json_out.py +65 -16
- pydantic_fixturegen/emitters/pytest_codegen.py +68 -13
- pydantic_fixturegen/emitters/schema_out.py +27 -3
- pydantic_fixturegen/logging.py +114 -0
- pydantic_fixturegen/schemas/config.schema.json +244 -0
- pydantic_fixturegen-1.1.0.dist-info/METADATA +173 -0
- pydantic_fixturegen-1.1.0.dist-info/RECORD +57 -0
- pydantic_fixturegen-1.0.0.dist-info/METADATA +0 -280
- pydantic_fixturegen-1.0.0.dist-info/RECORD +0 -41
- {pydantic_fixturegen-1.0.0.dist-info → pydantic_fixturegen-1.1.0.dist-info}/WHEEL +0 -0
- {pydantic_fixturegen-1.0.0.dist-info → pydantic_fixturegen-1.1.0.dist-info}/entry_points.txt +0 -0
- {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.
|
|
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.
|
|
13
|
-
|
|
14
|
-
from
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
141
|
+
output_template: OutputTemplate,
|
|
89
142
|
indent: int | None,
|
|
90
143
|
include: str | None,
|
|
91
144
|
exclude: str | None,
|
|
92
145
|
) -> None:
|
|
93
|
-
|
|
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
|
|
119
|
-
|
|
148
|
+
include_patterns = [include] if include else None
|
|
149
|
+
exclude_patterns = [exclude] if exclude else None
|
|
120
150
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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"]
|