pydantic-fixturegen 1.0.0__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (41) hide show
  1. pydantic_fixturegen/__init__.py +7 -0
  2. pydantic_fixturegen/cli/__init__.py +85 -0
  3. pydantic_fixturegen/cli/doctor.py +235 -0
  4. pydantic_fixturegen/cli/gen/__init__.py +23 -0
  5. pydantic_fixturegen/cli/gen/_common.py +139 -0
  6. pydantic_fixturegen/cli/gen/explain.py +145 -0
  7. pydantic_fixturegen/cli/gen/fixtures.py +283 -0
  8. pydantic_fixturegen/cli/gen/json.py +262 -0
  9. pydantic_fixturegen/cli/gen/schema.py +164 -0
  10. pydantic_fixturegen/cli/list.py +164 -0
  11. pydantic_fixturegen/core/__init__.py +103 -0
  12. pydantic_fixturegen/core/ast_discover.py +169 -0
  13. pydantic_fixturegen/core/config.py +440 -0
  14. pydantic_fixturegen/core/errors.py +136 -0
  15. pydantic_fixturegen/core/generate.py +311 -0
  16. pydantic_fixturegen/core/introspect.py +141 -0
  17. pydantic_fixturegen/core/io_utils.py +77 -0
  18. pydantic_fixturegen/core/providers/__init__.py +32 -0
  19. pydantic_fixturegen/core/providers/collections.py +74 -0
  20. pydantic_fixturegen/core/providers/identifiers.py +68 -0
  21. pydantic_fixturegen/core/providers/numbers.py +133 -0
  22. pydantic_fixturegen/core/providers/registry.py +98 -0
  23. pydantic_fixturegen/core/providers/strings.py +109 -0
  24. pydantic_fixturegen/core/providers/temporal.py +42 -0
  25. pydantic_fixturegen/core/safe_import.py +403 -0
  26. pydantic_fixturegen/core/schema.py +320 -0
  27. pydantic_fixturegen/core/seed.py +154 -0
  28. pydantic_fixturegen/core/strategies.py +193 -0
  29. pydantic_fixturegen/core/version.py +52 -0
  30. pydantic_fixturegen/emitters/__init__.py +15 -0
  31. pydantic_fixturegen/emitters/json_out.py +373 -0
  32. pydantic_fixturegen/emitters/pytest_codegen.py +365 -0
  33. pydantic_fixturegen/emitters/schema_out.py +84 -0
  34. pydantic_fixturegen/plugins/builtin.py +45 -0
  35. pydantic_fixturegen/plugins/hookspecs.py +59 -0
  36. pydantic_fixturegen/plugins/loader.py +72 -0
  37. pydantic_fixturegen-1.0.0.dist-info/METADATA +280 -0
  38. pydantic_fixturegen-1.0.0.dist-info/RECORD +41 -0
  39. pydantic_fixturegen-1.0.0.dist-info/WHEEL +4 -0
  40. pydantic_fixturegen-1.0.0.dist-info/entry_points.txt +5 -0
  41. pydantic_fixturegen-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,320 @@
