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
@@ -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(*, target: str, include: str | None, exclude: str | None) -> None:
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
- if warning.strip():
88
- typer.secho(f"warning: {warning.strip()}", err=True, fg=typer.colors.YELLOW)
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
- typer.echo("No models discovered.")
92
- return
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
- _render_model_report(model_cls, builder)
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
- def _render_model_report(model: type[BaseModel], builder: StrategyBuilder) -> None:
103
- typer.echo(f"Model: {model.__module__}.{model.__name__}")
104
- summaries = summarize_model_fields(model)
105
- strategies = {}
106
- field_failures: dict[str, str] = {}
107
- for name, model_field in model.model_fields.items():
108
- summary = summaries[name]
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
- strategies[name] = builder.build_field_strategy(
111
- model,
112
- name,
113
- model_field.annotation,
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
- field_failures[name] = str(exc)
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
- for field_name, strategy in strategies.items():
120
- summary = summaries[field_name]
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 _render_strategy(strategy: StrategyResult, indent: str) -> None:
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
- typer.echo(f"{indent}Union policy: {strategy.policy}")
134
- for idx, choice in enumerate(strategy.choices, start=1):
135
- typer.echo(f"{indent} Option {idx} -> type: {choice.summary.type}")
136
- _render_strategy(choice, indent=f"{indent} ")
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
- typer.echo(f"{indent}Provider: {strategy.provider_name}")
140
- if strategy.enum_values:
141
- typer.echo(f"{indent}Enum values: {strategy.enum_values}")
142
- typer.echo(f"{indent}p_none: {strategy.p_none}")
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"]