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.
- pydantic_fixturegen/__init__.py +7 -0
- pydantic_fixturegen/cli/__init__.py +85 -0
- pydantic_fixturegen/cli/doctor.py +235 -0
- pydantic_fixturegen/cli/gen/__init__.py +23 -0
- pydantic_fixturegen/cli/gen/_common.py +139 -0
- pydantic_fixturegen/cli/gen/explain.py +145 -0
- pydantic_fixturegen/cli/gen/fixtures.py +283 -0
- pydantic_fixturegen/cli/gen/json.py +262 -0
- pydantic_fixturegen/cli/gen/schema.py +164 -0
- pydantic_fixturegen/cli/list.py +164 -0
- pydantic_fixturegen/core/__init__.py +103 -0
- pydantic_fixturegen/core/ast_discover.py +169 -0
- pydantic_fixturegen/core/config.py +440 -0
- pydantic_fixturegen/core/errors.py +136 -0
- pydantic_fixturegen/core/generate.py +311 -0
- pydantic_fixturegen/core/introspect.py +141 -0
- pydantic_fixturegen/core/io_utils.py +77 -0
- pydantic_fixturegen/core/providers/__init__.py +32 -0
- pydantic_fixturegen/core/providers/collections.py +74 -0
- pydantic_fixturegen/core/providers/identifiers.py +68 -0
- pydantic_fixturegen/core/providers/numbers.py +133 -0
- pydantic_fixturegen/core/providers/registry.py +98 -0
- pydantic_fixturegen/core/providers/strings.py +109 -0
- pydantic_fixturegen/core/providers/temporal.py +42 -0
- pydantic_fixturegen/core/safe_import.py +403 -0
- pydantic_fixturegen/core/schema.py +320 -0
- pydantic_fixturegen/core/seed.py +154 -0
- pydantic_fixturegen/core/strategies.py +193 -0
- pydantic_fixturegen/core/version.py +52 -0
- pydantic_fixturegen/emitters/__init__.py +15 -0
- pydantic_fixturegen/emitters/json_out.py +373 -0
- pydantic_fixturegen/emitters/pytest_codegen.py +365 -0
- pydantic_fixturegen/emitters/schema_out.py +84 -0
- pydantic_fixturegen/plugins/builtin.py +45 -0
- pydantic_fixturegen/plugins/hookspecs.py +59 -0
- pydantic_fixturegen/plugins/loader.py +72 -0
- pydantic_fixturegen-1.0.0.dist-info/METADATA +280 -0
- pydantic_fixturegen-1.0.0.dist-info/RECORD +41 -0
- pydantic_fixturegen-1.0.0.dist-info/WHEEL +4 -0
- pydantic_fixturegen-1.0.0.dist-info/entry_points.txt +5 -0
- 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"]
|