1
+ """Utilities for extracting constraint metadata from Pydantic models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses as dataclasses_module
6
+ import datetime
7
+ import decimal
8
+ import enum
9
+ import types
10
+ import uuid
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass
13
+ from typing import Annotated, Any, Union, get_args, get_origin
14
+
15
+ import annotated_types
16
+ import pydantic
17
+ from pydantic import BaseModel, SecretBytes, SecretStr
18
+ from pydantic.fields import FieldInfo
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class FieldConstraints:
23
+ ge: float | None = None
24
+ le: float | None = None
25
+ gt: float | None = None
26
+ lt: float | None = None
27
+ multiple_of: float | None = None
28
+ min_length: int | None = None
29
+ max_length: int | None = None
30
+ pattern: str | None = None
31
+ max_digits: int | None = None
32
+ decimal_places: int | None = None
33
+
34
+ def has_constraints(self) -> bool:
35
+ return any(
36
+ value is not None
37
+ for value in (
38
+ self.ge,
39
+ self.le,
40
+ self.gt,
41
+ self.lt,
42
+ self.multiple_of,
43
+ self.min_length,
44
+ self.max_length,
45
+ self.pattern,
46
+ self.max_digits,
47
+ self.decimal_places,
48
+ )
49
+ )
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class FieldSummary:
54
+ type: str
55
+ constraints: FieldConstraints
56
+ format: str | None = None
57
+ item_type: str | None = None
58
+ enum_values: list[Any] | None = None
59
+ is_optional: bool = False
60
+ annotation: Any | None = None
61
+ item_annotation: Any | None = None
62
+
63
+
64
+ def extract_constraints(field: FieldInfo) -> FieldConstraints:
65
+ """Extract constraint metadata from a single Pydantic FieldInfo."""
66
+ constraints = FieldConstraints()
67
+
68
+ for meta in field.metadata:
69
+ _apply_metadata(constraints, meta)
70
+
71
+ # Additional decimal info can be provided via annotation / metadata
72
+ _normalize_decimal_constraints(constraints)
73
+
74
+ return constraints
75
+
76
+
77
+ def extract_model_constraints(model: type[BaseModel]) -> Mapping[str, FieldConstraints]:
78
+ """Return constraint metadata for each field on a Pydantic model."""
79
+ result: dict[str, FieldConstraints] = {}
80
+ for name, field in model.model_fields.items():
81
+ constraints = extract_constraints(field)
82
+ if constraints.has_constraints():
83
+ result[name] = constraints
84
+ return result
85
+
86
+
87
+ def summarize_field(field: FieldInfo) -> FieldSummary:
88
+ constraints = extract_constraints(field)
89
+ annotation = field.annotation
90
+ summary = _summarize_annotation(annotation, constraints)
91
+ return summary
92
+
93
+
94
+ def summarize_model_fields(model: type[BaseModel]) -> Mapping[str, FieldSummary]:
95
+ summary: dict[str, FieldSummary] = {}
96
+ for name, field in model.model_fields.items():
97
+ summary[name] = summarize_field(field)
98
+ return summary
99
+
100
+
101
+ def _apply_metadata(constraints: FieldConstraints, meta: Any) -> None:
102
+ # Numeric bounds
103
+ if meta is None:
104
+ return
105
+
106
+ if isinstance(meta, annotated_types.Interval):
107
+ constraints.ge = _max_value(constraints.ge, _to_float(getattr(meta, "ge", None)))
108
+ constraints.le = _min_value(constraints.le, _to_float(getattr(meta, "le", None)))
109
+ constraints.gt = _max_value(constraints.gt, _to_float(getattr(meta, "gt", None)))
110
+ constraints.lt = _min_value(constraints.lt, _to_float(getattr(meta, "lt", None)))
111
+
112
+ if isinstance(meta, annotated_types.Ge):
113
+ constraints.ge = _max_value(constraints.ge, _to_float(meta.ge))
114
+ if isinstance(meta, annotated_types.Le):
115
+ constraints.le = _min_value(constraints.le, _to_float(meta.le))
116
+ if hasattr(annotated_types, "Gt") and isinstance(meta, annotated_types.Gt):
117
+ constraints.gt = _max_value(constraints.gt, _to_float(getattr(meta, "gt", None)))
118
+ if hasattr(annotated_types, "Lt") and isinstance(meta, annotated_types.Lt):
119
+ constraints.lt = _min_value(constraints.lt, _to_float(getattr(meta, "lt", None)))
120
+ if hasattr(annotated_types, "MultipleOf") and isinstance(meta, annotated_types.MultipleOf):
121
+ constraints.multiple_of = _to_float(getattr(meta, "multiple_of", None))
122
+
123
+ # String / collection length
124
+ if isinstance(meta, annotated_types.MinLen):
125
+ constraints.min_length = _max_int(constraints.min_length, meta.min_length)
126
+ if isinstance(meta, annotated_types.MaxLen):
127
+ constraints.max_length = _min_int(constraints.max_length, meta.max_length)
128
+
129
+ # General metadata container used by Pydantic for Field(...)
130
+ if hasattr(meta, "__dict__"):
131
+ data = meta.__dict__
132
+ if "pattern" in data and data["pattern"] is not None:
133
+ constraints.pattern = data["pattern"]
134
+ if "min_length" in data and data["min_length"] is not None:
135
+ constraints.min_length = _max_int(constraints.min_length, data["min_length"])
136
+ if "max_length" in data and data["max_length"] is not None:
137
+ constraints.max_length = _min_int(constraints.max_length, data["max_length"])
138
+ if "max_digits" in data and data["max_digits"] is not None:
139
+ constraints.max_digits = _min_int(constraints.max_digits, data["max_digits"])
140
+ if "decimal_places" in data and data["decimal_places"] is not None:
141
+ constraints.decimal_places = _min_int(
142
+ constraints.decimal_places, data["decimal_places"]
143
+ )
144
+
145
+
146
+ def _normalize_decimal_constraints(constraints: FieldConstraints) -> None:
147
+ if (
148
+ constraints.max_digits is not None
149
+ and constraints.decimal_places is not None
150
+ and constraints.decimal_places > constraints.max_digits
151
+ ):
152
+ constraints.decimal_places = constraints.max_digits
153
+
154
+
155
+ def _max_value(current: float | None, new: float | int | decimal.Decimal | None) -> float | None:
156
+ if new is None:
157
+ return current
158
+ new_float = float(new)
159
+ if current is None or new_float > current:
160
+ return new_float
161
+ return current
162
+
163
+
164
+ def _min_value(current: float | None, new: float | int | decimal.Decimal | None) -> float | None:
165
+ if new is None:
166
+ return current
167
+ new_float = float(new)
168
+ if current is None or new_float < current:
169
+ return new_float
170
+ return current
171
+
172
+
173
+ def _to_float(value: Any) -> float | None:
174
+ if value is None:
175
+ return None
176
+ try:
177
+ return float(value)
178
+ except (TypeError, ValueError): # pragma: no cover - defensive fallback
179
+ return None
180
+
181
+
182
+ def _max_int(current: int | None, new: int | None) -> int | None:
183
+ if new is None:
184
+ return current
185
+ if current is None or new > current:
186
+ return int(new)
187
+ return current
188
+
189
+
190
+ def _min_int(current: int | None, new: int | None) -> int | None:
191
+ if new is None:
192
+ return current
193
+ if current is None or new < current:
194
+ return int(new)
195
+ return current
196
+
197
+
198
+ def _strip_optional(annotation: Any) -> tuple[Any, bool]:
199
+ origin = get_origin(annotation)
200
+ if origin in {Union, types.UnionType}:
201
+ args = [arg for arg in get_args(annotation) if arg is not type(None)] # noqa: E721
202
+ if len(args) == 1 and len(get_args(annotation)) != len(args):
203
+ return args[0], True
204
+ return annotation, False
205
+
206
+
207
+ def _extract_enum_values(annotation: Any) -> list[Any] | None:
208
+ if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
209
+ return [member.value for member in annotation]
210
+ return None
211
+
212
+
213
+ def _summarize_annotation(
214
+ annotation: Any,
215
+ constraints: FieldConstraints | None = None,
216
+ ) -> FieldSummary:
217
+ inner_annotation, is_optional = _strip_optional(annotation)
218
+ type_name, fmt, item_annotation = _infer_annotation_kind(inner_annotation)
219
+ item_type = None
220
+ item_annotation_clean = None
221
+ if item_annotation is not None:
222
+ item_inner, _ = _strip_optional(item_annotation)
223
+ item_annotation_clean = item_inner
224
+ item_type, _, _ = _infer_annotation_kind(item_inner)
225
+ enum_values = _extract_enum_values(inner_annotation)
226
+ return FieldSummary(
227
+ type=type_name,
228
+ constraints=constraints or FieldConstraints(),
229
+ format=fmt,
230
+ item_type=item_type,
231
+ enum_values=enum_values,
232
+ is_optional=is_optional,
233
+ annotation=inner_annotation,
234
+ item_annotation=item_annotation_clean,
235
+ )
236
+
237
+
238
+ def _infer_annotation_kind(annotation: Any) -> tuple[str, str | None, Any | None]:
239
+ annotation = _unwrap_annotation(annotation)
240
+ origin = get_origin(annotation)
241
+ args = get_args(annotation)
242
+
243
+ if origin in {Union, types.UnionType}:
244
+ non_none = [arg for arg in args if arg is not type(None)] # noqa: E721
245
+ if len(non_none) == 1:
246
+ return _infer_annotation_kind(non_none[0])
247
+
248
+ if origin in {list, list[int]}: # pragma: no cover - typing quirk
249
+ origin = list
250
+
251
+ if origin in {list, set, tuple}:
252
+ item_annotation = args[0] if args else None
253
+ type_map = {list: "list", set: "set", tuple: "tuple"}
254
+ return type_map.get(origin, "collection"), None, item_annotation
255
+
256
+ if origin in {dict}:
257
+ value_annotation = args[1] if len(args) > 1 else None
258
+ return "mapping", None, value_annotation
259
+
260
+ if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
261
+ return "enum", None, None
262
+
263
+ if annotation is Any:
264
+ return "any", None, None
265
+
266
+ if isinstance(annotation, type):
267
+ if dataclasses_module.is_dataclass(annotation):
268
+ return "dataclass", None, None
269
+ email_type = getattr(pydantic, "EmailStr", None)
270
+ if email_type is not None and issubclass(annotation, email_type):
271
+ return "email", None, None
272
+ any_url_type = getattr(pydantic, "AnyUrl", None)
273
+ if any_url_type is not None and issubclass(annotation, any_url_type):
274
+ return "url", None, None
275
+ ip_address_type = getattr(pydantic, "IPvAnyAddress", None)
276
+ if ip_address_type is not None and issubclass(annotation, ip_address_type):
277
+ return "ip-address", None, None
278
+ ip_interface_type = getattr(pydantic, "IPvAnyInterface", None)
279
+ if ip_interface_type is not None and issubclass(annotation, ip_interface_type):
280
+ return "ip-interface", None, None
281
+ ip_network_type = getattr(pydantic, "IPvAnyNetwork", None)
282
+ if ip_network_type is not None and issubclass(annotation, ip_network_type):
283
+ return "ip-network", None, None
284
+ payment_card_type = getattr(pydantic, "PaymentCardNumber", None)
285
+ if payment_card_type is not None and issubclass(annotation, payment_card_type):
286
+ return "payment-card", None, None
287
+ if issubclass(annotation, SecretStr):
288
+ return "secret-str", None, None
289
+ if issubclass(annotation, SecretBytes):
290
+ return "secret-bytes", None, None
291
+ if issubclass(annotation, uuid.UUID):
292
+ return "uuid", None, None
293
+ if issubclass(annotation, datetime.datetime):
294
+ return "datetime", None, None
295
+ if issubclass(annotation, datetime.date) and not issubclass(annotation, datetime.datetime):
296
+ return "date", None, None
297
+ if issubclass(annotation, datetime.time):
298
+ return "time", None, None
299
+ if issubclass(annotation, BaseModel):
300
+ return "model", None, None
301
+ scalar_map = {
302
+ bool: "bool",
303
+ int: "int",
304
+ float: "float",
305
+ str: "string",
306
+ bytes: "bytes",
307
+ decimal.Decimal: "decimal",
308
+ }
309
+ for candidate, label in scalar_map.items():
310
+ if issubclass(annotation, candidate):
311
+ return label, None, None
312
+
313
+ return "any", None, None
314
+
315
+
316
+ def _unwrap_annotation(annotation: Any) -> Any:
317
+ origin = get_origin(annotation)
318
+ if origin is Annotated:
319
+ return _unwrap_annotation(get_args(annotation)[0])
320
+ return annotation
@@ -0,0 +1,154 @@
1
+ """Deterministic seed plumbing for random, Faker, and optional NumPy streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import random
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass, field
9
+ from importlib import import_module
10
+ from typing import Any, TypeVar, cast
11
+
12
+ from faker import Faker
13
+
14
+ try: # pragma: no cover - optional dependency
15
+ _np = cast(Any, import_module("numpy"))
16
+ except ModuleNotFoundError: # pragma: no cover - numpy optional
17
+ _np = None
18
+
19
+ DEFAULT_LOCALE = "en_US"
20
+ _SEPARATOR = b"\x1f" # unit separator for hash derivation
21
+
22
+
23
+ def _normalize_seed(seed: Any | None) -> int:
24
+ """Normalize seed inputs to an integer suitable for RNGs."""
25
+ if isinstance(seed, int):
26
+ return seed
27
+
28
+ if seed is None:
29
+ return 0
30
+
31
+ payload = seed if isinstance(seed, bytes) else str(seed).encode("utf-8")
32
+ digest = hashlib.sha256(payload).digest()
33
+ return int.from_bytes(digest[:16], "big", signed=False)
34
+
35
+
36
+ def _create_faker(locale: str, seed: int) -> Faker:
37
+ faker = Faker(locale)
38
+ faker.seed_instance(seed)
39
+ return faker
40
+
41
+
42
+ def _create_numpy_rng(seed: int) -> Any | None:
43
+ if _np is None:
44
+ return None
45
+
46
+ bit_gen = _np.random.PCG64(seed) # pragma: no cover
47
+ return _np.random.Generator(bit_gen) # pragma: no cover
48
+
49
+
50
+ def _seed_bytes(seed: int) -> bytes:
51
+ return str(seed).encode("utf-8")
52
+
53
+
54
+ SeedKey = tuple[Any, ...]
55
+ T = TypeVar("T")
56
+
57
+
58
+ @dataclass
59
+ class SeedManager:
60
+ """Manage deterministic random streams derived from a master seed."""
61
+
62
+ seed: Any | None = None
63
+ locale: str = DEFAULT_LOCALE
64
+
65
+ _normalized_seed: int = field(init=False, repr=False)
66
+ _seed_bytes: bytes = field(init=False, repr=False)
67
+ _base_random: random.Random = field(init=False, repr=False)
68
+ _faker: Faker = field(init=False, repr=False)
69
+ _numpy_rng: Any | None = field(init=False, repr=False)
70
+ _random_cache: dict[SeedKey, random.Random] = field(
71
+ default_factory=dict, init=False, repr=False
72
+ )
73
+ _faker_cache: dict[SeedKey, Faker] = field(default_factory=dict, init=False, repr=False)
74
+ _numpy_cache: dict[SeedKey, Any] = field(default_factory=dict, init=False, repr=False)
75
+
76
+ def __post_init__(self) -> None:
77
+ self._normalized_seed = _normalize_seed(self.seed)
78
+ self._seed_bytes = _seed_bytes(self._normalized_seed)
79
+ self._base_random = random.Random(self._normalized_seed)
80
+ self._faker = _create_faker(self.locale, self._normalized_seed)
81
+ self._numpy_rng = _create_numpy_rng(self._normalized_seed)
82
+
83
+ @property
84
+ def normalized_seed(self) -> int:
85
+ """Return the normalized integer seed."""
86
+ return self._normalized_seed
87
+
88
+ @property
89
+ def base_random(self) -> random.Random:
90
+ """Random generator seeded with the master seed."""
91
+ return self._base_random
92
+
93
+ @property
94
+ def faker(self) -> Faker:
95
+ """Faker instance seeded with the master seed."""
96
+ return self._faker
97
+
98
+ @property
99
+ def numpy_rng(self) -> Any | None:
100
+ """NumPy random generator seeded with the master seed (if NumPy is available)."""
101
+ return self._numpy_rng # pragma: no cover - optional dependency
102
+
103
+ def derive_child_seed(self, *parts: Any) -> int:
104
+ """Derive a deterministic child seed from the master seed and supplied key parts."""
105
+ hasher = hashlib.sha256()
106
+ hasher.update(self._seed_bytes)
107
+
108
+ for part in parts:
109
+ hasher.update(_SEPARATOR)
110
+ hasher.update(str(part).encode("utf-8"))
111
+
112
+ digest = hasher.digest()
113
+ return int.from_bytes(digest[:16], "big", signed=False)
114
+
115
+ def _cache_get_or_create(
116
+ self, cache: dict[SeedKey, T], key: SeedKey, factory: Callable[[SeedKey], T]
117
+ ) -> T:
118
+ if key not in cache:
119
+ cache[key] = factory(key)
120
+ return cache[key]
121
+
122
+ def random_for(self, *parts: Any) -> random.Random:
123
+ """Return a deterministic `random.Random` stream for the given key parts."""
124
+ key = tuple(parts)
125
+
126
+ def factory(_: SeedKey) -> random.Random:
127
+ return random.Random(self.derive_child_seed(*parts))
128
+
129
+ return self._cache_get_or_create(self._random_cache, key, factory)
130
+
131
+ def faker_for(self, *parts: Any) -> Faker:
132
+ """Return a Faker instance seeded for the given key parts."""
133
+ key = tuple(parts)
134
+
135
+ def factory(_: SeedKey) -> Faker:
136
+ faker = Faker(self.locale)
137
+ faker.seed_instance(self.derive_child_seed(*parts))
138
+ return faker
139
+
140
+ return self._cache_get_or_create(self._faker_cache, key, factory)
141
+
142
+ def numpy_for(self, *parts: Any) -> Any | None:
143
+ """Return a NumPy generator seeded for the given key (if NumPy is available)."""
144
+ if _np is None:
145
+ return None
146
+ else: # pragma: no cover
147
+ key = tuple(parts)
148
+
149
+ def factory(_: SeedKey) -> Any:
150
+ seed = self.derive_child_seed(*parts)
151
+ bit_gen = _np.random.PCG64(seed) # pragma: no cover
152
+ return _np.random.Generator(bit_gen) # pragma: no cover
153
+
154
+ return self._cache_get_or_create(self._numpy_cache, key, factory)
@@ -0,0 +1,193 @@
1
+ """Strategy builder for field generation policies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import types
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Union, get_args, get_origin
9
+
10
+ import pluggy
11
+ from pydantic import BaseModel
12
+
13
+ from pydantic_fixturegen.core import schema as schema_module
14
+ from pydantic_fixturegen.core.providers import ProviderRef, ProviderRegistry
15
+ from pydantic_fixturegen.core.schema import FieldConstraints, FieldSummary, summarize_model_fields
16
+ from pydantic_fixturegen.plugins.loader import get_plugin_manager
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class Strategy:
21
+ """Represents a concrete provider strategy for a field."""
22
+
23
+ field_name: str
24
+ summary: FieldSummary
25
+ annotation: Any
26
+ provider_ref: ProviderRef | None
27
+ provider_name: str | None
28
+ provider_kwargs: dict[str, Any] = field(default_factory=dict)
29
+ p_none: float = 0.0
30
+ enum_values: list[Any] | None = None
31
+ enum_policy: str | None = None
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class UnionStrategy:
36
+ """Represents a strategy for union types."""
37
+
38
+ field_name: str
39
+ choices: list[Strategy]
40
+ policy: str
41
+
42
+
43
+ StrategyResult = Strategy | UnionStrategy
44
+
45
+
46
+ class StrategyBuilder:
47
+ """Builds provider strategies for Pydantic models."""
48
+
49
+ def __init__(
50
+ self,
51
+ registry: ProviderRegistry,
52
+ *,
53
+ enum_policy: str = "first",
54
+ union_policy: str = "first",
55
+ default_p_none: float = 0.0,
56
+ optional_p_none: float | None = None,
57
+ plugin_manager: pluggy.PluginManager | None = None,
58
+ ) -> None:
59
+ self.registry = registry
60
+ self.enum_policy = enum_policy
61
+ self.union_policy = union_policy
62
+ self.default_p_none = default_p_none
63
+ self.optional_p_none = optional_p_none if optional_p_none is not None else default_p_none
64
+ self._plugin_manager = plugin_manager or get_plugin_manager()
65
+
66
+ def build_model_strategies(self, model: type[BaseModel]) -> Mapping[str, StrategyResult]:
67
+ summaries = summarize_model_fields(model)
68
+ strategies: dict[str, StrategyResult] = {}
69
+ for name, model_field in model.model_fields.items():
70
+ summary = summaries[name]
71
+ strategies[name] = self.build_field_strategy(
72
+ model,
73
+ name,
74
+ model_field.annotation,
75
+ summary,
76
+ )
77
+ return strategies
78
+
79
+ def build_field_strategy(
80
+ self,
81
+ model: type[BaseModel],
82
+ field_name: str,
83
+ annotation: Any,
84
+ summary: FieldSummary,
85
+ ) -> StrategyResult:
86
+ base_annotation, _ = schema_module._strip_optional(annotation)
87
+ union_args = self._extract_union_args(base_annotation)
88
+ if union_args:
89
+ return self._build_union_strategy(model, field_name, union_args)
90
+ return self._build_single_strategy(model, field_name, summary, base_annotation)
91
+
92
+ # ------------------------------------------------------------------ helpers
93
+ def _build_union_strategy(
94
+ self,
95
+ model: type[BaseModel],
96
+ field_name: str,
97
+ union_args: Sequence[Any],
98
+ ) -> UnionStrategy:
99
+ choices: list[Strategy] = []
100
+ for ann in union_args:
101
+ summary = self._summarize_inline(ann)
102
+ choices.append(self._build_single_strategy(model, field_name, summary, ann))
103
+ return UnionStrategy(field_name=field_name, choices=choices, policy=self.union_policy)
104
+
105
+ def _build_single_strategy(
106
+ self,
107
+ model: type[BaseModel],
108
+ field_name: str,
109
+ summary: FieldSummary,
110
+ annotation: Any,
111
+ ) -> Strategy:
112
+ if summary.enum_values:
113
+ return Strategy(
114
+ field_name=field_name,
115
+ summary=summary,
116
+ annotation=annotation,
117
+ provider_ref=None,
118
+ provider_name="enum.static",
119
+ provider_kwargs={},
120
+ p_none=self.optional_p_none if summary.is_optional else self.default_p_none,
121
+ enum_values=summary.enum_values,
122
+ enum_policy=self.enum_policy,
123
+ )
124
+
125
+ if summary.type in {"model", "dataclass"}:
126
+ p_none = self.optional_p_none if summary.is_optional else self.default_p_none
127
+ return Strategy(
128
+ field_name=field_name,
129
+ summary=summary,
130
+ annotation=annotation,
131
+ provider_ref=None,
132
+ provider_name=summary.type,
133
+ provider_kwargs={},
134
+ p_none=p_none,
135
+ )
136
+
137
+ provider = self.registry.get(summary.type, summary.format)
138
+ if provider is None:
139
+ provider = self.registry.get(summary.type)
140
+ if provider is None and summary.type == "string":
141
+ provider = self.registry.get("string")
142
+ if provider is None:
143
+ raise ValueError(
144
+ f"No provider registered for field '{field_name}' with type '{summary.type}'."
145
+ )
146
+
147
+ p_none = self.default_p_none
148
+ if summary.is_optional:
149
+ p_none = self.optional_p_none
150
+
151
+ strategy = Strategy(
152
+ field_name=field_name,
153
+ summary=summary,
154
+ annotation=annotation,
155
+ provider_ref=provider,
156
+ provider_name=provider.name,
157
+ provider_kwargs={},
158
+ p_none=p_none,
159
+ )
160
+ return self._apply_strategy_plugins(model, field_name, strategy)
161
+
162
+ # ------------------------------------------------------------------ utilities
163
+ def _extract_union_args(self, annotation: Any) -> Sequence[Any]:
164
+ origin = get_origin(annotation)
165
+ if origin in {list, set, tuple, dict}:
166
+ return []
167
+ if origin in {Union, types.UnionType}:
168
+ args = [arg for arg in get_args(annotation) if arg is not type(None)] # noqa: E721
169
+ if len(args) > 1:
170
+ return args
171
+ return []
172
+
173
+ def _summarize_inline(self, annotation: Any) -> FieldSummary:
174
+ return schema_module._summarize_annotation(annotation, FieldConstraints())
175
+
176
+ def _apply_strategy_plugins(
177
+ self,
178
+ model: type[BaseModel],
179
+ field_name: str,
180
+ strategy: Strategy,
181
+ ) -> Strategy:
182
+ results = self._plugin_manager.hook.pfg_modify_strategy(
183
+ model=model,
184
+ field_name=field_name,
185
+ strategy=strategy,
186
+ )
187
+ for result in results:
188
+ if isinstance(result, Strategy):
189
+ strategy = result
190
+ return strategy
191
+
192
+
193
+ __all__ = ["Strategy", "UnionStrategy", "StrategyBuilder", "StrategyResult"]