Skip to content

Commit 388bb72

Browse files
committed
Initial release v1.0.0
0 parents  commit 388bb72

11 files changed

Lines changed: 611 additions & 0 deletions

File tree

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* @CrashBytes

.github/workflows/ci.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
python-version: ["3.10", "3.11", "3.12", "3.13"]
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-python@v5
18+
with:
19+
python-version: ${{ matrix.python-version }}
20+
- run: pip install -e ".[dev]"
21+
- run: ruff check src/ tests/
22+
- run: mypy --strict src/
23+
- run: pytest --cov=crashbytes_testkit --cov-branch --cov-fail-under=90

.github/workflows/publish.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: Publish to PyPI
2+
3+
on:
4+
push:
5+
tags: ["*"]
6+
7+
jobs:
8+
publish:
9+
runs-on: ubuntu-latest
10+
permissions:
11+
id-token: write
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: actions/setup-python@v5
15+
with:
16+
python-version: "3.12"
17+
- run: pip install build
18+
- run: python -m build
19+
- uses: pypa/gh-action-pypi-publish@release/v1

.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
__pycache__/
2+
*.pyc
3+
*.pyo
4+
dist/
5+
build/
6+
*.egg-info/
7+
.coverage
8+
.pytest_cache/
9+
.mypy_cache/
10+
.ruff_cache/
11+

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 CrashBytes
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.

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# crashbytes-testkit
2+
3+
Test data builders for Python — auto-generate dataclass instances from type hints.
4+
5+
## Install
6+
7+
```bash
8+
pip install crashbytes-testkit
9+
```
10+
11+
## Usage
12+
13+
```python
14+
from dataclasses import dataclass
15+
from crashbytes_testkit import Fixture, Builder
16+
17+
@dataclass
18+
class User:
19+
name: str
20+
age: int
21+
email: str
22+
23+
# Auto-generate from type hints
24+
user = Fixture.create(User)
25+
users = Fixture.create_many(User, 5)
26+
27+
# Override specific fields
28+
admin = Fixture.create(User, name="Admin", age=30)
29+
30+
# Fluent builder
31+
user = (
32+
Builder(User)
33+
.with_field("name", "Alice")
34+
.with_field("age", 25)
35+
.build()
36+
)
37+
```
38+
39+
Supports: `str`, `int`, `float`, `bool`, `bytes`, `datetime`, `date`, `UUID`, `list[T]`, `dict[K,V]`, `set[T]`, `tuple`, `Optional[T]`, nested dataclasses.
40+
41+
## License
42+
43+
MIT

pyproject.toml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
[build-system]
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "crashbytes-testkit"
7+
version = "1.0.0"
8+
description = "Test data builders for Python — auto-generate dataclass instances from type hints."
9+
readme = "README.md"
10+
license = "MIT"
11+
requires-python = ">=3.10"
12+
authors = [{ name = "CrashBytes", email = "crashbytes@users.noreply.github.com" }]
13+
keywords = ["testing", "fixture", "builder", "dataclass", "test-data"]
14+
classifiers = [
15+
"Development Status :: 5 - Production/Stable",
16+
"Intended Audience :: Developers",
17+
"License :: OSI Approved :: MIT License",
18+
"Programming Language :: Python :: 3",
19+
"Programming Language :: Python :: 3.10",
20+
"Programming Language :: Python :: 3.11",
21+
"Programming Language :: Python :: 3.12",
22+
"Programming Language :: Python :: 3.13",
23+
"Typing :: Typed",
24+
]
25+
26+
[project.optional-dependencies]
27+
dev = ["pytest", "pytest-cov", "mypy", "ruff"]
28+
29+
[project.urls]
30+
Homepage = "https://github.com/CrashBytes/crashbytes-testkit"
31+
Repository = "https://github.com/CrashBytes/crashbytes-testkit"
32+
Issues = "https://github.com/CrashBytes/crashbytes-testkit/issues"
33+
34+
[tool.ruff]
35+
target-version = "py310"
36+
line-length = 99
37+
38+
[tool.ruff.lint]
39+
select = ["E", "F", "I", "N", "UP", "B", "SIM", "TCH"]
40+
41+
[tool.mypy]
42+
strict = true
43+
python_version = "3.10"
44+
45+
[tool.pytest.ini_options]
46+
testpaths = ["tests"]
47+
48+
[tool.coverage.run]
49+
branch = true
50+
source = ["crashbytes_testkit"]
51+
52+
[tool.coverage.report]
53+
fail_under = 90

