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
|
@@ -2,14 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import dataclasses
|
|
6
|
+
import decimal
|
|
7
|
+
import enum
|
|
8
|
+
import json
|
|
9
|
+
import types
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
5
12
|
from pathlib import Path
|
|
13
|
+
from typing import Annotated, Any, Union, get_args, get_origin, get_type_hints
|
|
6
14
|
|
|
7
15
|
import typer
|
|
8
16
|
from pydantic import BaseModel
|
|
9
17
|
|
|
10
18
|
from pydantic_fixturegen.core.errors import DiscoveryError, PFGError
|
|
11
19
|
from pydantic_fixturegen.core.providers import create_default_registry
|
|
12
|
-
from pydantic_fixturegen.core.schema import summarize_model_fields
|
|
20
|
+
from pydantic_fixturegen.core.schema import FieldSummary, summarize_model_fields
|
|
13
21
|
from pydantic_fixturegen.core.strategies import StrategyBuilder, StrategyResult, UnionStrategy
|
|
14
22
|
from pydantic_fixturegen.plugins.loader import get_plugin_manager
|
|
15
23
|
|
|
@@ -38,6 +46,31 @@ EXCLUDE_OPTION = typer.Option(
|
|
|
38
46
|
help="Comma-separated pattern(s) of fully-qualified model names to exclude.",
|
|
39
47
|
)
|
|
40
48
|
|
|
49
|
+
JSON_OUTPUT_OPTION = typer.Option(
|
|
50
|
+
False,
|
|
51
|
+
"--json",
|
|
52
|
+
help="Emit a structured JSON description of strategies instead of formatted text.",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
TREE_OPTION = typer.Option(
|
|
56
|
+
False,
|
|
57
|
+
"--tree",
|
|
58
|
+
help="Render an ASCII tree illustrating strategy composition.",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
MAX_DEPTH_OPTION = typer.Option(
|
|
62
|
+
None,
|
|
63
|
+
"--max-depth",
|
|
64
|
+
min=0,
|
|
65
|
+
help="Limit nested strategy expansion depth (0 shows only top-level strategies).",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(slots=True)
|
|
70
|
+
class TreeNode:
|
|
71
|
+
label: str
|
|
72
|
+
children: list[TreeNode]
|
|
73
|
+
|
|
41
74
|
|
|
42
75
|
app = typer.Typer(invoke_without_command=True, subcommand_metavar="")
|
|
43
76
|
|
|
@@ -47,25 +80,60 @@ def explain( # noqa: D401 - Typer callback
|
|
|
47
80
|
path: str = PATH_ARGUMENT,
|
|
48
81
|
include: str | None = INCLUDE_OPTION,
|
|
49
82
|
exclude: str | None = EXCLUDE_OPTION,
|
|
83
|
+
json_output: bool = JSON_OUTPUT_OPTION,
|
|
84
|
+
tree_mode: bool = TREE_OPTION,
|
|
85
|
+
max_depth: int | None = MAX_DEPTH_OPTION,
|
|
50
86
|
json_errors: bool = JSON_ERRORS_OPTION,
|
|
51
87
|
) -> None:
|
|
52
88
|
_ = ctx # unused
|
|
89
|
+
if json_output and tree_mode:
|
|
90
|
+
render_cli_error(
|
|
91
|
+
DiscoveryError("--json and --tree cannot be combined."),
|
|
92
|
+
json_errors=json_errors,
|
|
93
|
+
)
|
|
94
|
+
return
|
|
95
|
+
|
|
53
96
|
try:
|
|
54
|
-
_execute_explain(
|
|
97
|
+
data = _execute_explain(
|
|
55
98
|
target=path,
|
|
56
99
|
include=include,
|
|
57
100
|
exclude=exclude,
|
|
101
|
+
max_depth=max_depth,
|
|
102
|
+
emit_warnings=not json_output,
|
|
58
103
|
)
|
|
59
104
|
except PFGError as exc:
|
|
60
105
|
render_cli_error(exc, json_errors=json_errors)
|
|
106
|
+
return
|
|
61
107
|
except ValueError as exc:
|
|
62
108
|
render_cli_error(DiscoveryError(str(exc)), json_errors=json_errors)
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
if json_output:
|
|
112
|
+
payload = {"warnings": data["warnings"], "models": data["models"]}
|
|
113
|
+
typer.echo(json.dumps(payload, indent=2, default=str))
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
if not data["models"]:
|
|
117
|
+
# Warnings and "No models discovered." already emitted when requested.
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
if tree_mode:
|
|
121
|
+
_render_tree(data["models"])
|
|
122
|
+
else:
|
|
123
|
+
_render_text_reports(data["models"])
|
|
63
124
|
|
|
64
125
|
|
|
65
126
|
app.callback(invoke_without_command=True)(explain)
|
|
66
127
|
|
|
67
128
|
|
|
68
|
-
def _execute_explain(
|
|
129
|
+
def _execute_explain(
|
|
130
|
+
*,
|
|
131
|
+
target: str,
|
|
132
|
+
include: str | None,
|
|
133
|
+
exclude: str | None,
|
|
134
|
+
max_depth: int | None = None,
|
|
135
|
+
emit_warnings: bool = True,
|
|
136
|
+
) -> dict[str, Any]:
|
|
69
137
|
path = Path(target)
|
|
70
138
|
if not path.exists():
|
|
71
139
|
raise ValueError(f"Target path '{target}' does not exist.")
|
|
@@ -83,63 +151,552 @@ def _execute_explain(*, target: str, include: str | None, exclude: str | None) -
|
|
|
83
151
|
if discovery.errors:
|
|
84
152
|
raise ValueError("; ".join(discovery.errors))
|
|
85
153
|
|
|
86
|
-
for warning in discovery.warnings
|
|
87
|
-
|
|
88
|
-
|
|
154
|
+
warnings = [warning.strip() for warning in discovery.warnings if warning.strip()]
|
|
155
|
+
if emit_warnings:
|
|
156
|
+
for warning in warnings:
|
|
157
|
+
typer.secho(f"warning: {warning}", err=True, fg=typer.colors.YELLOW)
|
|
89
158
|
|
|
90
159
|
if not discovery.models:
|
|
91
|
-
|
|
92
|
-
|
|
160
|
+
if emit_warnings:
|
|
161
|
+
typer.echo("No models discovered.")
|
|
162
|
+
return {"warnings": warnings, "models": []}
|
|
93
163
|
|
|
94
164
|
registry = create_default_registry(load_plugins=True)
|
|
95
165
|
builder = StrategyBuilder(registry, plugin_manager=get_plugin_manager())
|
|
166
|
+
depth_budget = None if max_depth is None else max_depth
|
|
96
167
|
|
|
168
|
+
reports: list[dict[str, Any]] = []
|
|
97
169
|
for model_info in discovery.models:
|
|
98
170
|
model_cls = load_model_class(model_info)
|
|
99
|
-
|
|
171
|
+
report = _collect_model_report(
|
|
172
|
+
model_cls,
|
|
173
|
+
builder=builder,
|
|
174
|
+
max_depth=depth_budget,
|
|
175
|
+
visited=set(),
|
|
176
|
+
)
|
|
177
|
+
reports.append(report)
|
|
100
178
|
|
|
179
|
+
return {"warnings": warnings, "models": reports}
|
|
101
180
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
181
|
+
|
|
182
|
+
def _collect_model_report(
|
|
183
|
+
model_cls: type[BaseModel],
|
|
184
|
+
*,
|
|
185
|
+
builder: StrategyBuilder,
|
|
186
|
+
max_depth: int | None,
|
|
187
|
+
visited: set[type[Any]],
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
qualname = _describe_type(model_cls)
|
|
190
|
+
report: dict[str, Any] = {
|
|
191
|
+
"kind": "model",
|
|
192
|
+
"name": model_cls.__name__,
|
|
193
|
+
"module": model_cls.__module__,
|
|
194
|
+
"qualname": qualname,
|
|
195
|
+
"fields": [],
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if model_cls in visited:
|
|
199
|
+
report["cycle"] = True
|
|
200
|
+
return report
|
|
201
|
+
|
|
202
|
+
visited.add(model_cls)
|
|
203
|
+
summaries = summarize_model_fields(model_cls)
|
|
204
|
+
|
|
205
|
+
for field_name, field in model_cls.model_fields.items():
|
|
206
|
+
summary = summaries[field_name]
|
|
207
|
+
field_report: dict[str, Any] = {
|
|
208
|
+
"name": field_name,
|
|
209
|
+
"summary": _summary_to_payload(summary),
|
|
210
|
+
}
|
|
109
211
|
try:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
212
|
+
strategy = builder.build_field_strategy(
|
|
213
|
+
model_cls,
|
|
214
|
+
field_name,
|
|
215
|
+
field.annotation,
|
|
114
216
|
summary,
|
|
115
217
|
)
|
|
116
218
|
except ValueError as exc:
|
|
117
|
-
|
|
219
|
+
field_report["error"] = str(exc)
|
|
220
|
+
else:
|
|
221
|
+
field_report["strategy"] = _strategy_to_payload(
|
|
222
|
+
strategy,
|
|
223
|
+
builder=builder,
|
|
224
|
+
remaining_depth=max_depth,
|
|
225
|
+
visited=visited,
|
|
226
|
+
)
|
|
227
|
+
report["fields"].append(field_report)
|
|
118
228
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
typer.echo(f" Field: {field_name}")
|
|
122
|
-
typer.echo(f" Type: {summary.type}")
|
|
123
|
-
_render_strategy(strategy, indent=" ")
|
|
124
|
-
for field_name, message in field_failures.items():
|
|
125
|
-
typer.echo(f" Field: {field_name}")
|
|
126
|
-
typer.echo(" Type: unknown")
|
|
127
|
-
typer.echo(f" Issue: {message}")
|
|
128
|
-
typer.echo("")
|
|
229
|
+
visited.remove(model_cls)
|
|
230
|
+
return report
|
|
129
231
|
|
|
130
232
|
|
|
131
|
-
def
|
|
233
|
+
def _strategy_to_payload(
|
|
234
|
+
strategy: StrategyResult,
|
|
235
|
+
*,
|
|
236
|
+
builder: StrategyBuilder,
|
|
237
|
+
remaining_depth: int | None,
|
|
238
|
+
visited: set[type[Any]],
|
|
239
|
+
) -> dict[str, Any]:
|
|
132
240
|
if isinstance(strategy, UnionStrategy):
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
241
|
+
payload: dict[str, Any] = {
|
|
242
|
+
"kind": "union",
|
|
243
|
+
"field": strategy.field_name,
|
|
244
|
+
"policy": strategy.policy,
|
|
245
|
+
"options": [],
|
|
246
|
+
}
|
|
247
|
+
if remaining_depth is not None and remaining_depth <= 0:
|
|
248
|
+
payload["truncated"] = True
|
|
249
|
+
return payload
|
|
250
|
+
|
|
251
|
+
next_depth = None if remaining_depth is None else remaining_depth - 1
|
|
252
|
+
for index, choice in enumerate(strategy.choices, start=1):
|
|
253
|
+
option_summary = _summary_to_payload(choice.summary)
|
|
254
|
+
option_payload: dict[str, Any] = {
|
|
255
|
+
"index": index,
|
|
256
|
+
"summary": option_summary,
|
|
257
|
+
"annotation": _describe_annotation(choice.annotation),
|
|
258
|
+
"strategy": _strategy_to_payload(
|
|
259
|
+
choice,
|
|
260
|
+
builder=builder,
|
|
261
|
+
remaining_depth=next_depth,
|
|
262
|
+
visited=visited,
|
|
263
|
+
),
|
|
264
|
+
}
|
|
265
|
+
payload["options"].append(option_payload)
|
|
266
|
+
return payload
|
|
267
|
+
|
|
268
|
+
provider_payload: dict[str, Any] = {
|
|
269
|
+
"kind": "provider",
|
|
270
|
+
"field": strategy.field_name,
|
|
271
|
+
"provider": strategy.provider_name,
|
|
272
|
+
"p_none": strategy.p_none,
|
|
273
|
+
"summary_type": strategy.summary.type,
|
|
274
|
+
"annotation": _describe_annotation(strategy.annotation),
|
|
275
|
+
}
|
|
276
|
+
if strategy.enum_values is not None:
|
|
277
|
+
provider_payload["enum_values"] = _safe_json(strategy.enum_values)
|
|
278
|
+
if strategy.enum_policy:
|
|
279
|
+
provider_payload["enum_policy"] = strategy.enum_policy
|
|
280
|
+
if strategy.provider_kwargs:
|
|
281
|
+
provider_payload["provider_kwargs"] = _safe_json(strategy.provider_kwargs)
|
|
282
|
+
|
|
283
|
+
if remaining_depth is not None and remaining_depth <= 0:
|
|
284
|
+
provider_payload["truncated"] = True
|
|
285
|
+
return provider_payload
|
|
286
|
+
|
|
287
|
+
next_depth = None if remaining_depth is None else remaining_depth - 1
|
|
288
|
+
summary_type = strategy.summary.type
|
|
289
|
+
annotation = strategy.annotation
|
|
290
|
+
if (
|
|
291
|
+
summary_type == "model"
|
|
292
|
+
and isinstance(annotation, type)
|
|
293
|
+
and issubclass(annotation, BaseModel)
|
|
294
|
+
):
|
|
295
|
+
provider_payload["nested_model"] = _collect_model_report(
|
|
296
|
+
annotation,
|
|
297
|
+
builder=builder,
|
|
298
|
+
max_depth=next_depth,
|
|
299
|
+
visited=visited,
|
|
300
|
+
)
|
|
301
|
+
elif summary_type == "dataclass" and isinstance(annotation, type):
|
|
302
|
+
provider_payload["nested_model"] = _collect_dataclass_report(
|
|
303
|
+
annotation,
|
|
304
|
+
builder=builder,
|
|
305
|
+
max_depth=next_depth,
|
|
306
|
+
visited=visited,
|
|
307
|
+
)
|
|
308
|
+
return provider_payload
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _collect_dataclass_report(
|
|
312
|
+
cls: type[Any],
|
|
313
|
+
*,
|
|
314
|
+
builder: StrategyBuilder,
|
|
315
|
+
max_depth: int | None,
|
|
316
|
+
visited: set[type[Any]],
|
|
317
|
+
) -> dict[str, Any]:
|
|
318
|
+
report: dict[str, Any] = {
|
|
319
|
+
"kind": "dataclass",
|
|
320
|
+
"qualname": _describe_type(cls),
|
|
321
|
+
"fields": [],
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if cls in visited:
|
|
325
|
+
report["cycle"] = True
|
|
326
|
+
return report
|
|
327
|
+
|
|
328
|
+
if not dataclasses.is_dataclass(cls):
|
|
329
|
+
report["unsupported"] = True
|
|
330
|
+
return report
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
dc_fields = dataclasses.fields(cls)
|
|
334
|
+
except TypeError:
|
|
335
|
+
report["unsupported"] = True
|
|
336
|
+
return report
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
type_hints = get_type_hints(cls, include_extras=True)
|
|
340
|
+
except (NameError, TypeError): # pragma: no cover - defensive
|
|
341
|
+
type_hints = {}
|
|
342
|
+
|
|
343
|
+
visited.add(cls)
|
|
344
|
+
for field in dc_fields:
|
|
345
|
+
resolved_type = type_hints.get(field.name, field.type)
|
|
346
|
+
annotation_desc = _describe_annotation(resolved_type)
|
|
347
|
+
summary: dict[str, Any] = {
|
|
348
|
+
"type": annotation_desc or "unknown",
|
|
349
|
+
"annotation": annotation_desc,
|
|
350
|
+
"origin": "dataclass",
|
|
351
|
+
}
|
|
352
|
+
if field.default is not dataclasses.MISSING:
|
|
353
|
+
summary["default"] = _safe_json(field.default)
|
|
354
|
+
else:
|
|
355
|
+
default_factory = getattr(field, "default_factory", dataclasses.MISSING)
|
|
356
|
+
if default_factory is not dataclasses.MISSING:
|
|
357
|
+
summary["default_factory"] = _describe_callable(default_factory)
|
|
358
|
+
|
|
359
|
+
field_entry: dict[str, Any] = {
|
|
360
|
+
"name": field.name,
|
|
361
|
+
"summary": summary,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if max_depth is not None and max_depth <= 0:
|
|
365
|
+
field_entry["truncated"] = True
|
|
366
|
+
else:
|
|
367
|
+
next_depth = None if max_depth is None else max_depth - 1
|
|
368
|
+
nested_type = _resolve_runtime_type(resolved_type)
|
|
369
|
+
if isinstance(nested_type, type) and nested_type not in visited:
|
|
370
|
+
if _is_subclass(nested_type, BaseModel):
|
|
371
|
+
field_entry["nested"] = _collect_model_report(
|
|
372
|
+
nested_type,
|
|
373
|
+
builder=builder,
|
|
374
|
+
max_depth=next_depth,
|
|
375
|
+
visited=visited,
|
|
376
|
+
)
|
|
377
|
+
elif dataclasses.is_dataclass(nested_type):
|
|
378
|
+
field_entry["nested"] = _collect_dataclass_report(
|
|
379
|
+
nested_type,
|
|
380
|
+
builder=builder,
|
|
381
|
+
max_depth=next_depth,
|
|
382
|
+
visited=visited,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
report["fields"].append(field_entry)
|
|
386
|
+
|
|
387
|
+
visited.remove(cls)
|
|
388
|
+
return report
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _summary_to_payload(summary: FieldSummary) -> dict[str, Any]:
|
|
392
|
+
payload: dict[str, Any] = {
|
|
393
|
+
"type": summary.type,
|
|
394
|
+
"is_optional": summary.is_optional,
|
|
395
|
+
}
|
|
396
|
+
if summary.format:
|
|
397
|
+
payload["format"] = summary.format
|
|
398
|
+
if summary.item_type:
|
|
399
|
+
payload["item_type"] = summary.item_type
|
|
400
|
+
if summary.enum_values is not None:
|
|
401
|
+
payload["enum_values"] = _safe_json(summary.enum_values)
|
|
402
|
+
annotation_desc = _describe_annotation(summary.annotation)
|
|
403
|
+
if annotation_desc is not None:
|
|
404
|
+
payload["annotation"] = annotation_desc
|
|
405
|
+
item_annotation_desc = _describe_annotation(summary.item_annotation)
|
|
406
|
+
if item_annotation_desc is not None:
|
|
407
|
+
payload["item_annotation"] = item_annotation_desc
|
|
408
|
+
constraints = _constraints_to_dict(summary)
|
|
409
|
+
if constraints:
|
|
410
|
+
payload["constraints"] = constraints
|
|
411
|
+
return payload
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _constraints_to_dict(summary: FieldSummary) -> dict[str, Any]:
|
|
415
|
+
if not summary.constraints.has_constraints():
|
|
416
|
+
return {}
|
|
417
|
+
constraints = summary.constraints
|
|
418
|
+
data: dict[str, Any] = {}
|
|
419
|
+
for key in (
|
|
420
|
+
"ge",
|
|
421
|
+
"le",
|
|
422
|
+
"gt",
|
|
423
|
+
"lt",
|
|
424
|
+
"multiple_of",
|
|
425
|
+
"min_length",
|
|
426
|
+
"max_length",
|
|
427
|
+
"pattern",
|
|
428
|
+
"max_digits",
|
|
429
|
+
"decimal_places",
|
|
430
|
+
):
|
|
431
|
+
value = getattr(constraints, key)
|
|
432
|
+
if value is not None:
|
|
433
|
+
data[key] = _safe_json(value)
|
|
434
|
+
return data
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _resolve_runtime_type(annotation: Any) -> type[Any] | None:
|
|
438
|
+
if isinstance(annotation, types.GenericAlias):
|
|
439
|
+
return None
|
|
440
|
+
if isinstance(annotation, type):
|
|
441
|
+
return annotation
|
|
442
|
+
|
|
443
|
+
origin = get_origin(annotation)
|
|
444
|
+
if origin is None:
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
if origin in {list, set, tuple, dict}:
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
if origin is Annotated:
|
|
451
|
+
args = get_args(annotation)
|
|
452
|
+
if args:
|
|
453
|
+
return _resolve_runtime_type(args[0])
|
|
454
|
+
|
|
455
|
+
if origin in {Union, types.UnionType}:
|
|
456
|
+
args = tuple(arg for arg in get_args(annotation) if arg is not type(None)) # noqa: E721
|
|
457
|
+
if len(args) == 1:
|
|
458
|
+
return _resolve_runtime_type(args[0])
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
return None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _describe_callable(factory: Any) -> str:
|
|
465
|
+
name = getattr(factory, "__qualname__", getattr(factory, "__name__", None))
|
|
466
|
+
module = getattr(factory, "__module__", None)
|
|
467
|
+
if name and module:
|
|
468
|
+
return f"{module}.{name}"
|
|
469
|
+
if name:
|
|
470
|
+
return str(name)
|
|
471
|
+
return repr(factory)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _safe_json(value: Any) -> Any:
|
|
475
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
476
|
+
return value
|
|
477
|
+
if isinstance(value, enum.Enum):
|
|
478
|
+
raw = value.value
|
|
479
|
+
if isinstance(raw, (str, int, float, bool)):
|
|
480
|
+
return raw
|
|
481
|
+
return str(raw)
|
|
482
|
+
if isinstance(value, decimal.Decimal):
|
|
483
|
+
try:
|
|
484
|
+
return float(value)
|
|
485
|
+
except (OverflowError, ValueError):
|
|
486
|
+
return str(value)
|
|
487
|
+
if isinstance(value, Mapping):
|
|
488
|
+
return {str(key): _safe_json(val) for key, val in value.items()}
|
|
489
|
+
if isinstance(value, (list, tuple, set)):
|
|
490
|
+
return [_safe_json(item) for item in value]
|
|
491
|
+
return str(value)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _describe_annotation(annotation: Any) -> str | None:
|
|
495
|
+
if annotation is None:
|
|
496
|
+
return None
|
|
497
|
+
if isinstance(annotation, type):
|
|
498
|
+
return _describe_type(annotation)
|
|
499
|
+
return repr(annotation)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _describe_type(tp: type[Any]) -> str:
|
|
503
|
+
return f"{tp.__module__}.{tp.__qualname__}"
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _is_subclass(candidate: type[Any], parent: type[Any]) -> bool:
|
|
507
|
+
try:
|
|
508
|
+
return issubclass(candidate, parent)
|
|
509
|
+
except TypeError:
|
|
510
|
+
return False
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _render_text_reports(models: list[dict[str, Any]]) -> None:
|
|
514
|
+
for model in models:
|
|
515
|
+
typer.echo(f"Model: {model['qualname']}")
|
|
516
|
+
fields = model.get("fields", [])
|
|
517
|
+
if not fields:
|
|
518
|
+
typer.echo(" (no fields)")
|
|
519
|
+
typer.echo("")
|
|
520
|
+
continue
|
|
521
|
+
for field in fields:
|
|
522
|
+
_render_field_text(field, indent=" ")
|
|
523
|
+
typer.echo("")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _render_field_text(field: dict[str, Any], *, indent: str) -> None:
|
|
527
|
+
summary = field.get("summary", {})
|
|
528
|
+
typer.echo(f"{indent}Field: {field['name']}")
|
|
529
|
+
typer.echo(f"{indent} Type: {summary.get('type')}")
|
|
530
|
+
if summary.get("format"):
|
|
531
|
+
typer.echo(f"{indent} Format: {summary['format']}")
|
|
532
|
+
if summary.get("is_optional"):
|
|
533
|
+
typer.echo(f"{indent} Optional: True")
|
|
534
|
+
if summary.get("constraints"):
|
|
535
|
+
typer.echo(f"{indent} Constraints: {summary['constraints']}")
|
|
536
|
+
if "default" in summary:
|
|
537
|
+
typer.echo(f"{indent} Default: {summary['default']}")
|
|
538
|
+
if "default_factory" in summary:
|
|
539
|
+
typer.echo(f"{indent} Default factory: {summary['default_factory']}")
|
|
540
|
+
|
|
541
|
+
if field.get("error"):
|
|
542
|
+
typer.echo(f"{indent} Issue: {field['error']}")
|
|
543
|
+
return
|
|
544
|
+
|
|
545
|
+
if field.get("truncated"):
|
|
546
|
+
typer.echo(f"{indent} ... (max depth reached)")
|
|
137
547
|
return
|
|
138
548
|
|
|
139
|
-
|
|
140
|
-
if
|
|
141
|
-
|
|
142
|
-
|
|
549
|
+
nested = field.get("nested")
|
|
550
|
+
if nested:
|
|
551
|
+
_render_nested_model_text(nested, indent=indent + " ")
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
strategy = field.get("strategy")
|
|
555
|
+
if strategy:
|
|
556
|
+
_render_strategy_text(strategy, indent=indent + " ")
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _render_strategy_text(strategy: dict[str, Any], *, indent: str) -> None:
|
|
560
|
+
if strategy["kind"] == "union":
|
|
561
|
+
typer.echo(f"{indent}Union policy: {strategy['policy']}")
|
|
562
|
+
if strategy.get("truncated"):
|
|
563
|
+
typer.echo(f"{indent} ... (max depth reached)")
|
|
564
|
+
return
|
|
565
|
+
for option in strategy.get("options", []):
|
|
566
|
+
option_summary = option.get("summary", {})
|
|
567
|
+
typer.echo(f"{indent} Option {option['index']} -> type: {option_summary.get('type')}")
|
|
568
|
+
nested = option.get("strategy")
|
|
569
|
+
if nested:
|
|
570
|
+
_render_strategy_text(nested, indent=indent + " ")
|
|
571
|
+
return
|
|
572
|
+
|
|
573
|
+
provider = strategy.get("provider") or "unknown"
|
|
574
|
+
typer.echo(f"{indent}Provider: {provider}")
|
|
575
|
+
if strategy.get("p_none") is not None:
|
|
576
|
+
typer.echo(f"{indent}p_none: {strategy['p_none']}")
|
|
577
|
+
if strategy.get("enum_values") is not None:
|
|
578
|
+
typer.echo(f"{indent}Enum values: {strategy['enum_values']}")
|
|
579
|
+
if strategy.get("enum_policy"):
|
|
580
|
+
typer.echo(f"{indent}Enum policy: {strategy['enum_policy']}")
|
|
581
|
+
if strategy.get("provider_kwargs"):
|
|
582
|
+
typer.echo(f"{indent}Provider kwargs: {strategy['provider_kwargs']}")
|
|
583
|
+
if strategy.get("truncated"):
|
|
584
|
+
typer.echo(f"{indent}... (max depth reached)")
|
|
585
|
+
return
|
|
586
|
+
|
|
587
|
+
nested = strategy.get("nested_model")
|
|
588
|
+
if nested:
|
|
589
|
+
_render_nested_model_text(nested, indent=indent)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _render_nested_model_text(model: dict[str, Any], *, indent: str) -> None:
|
|
593
|
+
qualname = model.get("qualname", "<unknown>")
|
|
594
|
+
if model.get("cycle"):
|
|
595
|
+
typer.echo(f"{indent}Nested model: {qualname} (cycle detected)")
|
|
596
|
+
return
|
|
597
|
+
if model.get("unsupported"):
|
|
598
|
+
typer.echo(f"{indent}Nested model: {qualname} (not expanded)")
|
|
599
|
+
return
|
|
600
|
+
|
|
601
|
+
typer.echo(f"{indent}Nested model: {qualname}")
|
|
602
|
+
for nested_field in model.get("fields", []):
|
|
603
|
+
_render_field_text(nested_field, indent=indent + " ")
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _render_tree(models: list[dict[str, Any]]) -> None:
|
|
607
|
+
nodes = [_model_to_tree(model) for model in models]
|
|
608
|
+
for index, node in enumerate(nodes):
|
|
609
|
+
typer.echo(node.label)
|
|
610
|
+
_render_tree_children(node.children, prefix="")
|
|
611
|
+
if index != len(nodes) - 1:
|
|
612
|
+
typer.echo("")
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _model_to_tree(model: dict[str, Any]) -> TreeNode:
|
|
616
|
+
fields = model.get("fields", [])
|
|
617
|
+
if not fields:
|
|
618
|
+
children = [TreeNode("(no fields)", [])]
|
|
619
|
+
else:
|
|
620
|
+
children = [_field_to_tree(field) for field in fields]
|
|
621
|
+
return TreeNode(f"Model {model['qualname']}", children)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _field_to_tree(field: dict[str, Any]) -> TreeNode:
|
|
625
|
+
summary = field.get("summary", {})
|
|
626
|
+
label = f"field {field['name']} [type={summary.get('type')}]"
|
|
627
|
+
|
|
628
|
+
if field.get("error"):
|
|
629
|
+
return TreeNode(label, [TreeNode(f"error: {field['error']}", [])])
|
|
630
|
+
|
|
631
|
+
strategy = field.get("strategy")
|
|
632
|
+
children = []
|
|
633
|
+
if strategy:
|
|
634
|
+
children.append(_strategy_to_tree_node(strategy))
|
|
635
|
+
nested = field.get("nested")
|
|
636
|
+
if nested:
|
|
637
|
+
children.append(_nested_model_to_tree(nested))
|
|
638
|
+
if field.get("truncated"):
|
|
639
|
+
children.append(TreeNode("... (max depth reached)", []))
|
|
640
|
+
return TreeNode(label, children)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _strategy_to_tree_node(strategy: dict[str, Any]) -> TreeNode:
|
|
644
|
+
if strategy["kind"] == "union":
|
|
645
|
+
label = f"union policy={strategy['policy']}"
|
|
646
|
+
if strategy.get("truncated"):
|
|
647
|
+
option_nodes = [TreeNode("... (max depth reached)", [])]
|
|
648
|
+
else:
|
|
649
|
+
option_nodes = []
|
|
650
|
+
for option in strategy.get("options", []):
|
|
651
|
+
summary = option.get("summary", {})
|
|
652
|
+
option_label = f"option {option['index']} [type={summary.get('type')}]"
|
|
653
|
+
option_strategy = option.get("strategy")
|
|
654
|
+
option_children = []
|
|
655
|
+
if option_strategy:
|
|
656
|
+
option_children.append(_strategy_to_tree_node(option_strategy))
|
|
657
|
+
option_nodes.append(TreeNode(option_label, option_children))
|
|
658
|
+
return TreeNode(label, option_nodes)
|
|
659
|
+
|
|
660
|
+
provider = strategy.get("provider") or "<unknown>"
|
|
661
|
+
p_none = strategy.get("p_none")
|
|
662
|
+
label = f"provider {provider}"
|
|
663
|
+
if p_none is not None:
|
|
664
|
+
label += f" (p_none={p_none})"
|
|
665
|
+
|
|
666
|
+
children = []
|
|
667
|
+
if strategy.get("enum_values") is not None:
|
|
668
|
+
children.append(TreeNode(f"enum_values={strategy['enum_values']}", []))
|
|
669
|
+
if strategy.get("enum_policy"):
|
|
670
|
+
children.append(TreeNode(f"enum_policy={strategy['enum_policy']}", []))
|
|
671
|
+
if strategy.get("provider_kwargs"):
|
|
672
|
+
children.append(TreeNode(f"provider_kwargs={strategy['provider_kwargs']}", []))
|
|
673
|
+
if strategy.get("truncated"):
|
|
674
|
+
children.append(TreeNode("... (max depth reached)", []))
|
|
675
|
+
else:
|
|
676
|
+
nested = strategy.get("nested_model")
|
|
677
|
+
if nested:
|
|
678
|
+
children.append(_nested_model_to_tree(nested))
|
|
679
|
+
return TreeNode(label, children)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _nested_model_to_tree(model: dict[str, Any]) -> TreeNode:
|
|
683
|
+
qualname = model.get("qualname", "<unknown>")
|
|
684
|
+
if model.get("cycle"):
|
|
685
|
+
return TreeNode(f"nested {qualname} (cycle detected)", [])
|
|
686
|
+
if model.get("unsupported"):
|
|
687
|
+
return TreeNode(f"nested {qualname} (not expanded)", [])
|
|
688
|
+
fields = model.get("fields", [])
|
|
689
|
+
children = [_field_to_tree(field) for field in fields] or [TreeNode("(no fields)", [])]
|
|
690
|
+
return TreeNode(f"nested {qualname}", children)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _render_tree_children(children: list[TreeNode], *, prefix: str) -> None:
|
|
694
|
+
for index, child in enumerate(children):
|
|
695
|
+
is_last = index == len(children) - 1
|
|
696
|
+
branch = "`-- " if is_last else "|-- "
|
|
697
|
+
typer.echo(f"{prefix}{branch}{child.label}")
|
|
698
|
+
next_prefix = f"{prefix}{' ' if is_last else '| '}"
|
|
699
|
+
_render_tree_children(child.children, prefix=next_prefix)
|
|
143
700
|
|
|
144
701
|
|
|
145
702
|
__all__ = ["app"]
|