capper 0.3.0__tar.gz → 0.4.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.
- {capper-0.3.0 → capper-0.4.0}/PKG-INFO +21 -4
- {capper-0.3.0 → capper-0.4.0}/README.md +19 -3
- {capper-0.3.0 → capper-0.4.0}/capper/__init__.py +1 -1
- capper-0.4.0/capper/base.py +127 -0
- {capper-0.3.0 → capper-0.4.0}/capper/cli.py +15 -20
- capper-0.4.0/capper/registry.py +26 -0
- {capper-0.3.0 → capper-0.4.0}/capper/strategies.py +5 -47
- capper-0.4.0/capper/tests/benchmark_core.py +56 -0
- capper-0.4.0/capper/tests/test_cli.py +78 -0
- capper-0.4.0/capper/tests/test_edge_cases.py +141 -0
- capper-0.4.0/capper/tests/test_thread_safety.py +102 -0
- {capper-0.3.0 → capper-0.4.0}/capper/tests/test_types.py +23 -8
- {capper-0.3.0 → capper-0.4.0}/capper.egg-info/PKG-INFO +21 -4
- {capper-0.3.0 → capper-0.4.0}/capper.egg-info/SOURCES.txt +4 -0
- {capper-0.3.0 → capper-0.4.0}/capper.egg-info/requires.txt +1 -0
- {capper-0.3.0 → capper-0.4.0}/pyproject.toml +10 -1
- capper-0.3.0/capper/base.py +0 -100
- capper-0.3.0/capper/tests/test_cli.py +0 -41
- {capper-0.3.0 → capper-0.4.0}/capper/barcode.py +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper/color.py +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper/commerce.py +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper/date_time.py +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper/examples/user_factory.py +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper/file.py +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper/finance.py +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper/geo.py +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper/internet.py +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper/misc.py +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper/person.py +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper/phone.py +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper/tests/__init__.py +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper/tests/conftest.py +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper/tests/test_docs_examples.py +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper/tests/test_hypothesis_strategies.py +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper/tests/test_polyfactory_integration.py +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper/text.py +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper.egg-info/dependency_links.txt +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper.egg-info/entry_points.txt +0 -0
- {capper-0.3.0 → capper-0.4.0}/capper.egg-info/top_level.txt +0 -0
- {capper-0.3.0 → capper-0.4.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: capper
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Semantic, typed wrappers for Faker with automatic Polyfactory integration
|
|
5
5
|
Author-email: Odos Matthews <odosmatthews@gmail.com>
|
|
6
6
|
Project-URL: Documentation, https://github.com/eddiethedean/capper#readme
|
|
@@ -15,6 +15,7 @@ Provides-Extra: hypothesis
|
|
|
15
15
|
Requires-Dist: hypothesis>=6.0; extra == "hypothesis"
|
|
16
16
|
Provides-Extra: dev
|
|
17
17
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-benchmark>=4.0; extra == "dev"
|
|
18
19
|
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
19
20
|
Requires-Dist: pytest-xdist>=3.0; extra == "dev"
|
|
20
21
|
Requires-Dist: pydantic>=2.0; extra == "dev"
|
|
@@ -29,7 +30,7 @@ Requires-Dist: mkdocstrings[python]>=0.24; extra == "docs"
|
|
|
29
30
|
|
|
30
31
|
[](https://pypi.org/project/capper/)
|
|
31
32
|
[](https://pypi.org/project/capper/)
|
|
32
|
-
[](https://github.com/eddiethedean/capper/actions/workflows/ci.yml)
|
|
33
|
+
[)](https://github.com/eddiethedean/capper/actions/workflows/ci.yml)
|
|
33
34
|
[](https://docs.astral.sh/ruff/)
|
|
34
35
|
[](https://mypy-lang.org/)
|
|
35
36
|
|
|
@@ -37,11 +38,21 @@ Semantic, typed wrappers for [Faker](https://faker.readthedocs.io/) with automat
|
|
|
37
38
|
|
|
38
39
|
**Source:** [github.com/eddiethedean/capper](https://github.com/eddiethedean/capper)
|
|
39
40
|
|
|
41
|
+
## CI pipeline
|
|
42
|
+
|
|
43
|
+
The `ci.yml` workflow runs on pushes and PRs to `main` and includes:
|
|
44
|
+
|
|
45
|
+
- **Linting:** `ruff check .` and `ruff format --check .`
|
|
46
|
+
- **Type checking:** `mypy capper`
|
|
47
|
+
- **Tests:** `pytest -n auto capper/tests -v -m "not benchmark" --cov=capper --cov-report=term-missing --cov-fail-under=98`
|
|
48
|
+
- **Docs:** `mkdocs build --strict`
|
|
49
|
+
|
|
40
50
|
## Why Capper?
|
|
41
51
|
|
|
42
52
|
- **Zero config** — Import a type; Polyfactory uses the right Faker provider. No manual registration.
|
|
43
53
|
- **Typed** — Use `Name`, `Email`, `PhoneNumber`, etc. in your models for clear intent and IDE support.
|
|
44
54
|
- **Multi-backend** — Works with Pydantic, dataclasses, attrs, and other [Polyfactory-supported](https://polyfactory.litestar.dev/) model types.
|
|
55
|
+
- **Thread-safe** — Per-thread Faker via a proxy; seeding and locales are isolated per thread, so concurrent tests are safe.
|
|
45
56
|
- **Optional Pydantic** — Install `capper` alone for dataclasses/attrs; add `capper[pydantic]` when you use Pydantic models.
|
|
46
57
|
|
|
47
58
|
## Install
|
|
@@ -151,6 +162,12 @@ Use `-n`/`--count` for the number of rows and `-s`/`--seed` for reproducible out
|
|
|
151
162
|
|
|
152
163
|
Capper targets **Python 3.10+**, **Faker >= 20.0**, and **Polyfactory >= 2.0**. For version ranges, upgrade guidance, and the deprecation policy, see [Compatibility](docs/compatibility.md).
|
|
153
164
|
|
|
165
|
+
## What's new in 0.4.0
|
|
166
|
+
|
|
167
|
+
- **Thread safety:** Capper is now thread-safe via a per-thread Faker proxy; `seed()` and `use_faker()` only affect the current thread.
|
|
168
|
+
- **Reliability and coverage:** Phase 9 adds a coverage gate (≥ 98% for `capper/`), targeted edge-case tests, and a lightweight performance check in CI.
|
|
169
|
+
- **Tooling and docs:** CI runs Ruff, mypy, tests (with coverage gate), and a strict MkDocs build on all supported Python versions; docs and roadmap have been updated to reflect Phase 9.
|
|
170
|
+
|
|
154
171
|
## Development
|
|
155
172
|
|
|
156
173
|
```bash
|
|
@@ -160,7 +177,7 @@ pytest capper/tests
|
|
|
160
177
|
|
|
161
178
|
Lint and type-check: `ruff check .`, `ruff format .`, `mypy capper`.
|
|
162
179
|
|
|
163
|
-
Run tests with coverage: `pytest capper/tests --cov=capper --cov-report=term-missing`.
|
|
180
|
+
Run tests with coverage: `pytest capper/tests --cov=capper --cov-report=term-missing`. CI requires coverage ≥ 98% for the `capper/` package (`--cov-fail-under=98`).
|
|
164
181
|
|
|
165
182
|
**Reproducibility:** Capper and Polyfactory share the same Faker instance, so one seed controls both capper types and built-in types (`str`, `int`, etc.):
|
|
166
183
|
|
|
@@ -191,7 +208,7 @@ Releases are built and published to PyPI via [GitHub Actions](https://github.com
|
|
|
191
208
|
|
|
192
209
|
1. Update [CHANGELOG.md](CHANGELOG.md): move Unreleased entries into a new version section and date it.
|
|
193
210
|
2. Add a `PYPI_API_TOKEN` secret (PyPI API token) to the repo.
|
|
194
|
-
3. Create a GitHub release (tag e.g. `v0.
|
|
211
|
+
3. Create a GitHub release (tag e.g. `v0.4.0`). The workflow runs tests, builds the package, and uploads to PyPI.
|
|
195
212
|
|
|
196
213
|
To build and upload manually: `pip install build twine`, `python -m build`, `twine upload dist/*`.
|
|
197
214
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://pypi.org/project/capper/)
|
|
4
4
|
[](https://pypi.org/project/capper/)
|
|
5
|
-
[](https://github.com/eddiethedean/capper/actions/workflows/ci.yml)
|
|
5
|
+
[)](https://github.com/eddiethedean/capper/actions/workflows/ci.yml)
|
|
6
6
|
[](https://docs.astral.sh/ruff/)
|
|
7
7
|
[](https://mypy-lang.org/)
|
|
8
8
|
|
|
@@ -10,11 +10,21 @@ Semantic, typed wrappers for [Faker](https://faker.readthedocs.io/) with automat
|
|
|
10
10
|
|
|
11
11
|
**Source:** [github.com/eddiethedean/capper](https://github.com/eddiethedean/capper)
|
|
12
12
|
|
|
13
|
+
## CI pipeline
|
|
14
|
+
|
|
15
|
+
The `ci.yml` workflow runs on pushes and PRs to `main` and includes:
|
|
16
|
+
|
|
17
|
+
- **Linting:** `ruff check .` and `ruff format --check .`
|
|
18
|
+
- **Type checking:** `mypy capper`
|
|
19
|
+
- **Tests:** `pytest -n auto capper/tests -v -m "not benchmark" --cov=capper --cov-report=term-missing --cov-fail-under=98`
|
|
20
|
+
- **Docs:** `mkdocs build --strict`
|
|
21
|
+
|
|
13
22
|
## Why Capper?
|
|
14
23
|
|
|
15
24
|
- **Zero config** — Import a type; Polyfactory uses the right Faker provider. No manual registration.
|
|
16
25
|
- **Typed** — Use `Name`, `Email`, `PhoneNumber`, etc. in your models for clear intent and IDE support.
|
|
17
26
|
- **Multi-backend** — Works with Pydantic, dataclasses, attrs, and other [Polyfactory-supported](https://polyfactory.litestar.dev/) model types.
|
|
27
|
+
- **Thread-safe** — Per-thread Faker via a proxy; seeding and locales are isolated per thread, so concurrent tests are safe.
|
|
18
28
|
- **Optional Pydantic** — Install `capper` alone for dataclasses/attrs; add `capper[pydantic]` when you use Pydantic models.
|
|
19
29
|
|
|
20
30
|
## Install
|
|
@@ -124,6 +134,12 @@ Use `-n`/`--count` for the number of rows and `-s`/`--seed` for reproducible out
|
|
|
124
134
|
|
|
125
135
|
Capper targets **Python 3.10+**, **Faker >= 20.0**, and **Polyfactory >= 2.0**. For version ranges, upgrade guidance, and the deprecation policy, see [Compatibility](docs/compatibility.md).
|
|
126
136
|
|
|
137
|
+
## What's new in 0.4.0
|
|
138
|
+
|
|
139
|
+
- **Thread safety:** Capper is now thread-safe via a per-thread Faker proxy; `seed()` and `use_faker()` only affect the current thread.
|
|
140
|
+
- **Reliability and coverage:** Phase 9 adds a coverage gate (≥ 98% for `capper/`), targeted edge-case tests, and a lightweight performance check in CI.
|
|
141
|
+
- **Tooling and docs:** CI runs Ruff, mypy, tests (with coverage gate), and a strict MkDocs build on all supported Python versions; docs and roadmap have been updated to reflect Phase 9.
|
|
142
|
+
|
|
127
143
|
## Development
|
|
128
144
|
|
|
129
145
|
```bash
|
|
@@ -133,7 +149,7 @@ pytest capper/tests
|
|
|
133
149
|
|
|
134
150
|
Lint and type-check: `ruff check .`, `ruff format .`, `mypy capper`.
|
|
135
151
|
|
|
136
|
-
Run tests with coverage: `pytest capper/tests --cov=capper --cov-report=term-missing`.
|
|
152
|
+
Run tests with coverage: `pytest capper/tests --cov=capper --cov-report=term-missing`. CI requires coverage ≥ 98% for the `capper/` package (`--cov-fail-under=98`).
|
|
137
153
|
|
|
138
154
|
**Reproducibility:** Capper and Polyfactory share the same Faker instance, so one seed controls both capper types and built-in types (`str`, `int`, etc.):
|
|
139
155
|
|
|
@@ -164,7 +180,7 @@ Releases are built and published to PyPI via [GitHub Actions](https://github.com
|
|
|
164
180
|
|
|
165
181
|
1. Update [CHANGELOG.md](CHANGELOG.md): move Unreleased entries into a new version section and date it.
|
|
166
182
|
2. Add a `PYPI_API_TOKEN` secret (PyPI API token) to the repo.
|
|
167
|
-
3. Create a GitHub release (tag e.g. `v0.
|
|
183
|
+
3. Create a GitHub release (tag e.g. `v0.4.0`). The workflow runs tests, builds the package, and uploads to PyPI.
|
|
168
184
|
|
|
169
185
|
To build and upload manually: `pip install build twine`, `python -m build`, `twine upload dist/*`.
|
|
170
186
|
|
|
@@ -11,7 +11,7 @@ try:
|
|
|
11
11
|
|
|
12
12
|
__version__ = _version("capper")
|
|
13
13
|
except Exception: # Package not installed (e.g. dev tree) or metadata missing
|
|
14
|
-
__version__ = "0.
|
|
14
|
+
__version__ = "0.4.0"
|
|
15
15
|
|
|
16
16
|
from .barcode import EAN8, EAN13
|
|
17
17
|
from .base import FakerType, faker, seed, use_faker
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""FakerType base class and per-thread Faker proxy with automatic Polyfactory registration.
|
|
2
|
+
|
|
3
|
+
The module-level ``faker`` is a proxy to a per-thread Faker instance. Each thread has its own
|
|
4
|
+
Faker; ``seed(n)`` and ``use_faker(instance)`` only affect the current thread. Polyfactory's
|
|
5
|
+
BaseFactory.__faker__ is set to the same proxy, so one seed per thread controls both capper
|
|
6
|
+
types and built-in types. Thread-safe for concurrent use from multiple threads.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
from faker import Faker
|
|
13
|
+
from polyfactory.factories.base import BaseFactory
|
|
14
|
+
|
|
15
|
+
_faker_local = threading.local()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_faker() -> Faker:
|
|
19
|
+
"""Return the current thread's Faker instance, creating one if needed."""
|
|
20
|
+
try:
|
|
21
|
+
instance = _faker_local.current
|
|
22
|
+
except AttributeError:
|
|
23
|
+
instance = None
|
|
24
|
+
if instance is None:
|
|
25
|
+
instance = Faker()
|
|
26
|
+
_faker_local.current = instance
|
|
27
|
+
return cast(Faker, instance)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _FakerProxy:
|
|
31
|
+
"""Proxy that forwards all attribute access to the current thread's Faker."""
|
|
32
|
+
|
|
33
|
+
__slots__ = ()
|
|
34
|
+
|
|
35
|
+
def __getattr__(self, name: str) -> Any:
|
|
36
|
+
return getattr(_get_faker(), name)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Single process-wide proxy; each thread gets its own Faker via _get_faker().
|
|
40
|
+
faker: Faker = _FakerProxy() # type: ignore[assignment]
|
|
41
|
+
BaseFactory.__faker__ = faker
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def seed(seed_value: int) -> None:
|
|
45
|
+
"""Seed the current thread's Faker instance for reproducible data.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
seed_value: Integer seed; same value produces the same sequence of values.
|
|
49
|
+
"""
|
|
50
|
+
_get_faker().seed_instance(seed_value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def use_faker(instance: Faker | None) -> None:
|
|
54
|
+
"""Use a custom Faker instance for the current thread only.
|
|
55
|
+
|
|
56
|
+
Sets the Faker used by Capper and Polyfactory for this thread (e.g. a
|
|
57
|
+
locale-specific Faker). Other threads are unaffected. Call before building
|
|
58
|
+
any models in this thread. Pass None to reset this thread to a new default
|
|
59
|
+
Faker (e.g. after temporarily using a custom instance).
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
instance: The Faker instance to use (e.g. Faker('de_DE')), or None to reset.
|
|
63
|
+
"""
|
|
64
|
+
if instance is None or instance is faker:
|
|
65
|
+
try:
|
|
66
|
+
del _faker_local.current
|
|
67
|
+
except AttributeError:
|
|
68
|
+
pass
|
|
69
|
+
else:
|
|
70
|
+
_faker_local.current = instance
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _install_pydantic_schema() -> None:
|
|
74
|
+
"""If Pydantic is available, attach __get_pydantic_core_schema__ to FakerType."""
|
|
75
|
+
try:
|
|
76
|
+
from pydantic import GetCoreSchemaHandler
|
|
77
|
+
from pydantic_core import CoreSchema, core_schema
|
|
78
|
+
except ImportError:
|
|
79
|
+
return # pragma: no cover — pydantic not installed
|
|
80
|
+
|
|
81
|
+
def __get_pydantic_core_schema__(
|
|
82
|
+
cls, source_type: Any, handler: GetCoreSchemaHandler
|
|
83
|
+
) -> CoreSchema:
|
|
84
|
+
"""Validate as str then coerce to the FakerType subclass."""
|
|
85
|
+
return core_schema.no_info_after_validator_function(cls, handler(str))
|
|
86
|
+
|
|
87
|
+
FakerType.__get_pydantic_core_schema__ = classmethod(__get_pydantic_core_schema__) # type: ignore[attr-defined]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class FakerType(str):
|
|
91
|
+
"""Base class for semantic Faker types; subclasses auto-register with Polyfactory.
|
|
92
|
+
|
|
93
|
+
Subclasses must set a non-empty ``faker_provider`` (the Faker method name).
|
|
94
|
+
Optional ``faker_kwargs`` is a dict of keyword arguments passed to that provider
|
|
95
|
+
(e.g. ``faker_kwargs = {"nb_words": 10}`` for ``sentence``).
|
|
96
|
+
When Hypothesis is installed, use ``st.from_type(YourFakerType)`` for property-based tests.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
faker_provider: str = ""
|
|
100
|
+
|
|
101
|
+
def __init_subclass__(cls, **kwargs: object) -> None:
|
|
102
|
+
super().__init_subclass__(**kwargs)
|
|
103
|
+
provider = getattr(cls, "faker_provider", None)
|
|
104
|
+
if provider:
|
|
105
|
+
_register(cls, provider)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_install_pydantic_schema()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _register(cls: type[FakerType], provider_name: str) -> None:
|
|
112
|
+
"""Register a FakerType subclass with Polyfactory so factories can generate values."""
|
|
113
|
+
if not hasattr(faker, provider_name):
|
|
114
|
+
raise AttributeError(
|
|
115
|
+
f"Faker has no provider {provider_name!r} (used by {cls.__name__}). "
|
|
116
|
+
"Check faker_provider on the type."
|
|
117
|
+
)
|
|
118
|
+
provider_fn = getattr(faker, provider_name)
|
|
119
|
+
if not callable(provider_fn):
|
|
120
|
+
raise TypeError(f"Faker.{provider_name} is not callable (used by {cls.__name__}).")
|
|
121
|
+
provider_kwargs = dict(getattr(cls, "faker_kwargs", None) or {})
|
|
122
|
+
|
|
123
|
+
def _provide() -> str:
|
|
124
|
+
value = getattr(faker, provider_name)(**provider_kwargs)
|
|
125
|
+
return str(value)
|
|
126
|
+
|
|
127
|
+
BaseFactory.add_provider(cls, _provide)
|
|
@@ -2,29 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import sys
|
|
5
|
-
from typing import Type
|
|
5
|
+
from typing import Mapping, Sequence, Type
|
|
6
6
|
|
|
7
7
|
import capper
|
|
8
8
|
|
|
9
9
|
from .base import FakerType, faker, seed
|
|
10
|
+
from .registry import build_type_registry
|
|
10
11
|
|
|
11
12
|
|
|
12
|
-
def _type_registry() ->
|
|
13
|
+
def _type_registry() -> Mapping[str, Type[FakerType]]:
|
|
13
14
|
"""Map type name -> FakerType subclass (only types with faker_provider)."""
|
|
14
|
-
|
|
15
|
-
for name in getattr(capper, "__all__", []):
|
|
16
|
-
if name in ("FakerType", "faker", "seed", "use_faker"):
|
|
17
|
-
continue
|
|
18
|
-
obj = getattr(capper, name, None)
|
|
19
|
-
provider = getattr(obj, "faker_provider", None)
|
|
20
|
-
if (
|
|
21
|
-
isinstance(obj, type)
|
|
22
|
-
and issubclass(obj, FakerType)
|
|
23
|
-
and isinstance(provider, str)
|
|
24
|
-
and len(provider) > 0
|
|
25
|
-
):
|
|
26
|
-
registry[name] = obj
|
|
27
|
-
return registry
|
|
15
|
+
return build_type_registry(capper)
|
|
28
16
|
|
|
29
17
|
|
|
30
18
|
def _generate_one(typ: Type[FakerType]) -> str:
|
|
@@ -35,6 +23,15 @@ def _generate_one(typ: Type[FakerType]) -> str:
|
|
|
35
23
|
return str(value)
|
|
36
24
|
|
|
37
25
|
|
|
26
|
+
def _generate_rows(types: Sequence[Type[FakerType]], count: int) -> list[str]:
|
|
27
|
+
"""Generate tab-separated rows for the given types."""
|
|
28
|
+
rows: list[str] = []
|
|
29
|
+
for _ in range(max(0, count)):
|
|
30
|
+
row = [_generate_one(t) for t in types]
|
|
31
|
+
rows.append("\t".join(row))
|
|
32
|
+
return rows
|
|
33
|
+
|
|
34
|
+
|
|
38
35
|
def cmd_generate(args: argparse.Namespace) -> int:
|
|
39
36
|
"""Run generate subcommand."""
|
|
40
37
|
registry = _type_registry()
|
|
@@ -49,10 +46,8 @@ def cmd_generate(args: argparse.Namespace) -> int:
|
|
|
49
46
|
if args.seed is not None:
|
|
50
47
|
seed(args.seed)
|
|
51
48
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
row = [_generate_one(t) for t in types]
|
|
55
|
-
print("\t".join(row))
|
|
49
|
+
for line in _generate_rows(types, args.count):
|
|
50
|
+
print(line)
|
|
56
51
|
return 0
|
|
57
52
|
|
|
58
53
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from types import ModuleType
|
|
4
|
+
from typing import Dict, Type
|
|
5
|
+
|
|
6
|
+
from .base import FakerType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_type_registry(module: ModuleType) -> dict[str, Type[FakerType]]:
|
|
10
|
+
"""Return a mapping of public name -> FakerType subclass for the given module.
|
|
11
|
+
|
|
12
|
+
Only names exported via ``module.__all__`` and backed by FakerType subclasses
|
|
13
|
+
with a non-empty ``faker_provider`` are included.
|
|
14
|
+
"""
|
|
15
|
+
registry: Dict[str, Type[FakerType]] = {}
|
|
16
|
+
for name in getattr(module, "__all__", []):
|
|
17
|
+
obj = getattr(module, name, None)
|
|
18
|
+
provider = getattr(obj, "faker_provider", None)
|
|
19
|
+
if (
|
|
20
|
+
isinstance(obj, type)
|
|
21
|
+
and issubclass(obj, FakerType)
|
|
22
|
+
and isinstance(provider, str)
|
|
23
|
+
and len(provider) > 0
|
|
24
|
+
):
|
|
25
|
+
registry[name] = obj
|
|
26
|
+
return registry
|
|
@@ -10,57 +10,15 @@ from typing import Type, TypeVar, cast
|
|
|
10
10
|
|
|
11
11
|
from hypothesis import strategies as st
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
import capper
|
|
14
|
+
|
|
14
15
|
from .base import FakerType, faker
|
|
15
|
-
from .
|
|
16
|
-
from .commerce import Company, Currency, Price, Product
|
|
17
|
-
from .date_time import Date, DateTime, Time
|
|
18
|
-
from .file import FileExtension, FileName, FilePath
|
|
19
|
-
from .finance import CreditCardExpiry, CreditCardNumber, CreditCardProvider
|
|
20
|
-
from .geo import Address, City, Country
|
|
21
|
-
from .internet import IP, URL, Email, UserName
|
|
22
|
-
from .misc import UUID
|
|
23
|
-
from .person import FirstName, Job, LastName, Name
|
|
24
|
-
from .phone import CountryCallingCode, PhoneNumber
|
|
25
|
-
from .text import Paragraph, Sentence
|
|
16
|
+
from .registry import build_type_registry
|
|
26
17
|
|
|
27
18
|
T = TypeVar("T", bound=FakerType)
|
|
28
19
|
|
|
29
|
-
# All built-in FakerType subclasses for registration
|
|
30
|
-
_BUILTIN_TYPES: tuple[type[FakerType], ...] = (
|
|
31
|
-
Address,
|
|
32
|
-
City,
|
|
33
|
-
Company,
|
|
34
|
-
Country,
|
|
35
|
-
CountryCallingCode,
|
|
36
|
-
CreditCardExpiry,
|
|
37
|
-
CreditCardNumber,
|
|
38
|
-
CreditCardProvider,
|
|
39
|
-
Currency,
|
|
40
|
-
Date,
|
|
41
|
-
DateTime,
|
|
42
|
-
EAN13,
|
|
43
|
-
EAN8,
|
|
44
|
-
Email,
|
|
45
|
-
FileExtension,
|
|
46
|
-
FileName,
|
|
47
|
-
FilePath,
|
|
48
|
-
FirstName,
|
|
49
|
-
HexColor,
|
|
50
|
-
IP,
|
|
51
|
-
Job,
|
|
52
|
-
LastName,
|
|
53
|
-
Name,
|
|
54
|
-
Paragraph,
|
|
55
|
-
PhoneNumber,
|
|
56
|
-
Price,
|
|
57
|
-
Product,
|
|
58
|
-
Sentence,
|
|
59
|
-
Time,
|
|
60
|
-
URL,
|
|
61
|
-
UUID,
|
|
62
|
-
UserName,
|
|
63
|
-
)
|
|
20
|
+
# All built-in FakerType subclasses for registration, discovered from the public API.
|
|
21
|
+
_BUILTIN_TYPES: tuple[type[FakerType], ...] = tuple(build_type_registry(capper).values())
|
|
64
22
|
|
|
65
23
|
|
|
66
24
|
def for_type(cls: Type[T]) -> st.SearchStrategy[T]:
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Performance benchmarks for core flows (ModelFactory, DataclassFactory, Hypothesis strategy).
|
|
2
|
+
|
|
3
|
+
Run with: pytest capper/tests/benchmark_core.py -v --benchmark-only
|
|
4
|
+
Exclude from default test run: pytest -m 'not benchmark'
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from capper import Email, Name
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.mark.benchmark
|
|
15
|
+
def test_bench_model_factory_build(benchmark: object) -> None:
|
|
16
|
+
"""Benchmark ModelFactory.build() with 2 Capper types (Name, Email)."""
|
|
17
|
+
from polyfactory.factories.pydantic_factory import ModelFactory
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
class User(BaseModel):
|
|
21
|
+
name: Name
|
|
22
|
+
email: Email
|
|
23
|
+
|
|
24
|
+
class UserFactory(ModelFactory[User]):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
benchmark(UserFactory.build)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.benchmark
|
|
31
|
+
def test_bench_dataclass_factory_build(benchmark: object) -> None:
|
|
32
|
+
"""Benchmark DataclassFactory.build() with 2 Capper types (Name, Email)."""
|
|
33
|
+
from polyfactory.factories import DataclassFactory
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Person:
|
|
37
|
+
name: Name
|
|
38
|
+
email: Email
|
|
39
|
+
|
|
40
|
+
class PersonFactory(DataclassFactory[Person]):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
benchmark(PersonFactory.build)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.benchmark
|
|
47
|
+
def test_bench_hypothesis_strategy_example(benchmark: object) -> None:
|
|
48
|
+
"""Benchmark drawing one value from st.from_type(Name) via strategy.example()."""
|
|
49
|
+
from capper import strategies
|
|
50
|
+
|
|
51
|
+
strategy = strategies.for_type(Name)
|
|
52
|
+
|
|
53
|
+
def draw_one() -> None:
|
|
54
|
+
strategy.example()
|
|
55
|
+
|
|
56
|
+
benchmark(draw_one)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Tests for the capper CLI (generate subcommand)."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
if sys.version_info >= (3, 10):
|
|
8
|
+
from capper import cli
|
|
9
|
+
else:
|
|
10
|
+
cli = None # type: ignore[assignment]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.mark.skipif(sys.version_info < (3, 10), reason="capper CLI uses Python 3.10+ syntax")
|
|
14
|
+
def test_cli_generate_exit_zero_and_non_empty_output() -> None:
|
|
15
|
+
"""Invoke generate subcommand; assert exit 0 and non-empty output for a known type."""
|
|
16
|
+
orig_argv = sys.argv
|
|
17
|
+
try:
|
|
18
|
+
sys.argv = ["capper", "generate", "Name", "--count", "1"]
|
|
19
|
+
exit_code = cli.main()
|
|
20
|
+
assert exit_code == 0
|
|
21
|
+
finally:
|
|
22
|
+
sys.argv = orig_argv
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.skipif(sys.version_info < (3, 10), reason="capper CLI uses Python 3.10+ syntax")
|
|
26
|
+
def test_cli_generate_produces_output(capsys: pytest.CaptureFixture[str]) -> None:
|
|
27
|
+
"""Generate subcommand prints one tab-separated value per row."""
|
|
28
|
+
orig_argv = sys.argv
|
|
29
|
+
try:
|
|
30
|
+
sys.argv = ["capper", "generate", "Name", "Email", "--count", "2"]
|
|
31
|
+
exit_code = cli.main()
|
|
32
|
+
assert exit_code == 0
|
|
33
|
+
out = capsys.readouterr().out
|
|
34
|
+
lines = [line for line in out.strip().splitlines() if line.strip()]
|
|
35
|
+
assert len(lines) == 2
|
|
36
|
+
for line in lines:
|
|
37
|
+
parts = line.split("\t")
|
|
38
|
+
assert len(parts) == 2
|
|
39
|
+
assert len(parts[0]) > 0 and len(parts[1]) > 0
|
|
40
|
+
finally:
|
|
41
|
+
sys.argv = orig_argv
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.skipif(sys.version_info < (3, 10), reason="capper CLI uses Python 3.10+ syntax")
|
|
45
|
+
def test_type_registry_includes_known_type() -> None:
|
|
46
|
+
"""Internal type registry used by CLI contains a known FakerType."""
|
|
47
|
+
assert hasattr(cli, "_type_registry")
|
|
48
|
+
registry = cli._type_registry()
|
|
49
|
+
assert "Name" in registry
|
|
50
|
+
name_type = registry["Name"]
|
|
51
|
+
assert getattr(name_type, "faker_provider", "") == "name"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.skipif(sys.version_info < (3, 10), reason="capper CLI uses Python 3.10+ syntax")
|
|
55
|
+
def test_cli_unknown_type_exits_nonzero_and_stderr(capsys: pytest.CaptureFixture[str]) -> None:
|
|
56
|
+
"""Passing an unknown type name prints to stderr and returns 1."""
|
|
57
|
+
orig_argv = sys.argv
|
|
58
|
+
try:
|
|
59
|
+
sys.argv = ["capper", "generate", "NonExistentType", "--count", "1"]
|
|
60
|
+
exit_code = cli.main()
|
|
61
|
+
assert exit_code == 1
|
|
62
|
+
err = capsys.readouterr().err
|
|
63
|
+
assert "Unknown type" in err
|
|
64
|
+
assert "NonExistentType" in err
|
|
65
|
+
finally:
|
|
66
|
+
sys.argv = orig_argv
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.mark.skipif(sys.version_info < (3, 10), reason="capper CLI uses Python 3.10+ syntax")
|
|
70
|
+
def test_cli_seed_used() -> None:
|
|
71
|
+
"""Generate with --seed runs and uses seed (deterministic output)."""
|
|
72
|
+
orig_argv = sys.argv
|
|
73
|
+
try:
|
|
74
|
+
sys.argv = ["capper", "generate", "Name", "--count", "2", "--seed", "42"]
|
|
75
|
+
exit_code = cli.main()
|
|
76
|
+
assert exit_code == 0
|
|
77
|
+
finally:
|
|
78
|
+
sys.argv = orig_argv
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Edge-case tests: version fallback, use_faker/thread, registration and strategy errors."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from capper import use_faker
|
|
9
|
+
from capper.base import FakerType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_version_fallback_when_metadata_missing() -> None:
|
|
13
|
+
"""When importlib.metadata.version raises, __version__ falls back to 0.4.0."""
|
|
14
|
+
with patch("importlib.metadata.version", side_effect=Exception("no metadata")):
|
|
15
|
+
if "capper" in sys.modules:
|
|
16
|
+
del sys.modules["capper"]
|
|
17
|
+
import capper as capper_mod
|
|
18
|
+
|
|
19
|
+
assert capper_mod.__version__ == "0.4.0"
|
|
20
|
+
# Reimport so later tests see the real version
|
|
21
|
+
if "capper" in sys.modules:
|
|
22
|
+
del sys.modules["capper"]
|
|
23
|
+
import capper # noqa: F401
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_use_faker_none_in_fresh_thread_covers_attribute_error() -> None:
|
|
27
|
+
"""use_faker(None) in a thread that never set faker hits the except AttributeError path."""
|
|
28
|
+
result: list[Exception | None] = []
|
|
29
|
+
|
|
30
|
+
def run_in_thread() -> None:
|
|
31
|
+
try:
|
|
32
|
+
use_faker(None)
|
|
33
|
+
result.append(None)
|
|
34
|
+
except Exception as e:
|
|
35
|
+
result.append(e)
|
|
36
|
+
|
|
37
|
+
import threading
|
|
38
|
+
|
|
39
|
+
t = threading.Thread(target=run_in_thread)
|
|
40
|
+
t.start()
|
|
41
|
+
t.join()
|
|
42
|
+
assert result == [None]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_register_invalid_provider_raises_attribute_error() -> None:
|
|
46
|
+
"""Defining a FakerType subclass with nonexistent faker_provider raises AttributeError."""
|
|
47
|
+
with pytest.raises(AttributeError, match="Faker has no provider .nonexistent_xyz."):
|
|
48
|
+
|
|
49
|
+
class BadType(FakerType):
|
|
50
|
+
faker_provider = "nonexistent_xyz"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_register_non_callable_provider_raises_type_error() -> None:
|
|
54
|
+
"""When Faker has the provider name but it is not callable, _register raises TypeError."""
|
|
55
|
+
from faker import Faker
|
|
56
|
+
|
|
57
|
+
my_faker = Faker()
|
|
58
|
+
with patch.object(my_faker, "name", "not_callable"):
|
|
59
|
+
use_faker(my_faker)
|
|
60
|
+
try:
|
|
61
|
+
with pytest.raises(TypeError, match="is not callable"):
|
|
62
|
+
|
|
63
|
+
class BadCallableType(FakerType):
|
|
64
|
+
faker_provider = "name"
|
|
65
|
+
|
|
66
|
+
finally:
|
|
67
|
+
use_faker(None)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_strategies_for_type_no_provider_raises_value_error() -> None:
|
|
71
|
+
"""strategies.for_type() on a class with no faker_provider raises ValueError."""
|
|
72
|
+
from capper import strategies
|
|
73
|
+
|
|
74
|
+
class NoProvider(FakerType):
|
|
75
|
+
faker_provider = ""
|
|
76
|
+
|
|
77
|
+
with pytest.raises(ValueError, match="has no faker_provider"):
|
|
78
|
+
strategies.for_type(NoProvider)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_strategies_for_type_invalid_provider_raises_attribute_error() -> None:
|
|
82
|
+
"""strategies.for_type() when current Faker has no such provider raises AttributeError."""
|
|
83
|
+
from capper import Name, strategies
|
|
84
|
+
|
|
85
|
+
class FakeFakerWithoutName:
|
|
86
|
+
"""Minimal Faker-like object missing the 'name' provider."""
|
|
87
|
+
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
use_faker(FakeFakerWithoutName())
|
|
91
|
+
try:
|
|
92
|
+
with pytest.raises(AttributeError, match="Faker has no provider"):
|
|
93
|
+
strategies.for_type(Name)
|
|
94
|
+
finally:
|
|
95
|
+
use_faker(None)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_use_faker_locale_german() -> None:
|
|
99
|
+
"""use_faker(Faker('de_DE')) produces locale-appropriate data; reset with use_faker(None)."""
|
|
100
|
+
from faker import Faker
|
|
101
|
+
from polyfactory.factories.pydantic_factory import ModelFactory
|
|
102
|
+
from pydantic import BaseModel
|
|
103
|
+
|
|
104
|
+
from capper import Address, Name
|
|
105
|
+
|
|
106
|
+
class User(BaseModel):
|
|
107
|
+
name: Name
|
|
108
|
+
address: Address
|
|
109
|
+
|
|
110
|
+
class UserFactory(ModelFactory[User]):
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
use_faker(Faker("de_DE"))
|
|
115
|
+
user = UserFactory.build()
|
|
116
|
+
assert len(str(user.name)) > 0
|
|
117
|
+
assert len(str(user.address)) > 0
|
|
118
|
+
# German locale often yields umlauts or typical patterns; at least non-ASCII is possible
|
|
119
|
+
combined = str(user.name) + str(user.address)
|
|
120
|
+
assert combined.isascii() or any(c in "äöüßÄÖÜ" for c in combined)
|
|
121
|
+
finally:
|
|
122
|
+
use_faker(None)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_strategies_for_type_non_callable_provider_raises_type_error() -> None:
|
|
126
|
+
"""strategies.for_type() when Faker's provider is not callable raises TypeError."""
|
|
127
|
+
from faker import Faker
|
|
128
|
+
|
|
129
|
+
from capper import strategies
|
|
130
|
+
|
|
131
|
+
class NotCallableProvider(FakerType):
|
|
132
|
+
faker_provider = "name"
|
|
133
|
+
|
|
134
|
+
my_faker = Faker()
|
|
135
|
+
with patch.object(my_faker, "name", "not_callable"):
|
|
136
|
+
use_faker(my_faker)
|
|
137
|
+
try:
|
|
138
|
+
with pytest.raises(TypeError, match="is not callable"):
|
|
139
|
+
strategies.for_type(NotCallableProvider)
|
|
140
|
+
finally:
|
|
141
|
+
use_faker(None)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Tests for thread-safe per-thread Faker: use_faker() and seed() are isolated per thread."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
|
|
5
|
+
from capper import Name, seed, use_faker
|
|
6
|
+
from capper.base import _get_faker
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_use_faker_per_thread_isolation() -> None:
|
|
10
|
+
"""Two threads each set use_faker() to a different locale; builds use that thread's Faker."""
|
|
11
|
+
from faker import Faker
|
|
12
|
+
from polyfactory.factories.pydantic_factory import ModelFactory
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
results: dict[str, str] = {}
|
|
16
|
+
|
|
17
|
+
def thread_de() -> None:
|
|
18
|
+
use_faker(Faker("de_DE"))
|
|
19
|
+
assert _get_faker() is not None
|
|
20
|
+
|
|
21
|
+
# Build a model; German locale often produces umlauts in names
|
|
22
|
+
class User(BaseModel):
|
|
23
|
+
name: Name
|
|
24
|
+
|
|
25
|
+
class UserFactory(ModelFactory[User]):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
user = UserFactory.build()
|
|
29
|
+
results["de"] = user.name
|
|
30
|
+
|
|
31
|
+
def thread_en() -> None:
|
|
32
|
+
use_faker(Faker("en_US"))
|
|
33
|
+
assert _get_faker() is not None
|
|
34
|
+
|
|
35
|
+
class User(BaseModel):
|
|
36
|
+
name: Name
|
|
37
|
+
|
|
38
|
+
class UserFactory(ModelFactory[User]):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
user = UserFactory.build()
|
|
42
|
+
results["en"] = user.name
|
|
43
|
+
|
|
44
|
+
t1 = threading.Thread(target=thread_de)
|
|
45
|
+
t2 = threading.Thread(target=thread_en)
|
|
46
|
+
t1.start()
|
|
47
|
+
t2.start()
|
|
48
|
+
t1.join()
|
|
49
|
+
t2.join()
|
|
50
|
+
|
|
51
|
+
assert "de" in results and "en" in results
|
|
52
|
+
assert len(results["de"]) > 0 and len(results["en"]) > 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_seed_per_thread_isolation() -> None:
|
|
56
|
+
"""seed(n) in one thread does not affect another thread's sequence."""
|
|
57
|
+
from polyfactory.factories.pydantic_factory import ModelFactory
|
|
58
|
+
from pydantic import BaseModel
|
|
59
|
+
|
|
60
|
+
results: dict[str, str] = {}
|
|
61
|
+
|
|
62
|
+
def thread_seed_42() -> None:
|
|
63
|
+
seed(42)
|
|
64
|
+
|
|
65
|
+
class User(BaseModel):
|
|
66
|
+
name: Name
|
|
67
|
+
|
|
68
|
+
class UserFactory(ModelFactory[User]):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
results["42"] = UserFactory.build().name
|
|
72
|
+
|
|
73
|
+
def thread_seed_99() -> None:
|
|
74
|
+
seed(99)
|
|
75
|
+
|
|
76
|
+
class User(BaseModel):
|
|
77
|
+
name: Name
|
|
78
|
+
|
|
79
|
+
class UserFactory(ModelFactory[User]):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
results["99"] = UserFactory.build().name
|
|
83
|
+
|
|
84
|
+
t1 = threading.Thread(target=thread_seed_42)
|
|
85
|
+
t2 = threading.Thread(target=thread_seed_99)
|
|
86
|
+
t1.start()
|
|
87
|
+
t2.start()
|
|
88
|
+
t1.join()
|
|
89
|
+
t2.join()
|
|
90
|
+
|
|
91
|
+
assert "42" in results and "99" in results
|
|
92
|
+
# Same seed in same thread gives same result when run again
|
|
93
|
+
seed(42)
|
|
94
|
+
|
|
95
|
+
class User(BaseModel):
|
|
96
|
+
name: Name
|
|
97
|
+
|
|
98
|
+
class UserFactory(ModelFactory[User]):
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
again = UserFactory.build().name
|
|
102
|
+
assert again == results["42"]
|
|
@@ -178,23 +178,17 @@ def test_seed_reproducibility(seeded_faker: None) -> None:
|
|
|
178
178
|
|
|
179
179
|
|
|
180
180
|
def test_use_faker_switches_global_faker() -> None:
|
|
181
|
-
"""use_faker()
|
|
181
|
+
"""use_faker() sets this thread's Faker; next factory build uses that instance (e.g. seed)."""
|
|
182
182
|
from faker import Faker
|
|
183
|
-
from polyfactory.factories.base import BaseFactory
|
|
184
183
|
from polyfactory.factories.pydantic_factory import ModelFactory
|
|
185
184
|
from pydantic import BaseModel
|
|
186
185
|
|
|
187
186
|
from capper import Name, use_faker
|
|
188
|
-
from capper import faker as default_faker
|
|
189
187
|
|
|
190
188
|
try:
|
|
191
189
|
custom = Faker()
|
|
192
190
|
custom.seed_instance(123)
|
|
193
191
|
use_faker(custom)
|
|
194
|
-
from capper.base import faker as module_faker
|
|
195
|
-
|
|
196
|
-
assert module_faker is custom
|
|
197
|
-
assert BaseFactory.__faker__ is custom
|
|
198
192
|
|
|
199
193
|
class User(BaseModel):
|
|
200
194
|
name: Name
|
|
@@ -209,4 +203,25 @@ def test_use_faker_switches_global_faker() -> None:
|
|
|
209
203
|
user2 = UserFactory.build()
|
|
210
204
|
assert user1.name == user2.name
|
|
211
205
|
finally:
|
|
212
|
-
use_faker(
|
|
206
|
+
use_faker(None)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_performance_1000_builds_under_threshold() -> None:
|
|
210
|
+
"""Lightweight performance gate: 1000 ModelFactory.build() calls complete in under 30s."""
|
|
211
|
+
import time
|
|
212
|
+
|
|
213
|
+
from polyfactory.factories.pydantic_factory import ModelFactory
|
|
214
|
+
from pydantic import BaseModel
|
|
215
|
+
|
|
216
|
+
class User(BaseModel):
|
|
217
|
+
name: Name
|
|
218
|
+
email: Email
|
|
219
|
+
|
|
220
|
+
class UserFactory(ModelFactory[User]):
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
start = time.perf_counter()
|
|
224
|
+
for _ in range(1000):
|
|
225
|
+
UserFactory.build()
|
|
226
|
+
elapsed = time.perf_counter() - start
|
|
227
|
+
assert elapsed < 30.0, f"1000 builds took {elapsed:.1f}s (threshold 30s)"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: capper
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Semantic, typed wrappers for Faker with automatic Polyfactory integration
|
|
5
5
|
Author-email: Odos Matthews <odosmatthews@gmail.com>
|
|
6
6
|
Project-URL: Documentation, https://github.com/eddiethedean/capper#readme
|
|
@@ -15,6 +15,7 @@ Provides-Extra: hypothesis
|
|
|
15
15
|
Requires-Dist: hypothesis>=6.0; extra == "hypothesis"
|
|
16
16
|
Provides-Extra: dev
|
|
17
17
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-benchmark>=4.0; extra == "dev"
|
|
18
19
|
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
19
20
|
Requires-Dist: pytest-xdist>=3.0; extra == "dev"
|
|
20
21
|
Requires-Dist: pydantic>=2.0; extra == "dev"
|
|
@@ -29,7 +30,7 @@ Requires-Dist: mkdocstrings[python]>=0.24; extra == "docs"
|
|
|
29
30
|
|
|
30
31
|
[](https://pypi.org/project/capper/)
|
|
31
32
|
[](https://pypi.org/project/capper/)
|
|
32
|
-
[](https://github.com/eddiethedean/capper/actions/workflows/ci.yml)
|
|
33
|
+
[)](https://github.com/eddiethedean/capper/actions/workflows/ci.yml)
|
|
33
34
|
[](https://docs.astral.sh/ruff/)
|
|
34
35
|
[](https://mypy-lang.org/)
|
|
35
36
|
|
|
@@ -37,11 +38,21 @@ Semantic, typed wrappers for [Faker](https://faker.readthedocs.io/) with automat
|
|
|
37
38
|
|
|
38
39
|
**Source:** [github.com/eddiethedean/capper](https://github.com/eddiethedean/capper)
|
|
39
40
|
|
|
41
|
+
## CI pipeline
|
|
42
|
+
|
|
43
|
+
The `ci.yml` workflow runs on pushes and PRs to `main` and includes:
|
|
44
|
+
|
|
45
|
+
- **Linting:** `ruff check .` and `ruff format --check .`
|
|
46
|
+
- **Type checking:** `mypy capper`
|
|
47
|
+
- **Tests:** `pytest -n auto capper/tests -v -m "not benchmark" --cov=capper --cov-report=term-missing --cov-fail-under=98`
|
|
48
|
+
- **Docs:** `mkdocs build --strict`
|
|
49
|
+
|
|
40
50
|
## Why Capper?
|
|
41
51
|
|
|
42
52
|
- **Zero config** — Import a type; Polyfactory uses the right Faker provider. No manual registration.
|
|
43
53
|
- **Typed** — Use `Name`, `Email`, `PhoneNumber`, etc. in your models for clear intent and IDE support.
|
|
44
54
|
- **Multi-backend** — Works with Pydantic, dataclasses, attrs, and other [Polyfactory-supported](https://polyfactory.litestar.dev/) model types.
|
|
55
|
+
- **Thread-safe** — Per-thread Faker via a proxy; seeding and locales are isolated per thread, so concurrent tests are safe.
|
|
45
56
|
- **Optional Pydantic** — Install `capper` alone for dataclasses/attrs; add `capper[pydantic]` when you use Pydantic models.
|
|
46
57
|
|
|
47
58
|
## Install
|
|
@@ -151,6 +162,12 @@ Use `-n`/`--count` for the number of rows and `-s`/`--seed` for reproducible out
|
|
|
151
162
|
|
|
152
163
|
Capper targets **Python 3.10+**, **Faker >= 20.0**, and **Polyfactory >= 2.0**. For version ranges, upgrade guidance, and the deprecation policy, see [Compatibility](docs/compatibility.md).
|
|
153
164
|
|
|
165
|
+
## What's new in 0.4.0
|
|
166
|
+
|
|
167
|
+
- **Thread safety:** Capper is now thread-safe via a per-thread Faker proxy; `seed()` and `use_faker()` only affect the current thread.
|
|
168
|
+
- **Reliability and coverage:** Phase 9 adds a coverage gate (≥ 98% for `capper/`), targeted edge-case tests, and a lightweight performance check in CI.
|
|
169
|
+
- **Tooling and docs:** CI runs Ruff, mypy, tests (with coverage gate), and a strict MkDocs build on all supported Python versions; docs and roadmap have been updated to reflect Phase 9.
|
|
170
|
+
|
|
154
171
|
## Development
|
|
155
172
|
|
|
156
173
|
```bash
|
|
@@ -160,7 +177,7 @@ pytest capper/tests
|
|
|
160
177
|
|
|
161
178
|
Lint and type-check: `ruff check .`, `ruff format .`, `mypy capper`.
|
|
162
179
|
|
|
163
|
-
Run tests with coverage: `pytest capper/tests --cov=capper --cov-report=term-missing`.
|
|
180
|
+
Run tests with coverage: `pytest capper/tests --cov=capper --cov-report=term-missing`. CI requires coverage ≥ 98% for the `capper/` package (`--cov-fail-under=98`).
|
|
164
181
|
|
|
165
182
|
**Reproducibility:** Capper and Polyfactory share the same Faker instance, so one seed controls both capper types and built-in types (`str`, `int`, etc.):
|
|
166
183
|
|
|
@@ -191,7 +208,7 @@ Releases are built and published to PyPI via [GitHub Actions](https://github.com
|
|
|
191
208
|
|
|
192
209
|
1. Update [CHANGELOG.md](CHANGELOG.md): move Unreleased entries into a new version section and date it.
|
|
193
210
|
2. Add a `PYPI_API_TOKEN` secret (PyPI API token) to the repo.
|
|
194
|
-
3. Create a GitHub release (tag e.g. `v0.
|
|
211
|
+
3. Create a GitHub release (tag e.g. `v0.4.0`). The workflow runs tests, builds the package, and uploads to PyPI.
|
|
195
212
|
|
|
196
213
|
To build and upload manually: `pip install build twine`, `python -m build`, `twine upload dist/*`.
|
|
197
214
|
|
|
@@ -14,6 +14,7 @@ capper/internet.py
|
|
|
14
14
|
capper/misc.py
|
|
15
15
|
capper/person.py
|
|
16
16
|
capper/phone.py
|
|
17
|
+
capper/registry.py
|
|
17
18
|
capper/strategies.py
|
|
18
19
|
capper/text.py
|
|
19
20
|
capper.egg-info/PKG-INFO
|
|
@@ -24,9 +25,12 @@ capper.egg-info/requires.txt
|
|
|
24
25
|
capper.egg-info/top_level.txt
|
|
25
26
|
capper/examples/user_factory.py
|
|
26
27
|
capper/tests/__init__.py
|
|
28
|
+
capper/tests/benchmark_core.py
|
|
27
29
|
capper/tests/conftest.py
|
|
28
30
|
capper/tests/test_cli.py
|
|
29
31
|
capper/tests/test_docs_examples.py
|
|
32
|
+
capper/tests/test_edge_cases.py
|
|
30
33
|
capper/tests/test_hypothesis_strategies.py
|
|
31
34
|
capper/tests/test_polyfactory_integration.py
|
|
35
|
+
capper/tests/test_thread_safety.py
|
|
32
36
|
capper/tests/test_types.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "capper"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Semantic, typed wrappers for Faker with automatic Polyfactory integration"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -25,6 +25,7 @@ hypothesis = [
|
|
|
25
25
|
]
|
|
26
26
|
dev = [
|
|
27
27
|
"pytest>=7.0",
|
|
28
|
+
"pytest-benchmark>=4.0",
|
|
28
29
|
"pytest-cov>=4.0",
|
|
29
30
|
"pytest-xdist>=3.0",
|
|
30
31
|
"pydantic>=2.0",
|
|
@@ -52,6 +53,14 @@ include = ["capper*"]
|
|
|
52
53
|
testpaths = ["capper/tests"]
|
|
53
54
|
pythonpath = ["."]
|
|
54
55
|
addopts = ["-v", "--tb=short"]
|
|
56
|
+
markers = ["benchmark: performance benchmarks (deselect with -m 'not benchmark')"]
|
|
57
|
+
|
|
58
|
+
[tool.coverage.run]
|
|
59
|
+
source = ["capper"]
|
|
60
|
+
omit = ["capper/tests/*"]
|
|
61
|
+
|
|
62
|
+
[tool.coverage.report]
|
|
63
|
+
exclude_lines = ["pragma: no cover"]
|
|
55
64
|
|
|
56
65
|
[tool.ruff]
|
|
57
66
|
line-length = 100
|
capper-0.3.0/capper/base.py
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
"""FakerType base class and shared Faker instance with automatic Polyfactory registration.
|
|
2
|
-
|
|
3
|
-
The module-level ``faker`` is attached to Polyfactory's BaseFactory so that one seed
|
|
4
|
-
controls both capper types and built-in types. Use ``seed(n)`` for reproducible data.
|
|
5
|
-
|
|
6
|
-
Note: The shared ``faker`` and ``use_faker()`` are not thread-safe; do not switch
|
|
7
|
-
the global Faker from multiple threads while building models.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from typing import Any
|
|
11
|
-
|
|
12
|
-
from faker import Faker
|
|
13
|
-
from polyfactory.factories.base import BaseFactory
|
|
14
|
-
|
|
15
|
-
faker = Faker()
|
|
16
|
-
|
|
17
|
-
# Share this Faker with Polyfactory so one seed controls both capper types and built-in types.
|
|
18
|
-
BaseFactory.__faker__ = faker
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def seed(seed_value: int) -> None:
|
|
22
|
-
"""Seed the shared Faker instance for reproducible data.
|
|
23
|
-
|
|
24
|
-
Args:
|
|
25
|
-
seed_value: Integer seed; same value produces the same sequence of values.
|
|
26
|
-
"""
|
|
27
|
-
faker.seed_instance(seed_value)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def use_faker(instance: Faker) -> None:
|
|
31
|
-
"""Use a custom Faker instance for both Capper and Polyfactory.
|
|
32
|
-
|
|
33
|
-
Replaces the module-level faker and Polyfactory's BaseFactory.__faker__
|
|
34
|
-
so that one instance (e.g. a locale-specific Faker) is used everywhere.
|
|
35
|
-
Call before building any models.
|
|
36
|
-
|
|
37
|
-
Note: The shared faker is not thread-safe. Do not call use_faker() from
|
|
38
|
-
multiple threads while other threads are building models.
|
|
39
|
-
|
|
40
|
-
Args:
|
|
41
|
-
instance: The Faker instance to use (e.g. Faker('de_DE')).
|
|
42
|
-
"""
|
|
43
|
-
global faker
|
|
44
|
-
faker = instance
|
|
45
|
-
BaseFactory.__faker__ = instance
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _install_pydantic_schema() -> None:
|
|
49
|
-
"""If Pydantic is available, attach __get_pydantic_core_schema__ to FakerType."""
|
|
50
|
-
try:
|
|
51
|
-
from pydantic import GetCoreSchemaHandler
|
|
52
|
-
from pydantic_core import CoreSchema, core_schema
|
|
53
|
-
except ImportError:
|
|
54
|
-
return
|
|
55
|
-
|
|
56
|
-
def __get_pydantic_core_schema__(source_type: Any, handler: GetCoreSchemaHandler) -> CoreSchema:
|
|
57
|
-
"""Validate as str then coerce to the FakerType subclass."""
|
|
58
|
-
return core_schema.no_info_after_validator_function(source_type, handler(str))
|
|
59
|
-
|
|
60
|
-
FakerType.__get_pydantic_core_schema__ = __get_pydantic_core_schema__ # type: ignore[attr-defined]
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
class FakerType(str):
|
|
64
|
-
"""Base class for semantic Faker types; subclasses auto-register with Polyfactory.
|
|
65
|
-
|
|
66
|
-
Subclasses must set a non-empty ``faker_provider`` (the Faker method name).
|
|
67
|
-
Optional ``faker_kwargs`` is a dict of keyword arguments passed to that provider
|
|
68
|
-
(e.g. ``faker_kwargs = {"nb_words": 10}`` for ``sentence``).
|
|
69
|
-
When Hypothesis is installed, use ``st.from_type(YourFakerType)`` for property-based tests.
|
|
70
|
-
"""
|
|
71
|
-
|
|
72
|
-
faker_provider: str = ""
|
|
73
|
-
|
|
74
|
-
def __init_subclass__(cls, **kwargs: object) -> None:
|
|
75
|
-
super().__init_subclass__(**kwargs)
|
|
76
|
-
provider = getattr(cls, "faker_provider", None)
|
|
77
|
-
if provider:
|
|
78
|
-
_register(cls, provider)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
_install_pydantic_schema()
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def _register(cls: type, provider_name: str) -> None:
|
|
85
|
-
"""Register a FakerType subclass with Polyfactory so factories can generate values."""
|
|
86
|
-
if not hasattr(faker, provider_name):
|
|
87
|
-
raise AttributeError(
|
|
88
|
-
f"Faker has no provider {provider_name!r} (used by {cls.__name__}). "
|
|
89
|
-
"Check faker_provider on the type."
|
|
90
|
-
)
|
|
91
|
-
provider_fn = getattr(faker, provider_name)
|
|
92
|
-
if not callable(provider_fn):
|
|
93
|
-
raise TypeError(f"Faker.{provider_name} is not callable (used by {cls.__name__}).")
|
|
94
|
-
provider_kwargs = dict(getattr(cls, "faker_kwargs", None) or {})
|
|
95
|
-
|
|
96
|
-
def _provide() -> str:
|
|
97
|
-
value = getattr(faker, provider_name)(**provider_kwargs)
|
|
98
|
-
return str(value)
|
|
99
|
-
|
|
100
|
-
BaseFactory.add_provider(cls, _provide)
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
"""Tests for the capper CLI (generate subcommand)."""
|
|
2
|
-
|
|
3
|
-
import sys
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
if sys.version_info >= (3, 10):
|
|
8
|
-
from capper import cli
|
|
9
|
-
else:
|
|
10
|
-
cli = None # type: ignore[assignment]
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@pytest.mark.skipif(sys.version_info < (3, 10), reason="capper CLI uses Python 3.10+ syntax")
|
|
14
|
-
def test_cli_generate_exit_zero_and_non_empty_output() -> None:
|
|
15
|
-
"""Invoke generate subcommand; assert exit 0 and non-empty output for a known type."""
|
|
16
|
-
orig_argv = sys.argv
|
|
17
|
-
try:
|
|
18
|
-
sys.argv = ["capper", "generate", "Name", "--count", "1"]
|
|
19
|
-
exit_code = cli.main()
|
|
20
|
-
assert exit_code == 0
|
|
21
|
-
finally:
|
|
22
|
-
sys.argv = orig_argv
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@pytest.mark.skipif(sys.version_info < (3, 10), reason="capper CLI uses Python 3.10+ syntax")
|
|
26
|
-
def test_cli_generate_produces_output(capsys: pytest.CaptureFixture[str]) -> None:
|
|
27
|
-
"""Generate subcommand prints one tab-separated value per row."""
|
|
28
|
-
orig_argv = sys.argv
|
|
29
|
-
try:
|
|
30
|
-
sys.argv = ["capper", "generate", "Name", "Email", "--count", "2"]
|
|
31
|
-
exit_code = cli.main()
|
|
32
|
-
assert exit_code == 0
|
|
33
|
-
out = capsys.readouterr().out
|
|
34
|
-
lines = [line for line in out.strip().splitlines() if line.strip()]
|
|
35
|
-
assert len(lines) == 2
|
|
36
|
-
for line in lines:
|
|
37
|
-
parts = line.split("\t")
|
|
38
|
-
assert len(parts) == 2
|
|
39
|
-
assert len(parts[0]) > 0 and len(parts[1]) > 0
|
|
40
|
-
finally:
|
|
41
|
-
sys.argv = orig_argv
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|