src/crashbytes_testkit/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""crashbytes-testkit — Test data builders for Python."""
2+
3+
from crashbytes_testkit._core import Builder, Fixture
4+
5+
__all__ = ["Builder", "Fixture"]

src/crashbytes_testkit/_core.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Test data builders — auto-generate dataclass instances from type hints."""
2+
3+
from __future__ import annotations
4+
5+
import dataclasses
6+
import random
7+
import string
8+
import uuid
9+
from datetime import date, datetime
10+
from typing import Any, TypeVar, get_type_hints
11+
12+
T = TypeVar("T")
13+
14+
_counter = 0
15+
16+
17+
def _next_id() -> int:
18+
global _counter # noqa: PLW0603
19+
_counter += 1
20+
return _counter
21+
22+
23+
def _random_string(length: int = 8) -> str:
24+
return "".join(random.choices(string.ascii_lowercase, k=length))
25+
26+
27+
def _generate_value(type_hint: Any, field_name: str = "") -> Any:
28+
"""Generate a plausible value for a given type hint."""
29+
origin = getattr(type_hint, "__origin__", None)
30+
args = getattr(type_hint, "__args__", ())
31+
32+
# Handle Union types (Optional[X], X | Y, etc.)
33+
import types
34+
import typing
35+
if origin is typing.Union or isinstance(type_hint, types.UnionType):
36+
type_args = args if args else getattr(type_hint, "__args__", ())
37+
non_none = [a for a in type_args if a is not type(None)]
38+
if non_none:
39+
return _generate_value(non_none[0], field_name)
40+
return None
41+
42+
if type_hint is str:
43+
return f"{field_name}_{_random_string()}" if field_name else _random_string()
44+
if type_hint is int:
45+
return _next_id()
46+
if type_hint is float:
47+
return round(random.uniform(0.0, 100.0), 2)
48+
if type_hint is bool:
49+
return random.choice([True, False])
50+
if type_hint is bytes:
51+
return _random_string().encode()
52+
if type_hint is datetime:
53+
return datetime(2024, 1, 1, 12, 0, 0)
54+
if type_hint is date:
55+
return date(2024, 1, 1)
56+
57+
# list[X]
58+
if origin is list:
59+
inner = args[0] if args else str
60+
return [_generate_value(inner, field_name) for _ in range(2)]
61+
62+
# dict[K, V]
63+
if origin is dict:
64+
k_type = args[0] if args else str
65+
v_type = args[1] if len(args) > 1 else str # type: ignore[misc]
66+
return {_generate_value(k_type): _generate_value(v_type) for _ in range(2)}
67+
68+
# set[X]
69+
if origin is set:
70+
inner = args[0] if args else str
71+
return {_generate_value(inner, field_name) for _ in range(2)}
72+
73+
# tuple[X, ...]
74+
if origin is tuple:
75+
if args:
76+
return tuple(_generate_value(a, field_name) for a in args if a is not Ellipsis)
77+
return ()
78+
79+
# UUID
80+
if type_hint is uuid.UUID:
81+
return uuid.uuid4()
82+
83+
# Nested dataclass
84+
if dataclasses.is_dataclass(type_hint) and isinstance(type_hint, type):
85+
return _create_instance(type_hint)
86+
87+
# Fallback
88+
return None
89+
90+
91+
def _create_instance(cls: type[T], overrides: dict[str, Any] | None = None) -> T:
92+
"""Create an instance of *cls* with auto-generated values."""
93+
hints = get_type_hints(cls)
94+
kwargs: dict[str, Any] = {}
95+
96+
for field in dataclasses.fields(cls): # type: ignore[arg-type]
97+
if overrides and field.name in overrides:
98+
kwargs[field.name] = overrides[field.name]
99+
elif field.default is not dataclasses.MISSING:
100+
kwargs[field.name] = field.default
101+
elif field.default_factory is not dataclasses.MISSING:
102+
kwargs[field.name] = field.default_factory()
103+
else:
104+
type_hint = hints.get(field.name, str)
105+
kwargs[field.name] = _generate_value(type_hint, field.name)
106+
107+
return cls(**kwargs)
108+
109+
110+
class Fixture:
111+
"""Auto-generate test data from dataclass type hints."""
112+
113+
@staticmethod
114+
def create(cls: type[T], **overrides: Any) -> T:
115+
"""Create a single instance of *cls*."""
116+
if not dataclasses.is_dataclass(cls):
117+
raise TypeError(f"{cls.__name__} is not a dataclass")
118+
return _create_instance(cls, overrides or None)
119+
120+
@staticmethod
121+
def create_many(cls: type[T], count: int = 3, **overrides: Any) -> list[T]:
122+
"""Create *count* instances of *cls*."""
123+
return [Fixture.create(cls, **overrides) for _ in range(count)]
124+
125+
126+
class Builder:
127+
"""Fluent builder for constructing test data."""
128+
129+
def __init__(self, cls: type[Any]) -> None:
130+
if not dataclasses.is_dataclass(cls):
131+
raise TypeError(f"{cls.__name__} is not a dataclass")
132+
self._cls = cls
133+
self._overrides: dict[str, Any] = {}
134+
135+
def with_field(self, name: str, value: Any) -> Builder:
136+
"""Set a specific field value."""
137+
self._overrides[name] = value
138+
return self
139+
140+
def build(self) -> Any:
141+
"""Build the instance."""
142+
return _create_instance(self._cls, self._overrides)
143+
144+
def build_many(self, count: int = 3) -> list[Any]:
145+
"""Build *count* instances."""
146+
return [self.build() for _ in range(count)]

src/crashbytes_testkit/py.typed

Whitespace-only changes.

0 commit comments

Comments
 (0)