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.
- polarfactory-0.1.0/LICENSE +21 -0
- polarfactory-0.1.0/PKG-INFO +82 -0
- polarfactory-0.1.0/README.md +57 -0
- polarfactory-0.1.0/pyproject.toml +69 -0
- polarfactory-0.1.0/pyproject.toml.orig +63 -0
- polarfactory-0.1.0/src/polarfactory/__init__.py +5 -0
- polarfactory-0.1.0/src/polarfactory/_build.py +423 -0
- polarfactory-0.1.0/src/polarfactory/_constraints.py +153 -0
- polarfactory-0.1.0/src/polarfactory/_names.py +94 -0
- polarfactory-0.1.0/src/polarfactory/_pydantic.py +31 -0
- polarfactory-0.1.0/src/polarfactory/errors.py +6 -0
- polarfactory-0.1.0/src/polarfactory/py.typed +0 -0
|
@@ -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,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
|
|
File without changes
|