polarfactory 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Felipe Castro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: polarfactory
3
+ Version: 0.1.0
4
+ Summary: Generate Python objects filled with realistic test data.
5
+ Keywords: testing,test data,factory,fixtures,dataclasses,pydantic
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Testing
16
+ Classifier: Typing :: Typed
17
+ Requires-Dist: pydantic>=2.11 ; extra == 'pydantic'
18
+ Requires-Python: >=3.10
19
+ Project-URL: Homepage, https://github.com/PinnLabs/Polars
20
+ Project-URL: Documentation, https://pinnlabs.github.io/Polars/
21
+ Project-URL: Changelog, https://github.com/PinnLabs/Polars/blob/main/CHANGELOG.md
22
+ Project-URL: Issues, https://github.com/PinnLabs/Polars/issues
23
+ Provides-Extra: pydantic
24
+ Description-Content-Type: text/markdown
25
+
26
+ # polarfactory
27
+
28
+ Generate Python objects filled with realistic test data, from the type hints you
29
+ already have.
30
+
31
+ ```bash
32
+ pip install polarfactory
33
+ pip install "polarfactory[pydantic]" # optional: Pydantic v2 support
34
+ ```
35
+
36
+ ## Quickstart
37
+
38
+ ```python
39
+ from dataclasses import dataclass
40
+ from datetime import datetime
41
+
42
+ from polarfactory import build, build_many
43
+
44
+
45
+ @dataclass
46
+ class Address:
47
+ city: str
48
+ country: str
49
+
50
+
51
+ @dataclass
52
+ class User:
53
+ id: int
54
+ name: str
55
+ email: str
56
+ created: datetime
57
+ address: Address
58
+ tags: list[str]
59
+ nickname: str | None
60
+
61
+
62
+ user = build(User, seed=42)
63
+ # User(id=8753, name='Grace Rossi', email='mateo.brown2@example.org',
64
+ # created=datetime.datetime(2022, 3, 4, 1, 29, 27, tzinfo=datetime.timezone.utc),
65
+ # address=Address(city='Toronto', country='Nigeria'),
66
+ # tags=['hwnoekxw', 'lucfvluc', 'pxxscfvm'], nickname='dpsjyett')
67
+
68
+ build(User, seed=42, name="Ana") # same values, except name
69
+ build_many(User, 3, seed=1) # three different users, same list every run
70
+ ```
71
+
72
+ - Same seed, same object, in every process.
73
+ - Works with dataclasses, plain classes and Pydantic v2 models, including
74
+ nested ones.
75
+ - Field names like `email`, `name` or `city` get realistic values.
76
+ - Errors say how to fix the problem.
77
+
78
+ Full documentation: <https://pinnlabs.github.io/Polars/>
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,57 @@
1
+ # polarfactory
2
+
3
+ Generate Python objects filled with realistic test data, from the type hints you
4
+ already have.
5
+
6
+ ```bash
7
+ pip install polarfactory
8
+ pip install "polarfactory[pydantic]" # optional: Pydantic v2 support
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```python
14
+ from dataclasses import dataclass
15
+ from datetime import datetime
16
+
17
+ from polarfactory import build, build_many
18
+
19
+
20
+ @dataclass
21
+ class Address:
22
+ city: str
23
+ country: str
24
+
25
+
26
+ @dataclass
27
+ class User:
28
+ id: int
29
+ name: str
30
+ email: str
31
+ created: datetime
32
+ address: Address
33
+ tags: list[str]
34
+ nickname: str | None
35
+
36
+
37
+ user = build(User, seed=42)
38
+ # User(id=8753, name='Grace Rossi', email='mateo.brown2@example.org',
39
+ # created=datetime.datetime(2022, 3, 4, 1, 29, 27, tzinfo=datetime.timezone.utc),
40
+ # address=Address(city='Toronto', country='Nigeria'),
41
+ # tags=['hwnoekxw', 'lucfvluc', 'pxxscfvm'], nickname='dpsjyett')
42
+
43
+ build(User, seed=42, name="Ana") # same values, except name
44
+ build_many(User, 3, seed=1) # three different users, same list every run
45
+ ```
46
+
47
+ - Same seed, same object, in every process.
48
+ - Works with dataclasses, plain classes and Pydantic v2 models, including
49
+ nested ones.
50
+ - Field names like `email`, `name` or `city` get realistic values.
51
+ - Errors say how to fix the problem.
52
+
53
+ Full documentation: <https://pinnlabs.github.io/Polars/>
54
+
55
+ ## License
56
+
57
+ MIT
@@ -0,0 +1,69 @@
1
+ [project]
2
+ name = "polarfactory"
3
+ version = "0.1.0"
4
+ description = "Generate Python objects filled with realistic test data."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.10"
9
+ dependencies = []
10
+ keywords = [
11
+ "testing",
12
+ "test data",
13
+ "factory",
14
+ "fixtures",
15
+ "dataclasses",
16
+ "pydantic",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 3 - Alpha",
20
+ "Intended Audience :: Developers",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Software Development :: Testing",
27
+ "Typing :: Typed",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/PinnLabs/Polars"
32
+ Documentation = "https://pinnlabs.github.io/Polars/"
33
+ Changelog = "https://github.com/PinnLabs/Polars/blob/main/CHANGELOG.md"
34
+ Issues = "https://github.com/PinnLabs/Polars/issues"
35
+
36
+ [project.optional-dependencies]
37
+ pydantic = ["pydantic>=2.11"]
38
+
39
+ [build-system]
40
+ requires = ["uv_build>=0.7.3,<0.14"]
41
+ build-backend = "uv_build"
42
+
43
+ [tool.ruff]
44
+ line-length = 100
45
+
46
+ [tool.ruff.lint]
47
+ select = [
48
+ "E",
49
+ "F",
50
+ "I",
51
+ "B",
52
+ "UP",
53
+ ]
54
+
55
+ [tool.mypy]
56
+ strict = true
57
+
58
+ [tool.pytest.ini_options]
59
+ testpaths = ["tests"]
60
+
61
+ [dependency-groups]
62
+ test = ["pytest>=9.2.0"]
63
+ dev = [
64
+ { include-group = "test" },
65
+ "mypy>=2.4.1",
66
+ "pydantic>=2.11",
67
+ "ruff>=0.17.3",
68
+ ]
69
+ docs = ["mkdocs-material>=9.7.7"]
@@ -0,0 +1,63 @@
1
+ [project]
2
+ name = "polarfactory"
3
+ version = "0.1.0"
4
+ description = "Generate Python objects filled with realistic test data."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.10"
9
+ dependencies = []
10
+ keywords = ["testing", "test data", "factory", "fixtures", "dataclasses", "pydantic"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Topic :: Software Development :: Testing",
20
+ "Typing :: Typed",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/PinnLabs/Polars"
25
+ Documentation = "https://pinnlabs.github.io/Polars/"
26
+ Changelog = "https://github.com/PinnLabs/Polars/blob/main/CHANGELOG.md"
27
+ Issues = "https://github.com/PinnLabs/Polars/issues"
28
+
29
+ [project.optional-dependencies]
30
+ pydantic = [
31
+ "pydantic>=2.11",
32
+ ]
33
+
34
+ [build-system]
35
+ requires = ["uv_build>=0.7.3,<0.14"]
36
+ build-backend = "uv_build"
37
+
38
+ [tool.ruff]
39
+ line-length = 100
40
+
41
+ [tool.ruff.lint]
42
+ select = ["E", "F", "I", "B", "UP"]
43
+
44
+ [tool.mypy]
45
+ strict = true
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+
50
+ [dependency-groups]
51
+ # CI installs only "test" to prove the core works without pydantic.
52
+ test = [
53
+ "pytest>=9.2.0",
54
+ ]
55
+ dev = [
56
+ { include-group = "test" },
57
+ "mypy>=2.4.1",
58
+ "pydantic>=2.11",
59
+ "ruff>=0.17.3",
60
+ ]
61
+ docs = [
62
+ "mkdocs-material>=9.7.7",
63
+ ]
@@ -0,0 +1,5 @@
1
+ from polarfactory._build import build, build_many, register, reset_registry
2
+
3
+ __all__ = ["build", "build_many", "register", "reset_registry", "__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,423 @@
1
+ import dataclasses
2
+ import inspect
3
+ import random
4
+ import string
5
+ import sys
6
+ import types
7
+ from collections.abc import Callable
8
+ from datetime import date, datetime, time, timedelta, timezone
9
+ from decimal import Decimal
10
+ from enum import Enum
11
+ from typing import Any, Literal, TypeVar, Union, cast, get_args, get_origin, get_type_hints
12
+ from uuid import UUID
13
+
14
+ from polarfactory._constraints import (
15
+ Constraints,
16
+ UnsupportedConstraint,
17
+ describe,
18
+ fit_length,
19
+ generate_from_pattern,
20
+ generate_number,
21
+ )
22
+ from polarfactory._names import NAME_GENERATORS
23
+ from polarfactory.errors import GenerationError, PolarFactoryError
24
+
25
+ T = TypeVar("T")
26
+
27
+ _MAX_DEPTH = 3
28
+ _NONE_PROBABILITY = 0.25
29
+ _DATE_ANCHOR = datetime(2025, 1, 1, tzinfo=timezone.utc)
30
+ _DATE_WINDOW = timedelta(days=5 * 365)
31
+
32
+
33
+ def _generate_int(rng: random.Random) -> int:
34
+ return rng.randint(0, 10_000)
35
+
36
+
37
+ def _generate_str(rng: random.Random) -> str:
38
+ return "".join(rng.choices(string.ascii_lowercase, k=8))
39
+
40
+
41
+ def _generate_bool(rng: random.Random) -> bool:
42
+ return rng.random() < 0.5
43
+
44
+
45
+ def _generate_float(rng: random.Random) -> float:
46
+ return round(rng.uniform(0, 10_000), 2)
47
+
48
+
49
+ def _generate_bytes(rng: random.Random) -> bytes:
50
+ return rng.randbytes(8)
51
+
52
+
53
+ def _generate_datetime(rng: random.Random) -> datetime:
54
+ return _DATE_ANCHOR - timedelta(seconds=rng.randrange(int(_DATE_WINDOW.total_seconds())))
55
+
56
+
57
+ def _generate_date(rng: random.Random) -> date:
58
+ return _DATE_ANCHOR.date() - timedelta(days=rng.randrange(_DATE_WINDOW.days))
59
+
60
+
61
+ def _generate_time(rng: random.Random) -> time:
62
+ return time(rng.randrange(24), rng.randrange(60), rng.randrange(60))
63
+
64
+
65
+ def _generate_timedelta(rng: random.Random) -> timedelta:
66
+ return timedelta(seconds=rng.randint(0, 30 * 24 * 60 * 60))
67
+
68
+
69
+ def _generate_uuid(rng: random.Random) -> UUID:
70
+ return UUID(int=rng.getrandbits(128), version=4)
71
+
72
+
73
+ def _generate_decimal(rng: random.Random) -> Decimal:
74
+ return Decimal(f"{rng.randint(0, 1_000_000) / 100:.2f}")
75
+
76
+
77
+ _DEFAULT_GENERATORS: dict[Any, Callable[[random.Random], Any]] = {
78
+ int: _generate_int,
79
+ str: _generate_str,
80
+ bool: _generate_bool,
81
+ float: _generate_float,
82
+ bytes: _generate_bytes,
83
+ datetime: _generate_datetime,
84
+ date: _generate_date,
85
+ time: _generate_time,
86
+ timedelta: _generate_timedelta,
87
+ UUID: _generate_uuid,
88
+ Decimal: _generate_decimal,
89
+ }
90
+ _GENERATORS = dict(_DEFAULT_GENERATORS)
91
+
92
+
93
+ def register(tp: type[T], fn: Callable[[random.Random], T]) -> None:
94
+ if not callable(fn):
95
+ raise PolarFactoryError(f"register() expects a callable for {tp!r}, got {fn!r}")
96
+ _GENERATORS[tp] = fn
97
+
98
+
99
+ def reset_registry() -> None:
100
+ _GENERATORS.clear()
101
+ _GENERATORS.update(_DEFAULT_GENERATORS)
102
+
103
+
104
+ def _type_name(tp: Any) -> str:
105
+ return str(tp) if get_origin(tp) else getattr(tp, "__name__", str(tp))
106
+
107
+
108
+ class _DepthExceededError(GenerationError):
109
+ pass
110
+
111
+
112
+ def _override_hint(path: str) -> str:
113
+ root, field = path.split(".")[:2]
114
+ return f"build({root}, {field}=...)"
115
+
116
+
117
+ def _type_hints(obj: Any, cls: type[Any], namespace: dict[str, Any] | None = None) -> Any:
118
+ try:
119
+ return get_type_hints(obj, namespace)
120
+ except NameError as exc:
121
+ raise GenerationError(
122
+ f"cannot resolve type hints of {cls.__name__}: {exc}. "
123
+ f"Define or import it in module '{cls.__module__}'"
124
+ ) from exc
125
+
126
+
127
+ @dataclasses.dataclass(frozen=True)
128
+ class _FieldSpec:
129
+ name: str
130
+ type: Any
131
+ init: bool = True
132
+ constraints: Constraints | None = None
133
+
134
+
135
+ def _is_dataclass_type(tp: Any) -> bool:
136
+ return isinstance(tp, type) and dataclasses.is_dataclass(tp)
137
+
138
+
139
+ def _is_buildable(tp: Any) -> bool:
140
+ # A Python-defined __init__ marks a plain class; builtins like complex use slot wrappers.
141
+ return _is_dataclass_type(tp) or (
142
+ isinstance(tp, type) and inspect.isfunction(getattr(tp, "__init__", None))
143
+ )
144
+
145
+
146
+ def _pydantic_support(tp: Any) -> types.ModuleType | None:
147
+ # Never import pydantic ourselves: a pydantic model can only exist once the user has.
148
+ if "pydantic" not in sys.modules:
149
+ return None
150
+ from polarfactory import _pydantic
151
+
152
+ return _pydantic if _pydantic.is_model(tp) else None
153
+
154
+
155
+ def _field_specs(cls: type[Any]) -> list[_FieldSpec]:
156
+ pydantic_support = _pydantic_support(cls)
157
+ if pydantic_support is not None:
158
+ return cast(list[_FieldSpec], pydantic_support.field_specs(cls))
159
+ if _is_dataclass_type(cls):
160
+ hints = _type_hints(cls, cls)
161
+ specs = [
162
+ _FieldSpec(field.name, hints[field.name], field.init)
163
+ for field in dataclasses.fields(cls)
164
+ ]
165
+ # InitVar pseudo-fields are __init__ arguments but not part of dataclasses.fields().
166
+ return specs + [
167
+ _FieldSpec(name, hint.type)
168
+ for name, hint in hints.items()
169
+ if isinstance(hint, dataclasses.InitVar)
170
+ ]
171
+ hints = _type_hints(cls.__init__, cls)
172
+ params = list(inspect.signature(cls.__init__).parameters.values())[1:] # drop self
173
+ return [
174
+ # A missing hint is stored as Parameter.empty so an override can still supply the value.
175
+ _FieldSpec(param.name, hints.get(param.name, param.empty))
176
+ for param in params
177
+ if param.kind not in (param.VAR_POSITIONAL, param.VAR_KEYWORD)
178
+ ]
179
+
180
+
181
+ def _resolve_forward_ref(tp: str, owner: type) -> Any:
182
+ # shortcut: Python 3.10 leaves list["X"] unresolved, and this only looks in owner's module, so
183
+ # a field inherited from another module raises NameError. Drop this when 3.10 support ends.
184
+ holder = types.SimpleNamespace(__annotations__={"value": tp})
185
+ return _type_hints(holder, owner, vars(sys.modules[owner.__module__]))["value"]
186
+
187
+
188
+ _COLLECTION_HINTS: dict[Any, str] = {
189
+ list: "list[X]",
190
+ set: "set[X]",
191
+ dict: "dict[K, V]",
192
+ tuple: "tuple[X, Y] or tuple[X, ...]",
193
+ }
194
+
195
+
196
+ def _generate_items(
197
+ tp: Any,
198
+ seed: str,
199
+ path: str,
200
+ ancestors: tuple[type, ...],
201
+ sizes: tuple[int, int],
202
+ suffix: str = "",
203
+ ) -> list[Any]:
204
+ # The length depends only on seed, so dict keys and values get the same length.
205
+ length = random.Random(seed).randint(*sizes)
206
+ try:
207
+ return [_generate(tp, f"{seed}:{i}{suffix}", path, ancestors) for i in range(length)]
208
+ except _DepthExceededError:
209
+ return []
210
+
211
+
212
+ def _generate_collection(
213
+ kind: type,
214
+ tp: Any,
215
+ seed: str,
216
+ path: str,
217
+ ancestors: tuple[type, ...],
218
+ constraints: Constraints | None,
219
+ ) -> Any:
220
+ args = get_args(tp)
221
+ if not args:
222
+ raise GenerationError(
223
+ f"cannot generate field '{path}' of type {kind.__name__}. "
224
+ f"Use {_COLLECTION_HINTS[kind]}, or pass it as an override: {_override_hint(path)}"
225
+ )
226
+ if kind is tuple and args[-1] is not Ellipsis:
227
+ return tuple(_generate(arg, f"{seed}:{i}", path, ancestors) for i, arg in enumerate(args))
228
+ sizes = constraints.size_range(1, 3) if constraints else (1, 3)
229
+ items = _generate_items(args[0], seed, path, ancestors, sizes)
230
+ if kind is list:
231
+ return items
232
+ if kind is tuple:
233
+ return tuple(items)
234
+ values = (
235
+ _generate_items(args[1], seed, path, ancestors, sizes, ":value") if kind is dict else []
236
+ )
237
+ try:
238
+ return dict(zip(items, values, strict=False)) if kind is dict else set(items)
239
+ except TypeError as exc:
240
+ what = "keys" if kind is dict else "items"
241
+ raise GenerationError(
242
+ f"cannot generate field '{path}' of type {_type_name(tp)}: {what} are not hashable. "
243
+ f"Use a hashable type, or pass it as an override: {_override_hint(path)}"
244
+ ) from exc
245
+
246
+
247
+ def _generate_union(
248
+ members: list[Any],
249
+ seed: str,
250
+ path: str,
251
+ ancestors: tuple[type, ...],
252
+ constraints: Constraints | None,
253
+ ) -> Any:
254
+ # Generate every member so an unsupported one fails no matter which member the seed picks.
255
+ values = []
256
+ depth_errors = []
257
+ for i, member in enumerate(members):
258
+ try:
259
+ values.append(_generate(member, f"{seed}:{i}", path, ancestors, constraints))
260
+ except _DepthExceededError as exc:
261
+ depth_errors.append(exc)
262
+ if not values:
263
+ raise depth_errors[0]
264
+ return random.Random(seed).choice(values)
265
+
266
+
267
+ def _generate_optional(
268
+ tp: Any,
269
+ seed: str,
270
+ path: str,
271
+ ancestors: tuple[type, ...],
272
+ constraints: Constraints | None,
273
+ ) -> Any:
274
+ try:
275
+ value = _generate(tp, f"{seed}:value", path, ancestors, constraints)
276
+ except _DepthExceededError:
277
+ return None
278
+ return None if random.Random(seed).random() < _NONE_PROBABILITY else value
279
+
280
+
281
+ def _generate_object(
282
+ cls: type[Any],
283
+ seed: str,
284
+ path: str,
285
+ ancestors: tuple[type, ...],
286
+ overrides: dict[str, object],
287
+ ) -> Any:
288
+ ancestors = (*ancestors, cls)
289
+ if len(ancestors) - len(set(ancestors)) > _MAX_DEPTH:
290
+ raise _DepthExceededError(
291
+ f"cannot generate field '{path}' of type {cls.__name__}: "
292
+ f"required self-reference exceeds max depth {_MAX_DEPTH}. "
293
+ f"Make it Optional or a list, or pass it as an override: {_override_hint(path)}"
294
+ )
295
+ values: dict[str, Any] = dict(overrides)
296
+ for field in _field_specs(cls):
297
+ if field.init and field.name not in overrides:
298
+ field_path = f"{path}.{field.name}"
299
+ if field.type is inspect.Parameter.empty:
300
+ raise GenerationError(
301
+ f"cannot generate field '{field_path}': parameter '{field.name}' of "
302
+ f"{cls.__name__}.__init__ has no type hint. "
303
+ f"Add one, or pass it as an override: {_override_hint(field_path)}"
304
+ )
305
+ values[field.name] = _generate(
306
+ field.type, f"{seed}:{field.name}", field_path, ancestors, field.constraints
307
+ )
308
+ pydantic_support = _pydantic_support(cls)
309
+ if pydantic_support is not None:
310
+ return pydantic_support.construct(cls, values, path)
311
+ return cls(**values)
312
+
313
+
314
+ def _generate_constrained(
315
+ tp: Any,
316
+ generator: Callable[[random.Random], Any],
317
+ constraints: Constraints,
318
+ seed: str,
319
+ path: str,
320
+ ) -> Any:
321
+ rng = random.Random(seed)
322
+ try:
323
+ if tp in (int, float, Decimal):
324
+ return generate_number(tp, constraints, rng)
325
+ if tp is str and constraints.pattern is not None:
326
+ return generate_from_pattern(constraints.pattern, rng)
327
+ if tp is str:
328
+ return fit_length(generator(rng), constraints, random.Random(f"{seed}:length"))
329
+ except UnsupportedConstraint as exc:
330
+ raise GenerationError(
331
+ f"cannot generate field '{path}' with {describe(constraints)}: {exc}. "
332
+ f"Pass it as an override: {_override_hint(path)}"
333
+ ) from exc
334
+ return generator(rng)
335
+
336
+
337
+ def _generate(
338
+ tp: Any,
339
+ seed: str,
340
+ path: str,
341
+ ancestors: tuple[type, ...],
342
+ constraints: Constraints | None = None,
343
+ ) -> Any:
344
+ if isinstance(tp, str):
345
+ tp = _resolve_forward_ref(tp, ancestors[-1])
346
+ generator = _GENERATORS.get(tp)
347
+ if tp is str and generator is _generate_str:
348
+ # Only while str uses the default generator, so register(str, ...) still wins.
349
+ generator = NAME_GENERATORS.get(path.rsplit(".", 1)[-1], generator)
350
+ if generator is not None:
351
+ # Constraints only steer built-in generators; a registered one is used as is.
352
+ if constraints is not None and _GENERATORS.get(tp) is _DEFAULT_GENERATORS.get(tp):
353
+ return _generate_constrained(tp, generator, constraints, seed, path)
354
+ return generator(random.Random(seed))
355
+ origin = get_origin(tp)
356
+ if origin is Literal:
357
+ return random.Random(seed).choice(get_args(tp))
358
+ if origin is None and isinstance(tp, type) and issubclass(tp, Enum):
359
+ members = list(tp)
360
+ if not members:
361
+ raise GenerationError(
362
+ f"cannot generate field '{path}' of type {tp.__name__}: enum has no members. "
363
+ f"Add a member, or pass it as an override: {_override_hint(path)}"
364
+ )
365
+ return random.Random(seed).choice(members)
366
+ if _is_buildable(tp):
367
+ return _generate_object(tp, seed, path, ancestors, {})
368
+ kind = origin or tp
369
+ if kind in _COLLECTION_HINTS:
370
+ return _generate_collection(kind, tp, seed, path, ancestors, constraints)
371
+ if origin in (Union, types.UnionType):
372
+ args = get_args(tp)
373
+ inner = [arg for arg in args if arg is not type(None)]
374
+ if len(inner) < len(args):
375
+ core = inner[0] if len(inner) == 1 else Union[tuple(inner)] # noqa: UP007 - built at runtime
376
+ return _generate_optional(core, seed, path, ancestors, constraints)
377
+ return _generate_union(inner, seed, path, ancestors, constraints)
378
+ raise GenerationError(
379
+ f"cannot generate field '{path}' of type {_type_name(tp)}. "
380
+ f"Pass it as an override: {_override_hint(path)}"
381
+ )
382
+
383
+
384
+ def _check_build_args(func: str, cls: type[Any], overrides: dict[str, object]) -> None:
385
+ if not _is_buildable(cls):
386
+ raise PolarFactoryError(
387
+ f"{func}() expects a dataclass or a class with __init__, got {cls!r}"
388
+ )
389
+
390
+ fields = _field_specs(cls)
391
+ init_by_name = {field.name: field.init for field in fields}
392
+ for name in overrides:
393
+ if name not in init_by_name:
394
+ valid = ", ".join(field.name for field in fields if field.init)
395
+ raise PolarFactoryError(
396
+ f"{func}() got unknown field '{name}' for {cls.__name__}; valid fields: {valid}"
397
+ )
398
+ if not init_by_name[name]:
399
+ raise PolarFactoryError(
400
+ f"{func}() cannot set field '{cls.__name__}.{name}': it has init=False. "
401
+ "Set it on the built object instead"
402
+ )
403
+
404
+
405
+ def _base_seed(seed: int | None) -> int:
406
+ return random.Random().getrandbits(64) if seed is None else seed
407
+
408
+
409
+ def build(cls: type[T], /, *, seed: int | None = None, **overrides: object) -> T:
410
+ _check_build_args("build", cls, overrides)
411
+ root_seed = str(_base_seed(seed))
412
+ return cast(T, _generate_object(cls, root_seed, cls.__name__, (), overrides))
413
+
414
+
415
+ def build_many(cls: type[T], n: int, /, *, seed: int | None = None, **overrides: object) -> list[T]:
416
+ _check_build_args("build_many", cls, overrides)
417
+ if n < 0:
418
+ raise PolarFactoryError(f"build_many() expects n >= 0, got {n}")
419
+ base_seed = _base_seed(seed)
420
+ return [
421
+ cast(T, _generate_object(cls, f"{base_seed}:{i}", cls.__name__, (), overrides))
422
+ for i in range(n)
423
+ ]
@@ -0,0 +1,153 @@
1
+ """Value generation under field constraints (bounds, lengths, regex patterns)."""
2
+
3
+ import dataclasses
4
+ import importlib
5
+ import math
6
+ import random
7
+ import string
8
+ import sys
9
+ from decimal import Decimal
10
+ from typing import Any
11
+
12
+ # shortcut: the stdlib regex parser is private API (sre_parse, renamed re._parser in 3.11).
13
+ # Replace it with a small own parser if a Python release changes its token format.
14
+ sre_parse: Any = importlib.import_module(
15
+ "re._parser" if sys.version_info >= (3, 11) else "sre_parse"
16
+ )
17
+
18
+ _NAMES = ("ge", "gt", "le", "lt", "min_length", "max_length", "pattern")
19
+ _SPAN = 10_000
20
+ _UNBOUNDED_REPEAT = 3
21
+ _CATEGORIES = {
22
+ sre_parse.CATEGORY_DIGIT: string.digits,
23
+ sre_parse.CATEGORY_WORD: string.ascii_letters + string.digits + "_",
24
+ sre_parse.CATEGORY_SPACE: " ",
25
+ }
26
+ _ANY_CHAR = string.ascii_letters + string.digits
27
+
28
+
29
+ @dataclasses.dataclass(frozen=True)
30
+ class Constraints:
31
+ ge: Any = None
32
+ gt: Any = None
33
+ le: Any = None
34
+ lt: Any = None
35
+ min_length: int | None = None
36
+ max_length: int | None = None
37
+ pattern: str | None = None
38
+
39
+ @classmethod
40
+ def from_metadata(cls, metadata: list[Any]) -> "Constraints | None":
41
+ # Duck-typed so annotated_types and pydantic metadata work without importing either.
42
+ found = {
43
+ name: getattr(item, name)
44
+ for item in metadata
45
+ for name in _NAMES
46
+ if getattr(item, name, None) is not None
47
+ }
48
+ return cls(**found) if found else None
49
+
50
+ def size_range(self, low: int, high: int) -> tuple[int, int]:
51
+ lo = self.min_length if self.min_length is not None else low
52
+ hi = self.max_length if self.max_length is not None else max(lo, high)
53
+ return min(lo, hi), hi
54
+
55
+
56
+ class UnsupportedConstraint(ValueError):
57
+ pass
58
+
59
+
60
+ def generate_number(tp: type, constraints: Constraints, rng: random.Random) -> Any:
61
+ step = 1.0 if tp is int else 0.01
62
+ lo = _bound(constraints.ge, constraints.gt, step)
63
+ hi = _bound(constraints.le, constraints.lt, -step)
64
+ if lo is None and hi is None:
65
+ lo, hi = 0.0, float(_SPAN)
66
+ elif lo is None:
67
+ lo = hi - _SPAN if hi < 0 else 0.0 # type: ignore[operator]
68
+ elif hi is None:
69
+ hi = max(lo, 0.0) + _SPAN
70
+ assert lo is not None and hi is not None
71
+ if tp is int:
72
+ low, high = math.ceil(lo), math.floor(hi)
73
+ if low > high:
74
+ raise UnsupportedConstraint("no int fits these bounds")
75
+ return rng.randint(low, high)
76
+ if lo > hi:
77
+ raise UnsupportedConstraint("no number fits these bounds")
78
+ value = min(max(round(rng.uniform(lo, hi), 2), lo), hi)
79
+ return Decimal(str(value)) if tp is Decimal else value
80
+
81
+
82
+ def _bound(inclusive: Any, exclusive: Any, step: float) -> float | None:
83
+ if inclusive is not None:
84
+ return float(inclusive)
85
+ if exclusive is not None:
86
+ return float(exclusive) + step
87
+ return None
88
+
89
+
90
+ def describe(constraints: Constraints) -> str:
91
+ return ", ".join(
92
+ f"{field.name}={getattr(constraints, field.name)!r}"
93
+ for field in dataclasses.fields(constraints)
94
+ if getattr(constraints, field.name) is not None
95
+ )
96
+
97
+
98
+ def fit_length(value: str, constraints: Constraints, rng: random.Random) -> str:
99
+ lo, hi = constraints.size_range(0, max(len(value), 8))
100
+ if lo <= len(value) <= hi:
101
+ return value
102
+ return "".join(rng.choices(string.ascii_lowercase, k=rng.randint(lo, hi)))
103
+
104
+
105
+ def generate_from_pattern(pattern: str, rng: random.Random) -> str:
106
+ try:
107
+ return _emit(sre_parse.parse(pattern), rng)
108
+ except UnsupportedConstraint as exc:
109
+ raise UnsupportedConstraint(f"pattern {pattern!r} uses {exc}") from exc
110
+
111
+
112
+ def _emit(items: Any, rng: random.Random) -> str:
113
+ out = []
114
+ for op, arg in items:
115
+ if op is sre_parse.LITERAL:
116
+ out.append(chr(arg))
117
+ elif op is sre_parse.ANY:
118
+ out.append(rng.choice(_ANY_CHAR))
119
+ elif op is sre_parse.IN:
120
+ out.append(rng.choice(_charset(arg)))
121
+ elif op is sre_parse.CATEGORY:
122
+ out.append(rng.choice(_category(arg)))
123
+ elif op in (sre_parse.MAX_REPEAT, sre_parse.MIN_REPEAT):
124
+ low, high, sub = arg
125
+ high = low + _UNBOUNDED_REPEAT if high is sre_parse.MAXREPEAT else high
126
+ out.extend(_emit(sub, rng) for _ in range(rng.randint(low, high)))
127
+ elif op is sre_parse.SUBPATTERN:
128
+ out.append(_emit(arg[-1], rng))
129
+ elif op is sre_parse.BRANCH:
130
+ out.append(_emit(rng.choice(arg[1]), rng))
131
+ elif op is not sre_parse.AT: # anchors like ^ and $ produce no characters
132
+ raise UnsupportedConstraint(f"unsupported regex feature {str(op).lower()}")
133
+ return "".join(out)
134
+
135
+
136
+ def _charset(items: Any) -> str:
137
+ chars = []
138
+ for op, arg in items:
139
+ if op is sre_parse.LITERAL:
140
+ chars.append(chr(arg))
141
+ elif op is sre_parse.RANGE:
142
+ chars.extend(chr(code) for code in range(arg[0], arg[1] + 1))
143
+ elif op is sre_parse.CATEGORY:
144
+ chars.append(_category(arg))
145
+ else:
146
+ raise UnsupportedConstraint(f"unsupported regex feature {str(op).lower()}")
147
+ return "".join(chars)
148
+
149
+
150
+ def _category(category: Any) -> str:
151
+ if category not in _CATEGORIES:
152
+ raise UnsupportedConstraint(f"unsupported regex feature {str(category).lower()}")
153
+ return _CATEGORIES[category]
@@ -0,0 +1,94 @@
1
+ """Realistic str values picked from the field name, e.g. a field called `email` gets an email."""
2
+
3
+ import random
4
+ from collections.abc import Callable
5
+
6
+ FIRST_NAMES = (
7
+ "Ana", "Bruno", "Carla", "David", "Elena", "Fatima", "Grace", "Hiro", "Isabel", "James",
8
+ "Kenji", "Laura", "Mateo", "Nadia", "Omar", "Priya", "Quentin", "Rosa", "Samuel", "Tara",
9
+ ) # fmt: skip
10
+ LAST_NAMES = (
11
+ "Silva", "Smith", "Garcia", "Kim", "Müller", "Rossi", "Nguyen", "Okafor", "Costa", "Tanaka",
12
+ "Novak", "Haddad", "Johansson", "Patel", "Dubois", "Moreno", "Kowalski", "Ali", "Brown", "Lee",
13
+ ) # fmt: skip
14
+ CITIES = (
15
+ "Lisbon", "São Paulo", "Tokyo", "Nairobi", "Berlin", "Toronto", "Mumbai", "Sydney",
16
+ "Mexico City", "Seoul", "Cairo", "Paris", "Buenos Aires", "Oslo", "Lagos",
17
+ ) # fmt: skip
18
+ COUNTRIES = (
19
+ "Portugal", "Brazil", "Japan", "Kenya", "Germany", "Canada", "India", "Australia",
20
+ "Mexico", "South Korea", "Egypt", "France", "Argentina", "Norway", "Nigeria",
21
+ ) # fmt: skip
22
+ WORDS = (
23
+ "amber", "bright", "cloud", "delta", "echo", "field", "garden", "harbor", "island", "jade",
24
+ "kite", "lemon", "maple", "north", "ocean", "pixel", "quiet", "river", "silver", "timber",
25
+ "urban", "velvet", "willow", "yellow", "zephyr", "stone", "spark", "meadow", "copper", "light",
26
+ ) # fmt: skip
27
+ # Reserved for documentation (RFC 2606), so generated addresses never reach a real inbox.
28
+ DOMAINS = ("example.com", "example.org", "example.net")
29
+
30
+
31
+ def _ascii(text: str) -> str:
32
+ return text.lower().replace("ü", "u").replace(" ", "")
33
+
34
+
35
+ def _first_name(rng: random.Random) -> str:
36
+ return rng.choice(FIRST_NAMES)
37
+
38
+
39
+ def _last_name(rng: random.Random) -> str:
40
+ return rng.choice(LAST_NAMES)
41
+
42
+
43
+ def _name(rng: random.Random) -> str:
44
+ return f"{_first_name(rng)} {_last_name(rng)}"
45
+
46
+
47
+ def _email(rng: random.Random) -> str:
48
+ first, last = _ascii(_first_name(rng)), _ascii(_last_name(rng))
49
+ return f"{first}.{last}{rng.randint(1, 99)}@{rng.choice(DOMAINS)}"
50
+
51
+
52
+ def _username(rng: random.Random) -> str:
53
+ return f"{_ascii(_first_name(rng))}_{rng.choice(WORDS)}{rng.randint(1, 999)}"
54
+
55
+
56
+ def _words(rng: random.Random, low: int, high: int) -> list[str]:
57
+ return [rng.choice(WORDS) for _ in range(rng.randint(low, high))]
58
+
59
+
60
+ def _slug(rng: random.Random) -> str:
61
+ return "-".join(_words(rng, 2, 4))
62
+
63
+
64
+ def _url(rng: random.Random) -> str:
65
+ return f"https://{rng.choice(DOMAINS)}/{_slug(rng)}"
66
+
67
+
68
+ def _phone(rng: random.Random) -> str:
69
+ # 555-0100 to 555-0199 is reserved for fiction, so no real person gets called.
70
+ return f"+1-555-01{rng.randint(0, 99):02d}"
71
+
72
+
73
+ def _title(rng: random.Random) -> str:
74
+ return " ".join(_words(rng, 2, 5)).capitalize()
75
+
76
+
77
+ def _description(rng: random.Random) -> str:
78
+ return " ".join(_words(rng, 8, 15)).capitalize() + "."
79
+
80
+
81
+ NAME_GENERATORS: dict[str, Callable[[random.Random], str]] = {
82
+ "email": _email,
83
+ "name": _name,
84
+ "first_name": _first_name,
85
+ "last_name": _last_name,
86
+ "city": lambda rng: rng.choice(CITIES),
87
+ "country": lambda rng: rng.choice(COUNTRIES),
88
+ "url": _url,
89
+ "phone": _phone,
90
+ "username": _username,
91
+ "title": _title,
92
+ "description": _description,
93
+ "slug": _slug,
94
+ }
@@ -0,0 +1,31 @@
1
+ """Pydantic v2 support. Only imported once the user has imported pydantic themselves."""
2
+
3
+ from typing import Any
4
+
5
+ import pydantic
6
+
7
+ from polarfactory._build import _FieldSpec
8
+ from polarfactory._constraints import Constraints
9
+ from polarfactory.errors import GenerationError
10
+
11
+
12
+ def is_model(tp: Any) -> bool:
13
+ return isinstance(tp, type) and issubclass(tp, pydantic.BaseModel)
14
+
15
+
16
+ def field_specs(cls: type[pydantic.BaseModel]) -> list[_FieldSpec]:
17
+ return [
18
+ _FieldSpec(name, info.annotation, constraints=Constraints.from_metadata(info.metadata))
19
+ for name, info in cls.model_fields.items()
20
+ ]
21
+
22
+
23
+ def construct(cls: type[pydantic.BaseModel], values: dict[str, Any], path: str) -> Any:
24
+ try:
25
+ # by_name so fields with an alias accept the attribute name we generated under.
26
+ return cls.model_validate(values, by_name=True)
27
+ except pydantic.ValidationError as exc:
28
+ raise GenerationError(
29
+ f"values for '{path}' failed {cls.__name__} validation. "
30
+ f"Pass the failing fields as overrides. Details:\n{exc}"
31
+ ) from exc
@@ -0,0 +1,6 @@
1
+ class PolarFactoryError(TypeError):
2
+ """Base class for every error raised by polarfactory."""
3
+
4
+
5
+ class GenerationError(PolarFactoryError):
6
+ """A field value could not be generated."""
File without changes