pyrrange 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyrrange/__init__.py +10 -0
- pyrrange/_version.py +24 -0
- pyrrange/arrange.py +170 -0
- pyrrange/context.py +24 -0
- pyrrange/py.typed +0 -0
- pyrrange/scene.py +38 -0
- pyrrange-0.1.0.dist-info/METADATA +297 -0
- pyrrange-0.1.0.dist-info/RECORD +10 -0
- pyrrange-0.1.0.dist-info/WHEEL +4 -0
- pyrrange-0.1.0.dist-info/licenses/LICENSE +21 -0
pyrrange/__init__.py
ADDED
pyrrange/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
pyrrange/arrange.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import Any, TypeVar, overload
|
|
7
|
+
|
|
8
|
+
from pyrrange.context import Context
|
|
9
|
+
from pyrrange.scene import Scene
|
|
10
|
+
|
|
11
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StepError(Exception):
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
step_name: str,
|
|
18
|
+
step_index: int,
|
|
19
|
+
total_steps: int,
|
|
20
|
+
arrange_class: str,
|
|
21
|
+
previous_result: Any,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.step_name = step_name
|
|
24
|
+
self.step_index = step_index
|
|
25
|
+
self.total_steps = total_steps
|
|
26
|
+
self.arrange_class = arrange_class
|
|
27
|
+
self.previous_result = previous_result
|
|
28
|
+
super().__init__(
|
|
29
|
+
f"Step {step_index}/{total_steps} '{step_name}' failed on {arrange_class}\n"
|
|
30
|
+
f" Previous result: {previous_result!r}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@overload
|
|
35
|
+
def step(fn: F) -> F: ... # pragma: no cover
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@overload
|
|
39
|
+
def step(label: str) -> Callable[[F], F]: ... # pragma: no cover
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def step(fn: F | str | None = None, label: str | None = None) -> F | Callable[[F], F]: # type: ignore[misc]
|
|
43
|
+
if isinstance(fn, str):
|
|
44
|
+
return _make_step_decorator(fn)
|
|
45
|
+
|
|
46
|
+
if fn is None:
|
|
47
|
+
return _make_step_decorator(label)
|
|
48
|
+
|
|
49
|
+
return _make_step_decorator(None)(fn)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _make_step_decorator(label: str | None) -> Callable[[F], F]:
|
|
53
|
+
def decorator(fn: F) -> F:
|
|
54
|
+
step_label = label or fn.__name__
|
|
55
|
+
|
|
56
|
+
@wraps(fn)
|
|
57
|
+
def wrapper(self: Arrange, *args: Any, **kwargs: Any) -> Arrange:
|
|
58
|
+
self._recorded_steps.append(_StepRecord(fn, step_label, args, kwargs))
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
wrapper.__annotations__ = {"return": Arrange}
|
|
62
|
+
wrapper._original = fn # type: ignore[attr-defined]
|
|
63
|
+
wrapper._step_label = step_label # type: ignore[attr-defined]
|
|
64
|
+
return wrapper # type: ignore[return-value]
|
|
65
|
+
|
|
66
|
+
return decorator
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_kwargs(
|
|
70
|
+
fn: Callable[..., Any],
|
|
71
|
+
context: Context,
|
|
72
|
+
recorded_args: tuple[Any, ...],
|
|
73
|
+
recorded_kwargs: dict[str, Any],
|
|
74
|
+
skip_self: bool,
|
|
75
|
+
) -> dict[str, Any]:
|
|
76
|
+
sig = inspect.signature(fn)
|
|
77
|
+
params = list(sig.parameters.values())
|
|
78
|
+
|
|
79
|
+
if skip_self and params:
|
|
80
|
+
params = params[1:]
|
|
81
|
+
|
|
82
|
+
resolved: dict[str, Any] = {}
|
|
83
|
+
|
|
84
|
+
for param in params:
|
|
85
|
+
if param.name in recorded_kwargs:
|
|
86
|
+
continue
|
|
87
|
+
if param.default is inspect.Parameter.empty and param.name in context:
|
|
88
|
+
resolved[param.name] = context[param.name]
|
|
89
|
+
|
|
90
|
+
unresolved = [p for p in params if p.name not in resolved and p.name not in recorded_kwargs]
|
|
91
|
+
for i, arg in enumerate(recorded_args):
|
|
92
|
+
if i < len(unresolved):
|
|
93
|
+
resolved[unresolved[i].name] = arg
|
|
94
|
+
|
|
95
|
+
resolved.update(recorded_kwargs)
|
|
96
|
+
|
|
97
|
+
return resolved
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _StepRecord:
|
|
101
|
+
__slots__ = ("args", "fn", "kwargs", "label")
|
|
102
|
+
|
|
103
|
+
def __init__(self, fn: Callable[..., Any], label: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
|
|
104
|
+
self.fn = fn
|
|
105
|
+
self.label = label
|
|
106
|
+
self.args = args
|
|
107
|
+
self.kwargs = kwargs
|
|
108
|
+
|
|
109
|
+
def execute(self, arrange: Arrange) -> Any:
|
|
110
|
+
kwargs = _resolve_kwargs(self.fn, arrange._context, self.args, self.kwargs, skip_self=True)
|
|
111
|
+
return self.fn(arrange, **kwargs)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _ThenRecord:
|
|
115
|
+
__slots__ = ("args", "fn", "kwargs", "label")
|
|
116
|
+
|
|
117
|
+
def __init__(self, fn: Callable[..., Any], label: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
|
|
118
|
+
self.fn = fn
|
|
119
|
+
self.label = label
|
|
120
|
+
self.args = args
|
|
121
|
+
self.kwargs = kwargs
|
|
122
|
+
|
|
123
|
+
def execute(self, _arrange: Arrange) -> Any:
|
|
124
|
+
kwargs = _resolve_kwargs(self.fn, _arrange._context, self.args, self.kwargs, skip_self=False)
|
|
125
|
+
return self.fn(**kwargs)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class Arrange:
|
|
129
|
+
def __init__(self) -> None:
|
|
130
|
+
self._recorded_steps: list[_StepRecord | _ThenRecord] = []
|
|
131
|
+
self._context: Context = Context()
|
|
132
|
+
|
|
133
|
+
def then(self, label: str, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Arrange:
|
|
134
|
+
self._recorded_steps.append(_ThenRecord(fn, label, args, kwargs))
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
def teardown(self, scene: Scene) -> None:
|
|
138
|
+
"""Hook for cleaning up resources after a test.
|
|
139
|
+
|
|
140
|
+
Override in subclasses to release external state that cannot be
|
|
141
|
+
rolled back automatically — polymorphic model deletion, external
|
|
142
|
+
service cleanup, file removal, etc. The base implementation is a
|
|
143
|
+
no-op so calling ``scene.teardown()`` is always safe.
|
|
144
|
+
|
|
145
|
+
Called automatically when a Scene is used as a context manager
|
|
146
|
+
(``with ... as scene:``), or manually via ``scene.teardown()``.
|
|
147
|
+
|
|
148
|
+
:param scene: The Scene produced by ``arrange()``. Use
|
|
149
|
+
``scene["label"]`` or ``scene.label`` to access step results
|
|
150
|
+
that need cleanup.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def arrange(self) -> Scene:
|
|
154
|
+
total = len(self._recorded_steps)
|
|
155
|
+
for index, record in enumerate(self._recorded_steps, start=1):
|
|
156
|
+
try:
|
|
157
|
+
result = record.execute(self)
|
|
158
|
+
except StepError:
|
|
159
|
+
raise
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
raise StepError(
|
|
162
|
+
step_name=record.label,
|
|
163
|
+
step_index=index,
|
|
164
|
+
total_steps=total,
|
|
165
|
+
arrange_class=type(self).__name__,
|
|
166
|
+
previous_result=self._context._last_result,
|
|
167
|
+
) from exc
|
|
168
|
+
self._context.set_result(record.label, result)
|
|
169
|
+
scene_cls = getattr(type(self), "SceneType", Scene)
|
|
170
|
+
return scene_cls(self._context, self)
|
pyrrange/context.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Context:
|
|
7
|
+
def __init__(self) -> None:
|
|
8
|
+
self._registry: dict[str, Any] = {}
|
|
9
|
+
self._last_result: Any = None
|
|
10
|
+
|
|
11
|
+
def set_result(self, label: str, value: Any) -> None:
|
|
12
|
+
self._last_result = value
|
|
13
|
+
self._registry[label] = value
|
|
14
|
+
|
|
15
|
+
def __getitem__(self, label: str) -> Any:
|
|
16
|
+
if label not in self._registry:
|
|
17
|
+
raise KeyError(f"No step result for label '{label}'. Available: {list(self._registry.keys())}")
|
|
18
|
+
return self._registry[label]
|
|
19
|
+
|
|
20
|
+
def __contains__(self, label: str) -> bool:
|
|
21
|
+
return label in self._registry
|
|
22
|
+
|
|
23
|
+
def __repr__(self) -> str:
|
|
24
|
+
return f"Context({list(self._registry.keys())})"
|
pyrrange/py.typed
ADDED
|
File without changes
|
pyrrange/scene.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from pyrrange.context import Context
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pyrrange.arrange import Arrange
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Scene:
|
|
12
|
+
def __init__(self, context: Context, arrange: Arrange) -> None:
|
|
13
|
+
self._context = context
|
|
14
|
+
self._arrange = arrange
|
|
15
|
+
|
|
16
|
+
def __getitem__(self, label: str) -> Any:
|
|
17
|
+
return self._context[label]
|
|
18
|
+
|
|
19
|
+
def __getattr__(self, name: str) -> Any:
|
|
20
|
+
try:
|
|
21
|
+
return self._context[name]
|
|
22
|
+
except KeyError:
|
|
23
|
+
raise AttributeError(f"Scene has no label '{name}'. Available: {self._context!r}") from None
|
|
24
|
+
|
|
25
|
+
def __contains__(self, label: str) -> bool:
|
|
26
|
+
return label in self._context
|
|
27
|
+
|
|
28
|
+
def teardown(self) -> None:
|
|
29
|
+
self._arrange.teardown(self)
|
|
30
|
+
|
|
31
|
+
def __enter__(self) -> Scene:
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
|
|
35
|
+
self.teardown()
|
|
36
|
+
|
|
37
|
+
def __repr__(self) -> str:
|
|
38
|
+
return f"Scene({self._context!r})"
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyrrange
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Expressive, fluent test scenario preparation for Python.
|
|
5
|
+
Project-URL: Homepage, https://github.com/othercodes/pyrrange
|
|
6
|
+
Project-URL: Repository, https://github.com/othercodes/pyrrange.git
|
|
7
|
+
Project-URL: Issues, https://github.com/othercodes/pyrrange/issues
|
|
8
|
+
Author-email: Unay Santisteban <usantisteban@othercode.io>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: arrange,fluent,pytest,scenarios,testing
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: Pytest
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Software Development :: Testing
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# pyrrange
|
|
29
|
+
|
|
30
|
+
[](https://github.com/othercodes/pyrrange/actions/workflows/test.yml)
|
|
31
|
+
[](https://sonarcloud.io/summary/new_code?id=othercodes_pyrrange)
|
|
32
|
+
|
|
33
|
+
Expressive, fluent test scenario preparation for Python.
|
|
34
|
+
|
|
35
|
+
## Why
|
|
36
|
+
|
|
37
|
+
In large codebases, the arrange phase of tests becomes the bottleneck. Fixtures are one-size-fits-all — the same `user` fixture creates a full object graph whether the test needs a simple login check or a complete checkout flow. Tests pay for setup they don't need, and there's no way to declare "give me just enough state for *this* test."
|
|
38
|
+
|
|
39
|
+
Pyrrange solves this by letting tests declare exactly what state they need through a fluent chain of operations. Each step calls a real domain operation (not a factory), so the state is built the same way production builds it.
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- Fluent, chainable API for test state preparation
|
|
44
|
+
- Operation-based: steps call real use cases, not create DB rows directly
|
|
45
|
+
- Labeled results: access any step's output by name via attribute or dict access
|
|
46
|
+
- Automatic dependency injection: step parameters are resolved from context by name
|
|
47
|
+
- Optional typed scenes: declare a `SceneType` for full IDE autocomplete and type checking
|
|
48
|
+
- Inline steps via `.then()` for ad-hoc logic
|
|
49
|
+
- Teardown support with context manager for guaranteed cleanup
|
|
50
|
+
- Framework-agnostic: works with Django, FastAPI, or any Python project
|
|
51
|
+
|
|
52
|
+
## Requirements
|
|
53
|
+
|
|
54
|
+
- Python 3.10+
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install pyrrange
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
### Define an Arrange
|
|
65
|
+
|
|
66
|
+
Subclass `Arrange` and define `@step` methods. Each step declares what it needs via its parameter names — pyrrange injects values from the context automatically.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from pyrrange import Arrange, step
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AccountArrange(Arrange):
|
|
73
|
+
@step("user")
|
|
74
|
+
def register(self, email="user@example.com", password="secret"):
|
|
75
|
+
user = register_user(email=email, password=password)
|
|
76
|
+
return user
|
|
77
|
+
|
|
78
|
+
@step("user")
|
|
79
|
+
def verified(self, user):
|
|
80
|
+
verify_email(user)
|
|
81
|
+
return user
|
|
82
|
+
|
|
83
|
+
@step("user")
|
|
84
|
+
def as_admin(self, user):
|
|
85
|
+
user.is_admin = True
|
|
86
|
+
user.save()
|
|
87
|
+
return user
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### How injection works
|
|
91
|
+
|
|
92
|
+
Parameters are resolved using a simple rule:
|
|
93
|
+
|
|
94
|
+
- **No default value** + name matches a label in context → **injected automatically**
|
|
95
|
+
- **Has default value** → **uses the default**, never injected (safe from silent overrides)
|
|
96
|
+
- **Caller provides a value** → **caller always wins**
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
@step("user")
|
|
100
|
+
def register(self, email="user@example.com"):
|
|
101
|
+
# `email` has a default → not injected, uses "user@example.com"
|
|
102
|
+
# Override via chain: .register(email="other@example.com")
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
@step("user")
|
|
106
|
+
def verified(self, user):
|
|
107
|
+
# `user` has no default → injected from context["user"]
|
|
108
|
+
...
|
|
109
|
+
|
|
110
|
+
@step("checkout")
|
|
111
|
+
def purchase(self, api_client, payment_method, config=None):
|
|
112
|
+
# `api_client` → injected from context["api_client"]
|
|
113
|
+
# `payment_method` → injected from context["payment_method"]
|
|
114
|
+
# `config` has a default → not injected, uses None
|
|
115
|
+
...
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
This means every dependency is **typed in the signature** — your IDE gives autocomplete and your type checker validates usage.
|
|
119
|
+
|
|
120
|
+
### Use in tests
|
|
121
|
+
|
|
122
|
+
With context manager (recommended — guarantees teardown):
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
def test_login(account_arrange):
|
|
126
|
+
with account_arrange.register().arrange() as scene:
|
|
127
|
+
response = client.post("/login", {"email": scene.user.email, "password": "secret"})
|
|
128
|
+
assert response.status_code == 200
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Without context manager:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
def test_login(account_arrange):
|
|
135
|
+
scene = account_arrange.register().arrange()
|
|
136
|
+
response = client.post("/login", {"email": scene.user.email, "password": "secret"})
|
|
137
|
+
assert response.status_code == 200
|
|
138
|
+
scene.teardown()
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Each test declares only the steps it needs:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
# Just a registered user
|
|
145
|
+
scene = account_arrange.register().arrange()
|
|
146
|
+
|
|
147
|
+
# Registered and verified
|
|
148
|
+
scene = account_arrange.register().verified().arrange()
|
|
149
|
+
|
|
150
|
+
# Full admin user
|
|
151
|
+
scene = account_arrange.register().verified().as_admin().arrange()
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Labels
|
|
155
|
+
|
|
156
|
+
Steps are labeled by default with the method name. Use `@step("label")` to set a custom label. Same label overwrites (latest wins).
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
class OrderArrange(Arrange):
|
|
160
|
+
@step("order")
|
|
161
|
+
def create(self, total=100):
|
|
162
|
+
return create_order(total=total)
|
|
163
|
+
|
|
164
|
+
@step("order")
|
|
165
|
+
def paid(self, order):
|
|
166
|
+
process_payment(order)
|
|
167
|
+
return order
|
|
168
|
+
|
|
169
|
+
@step("receipt")
|
|
170
|
+
def with_receipt(self, order):
|
|
171
|
+
return generate_receipt(order)
|
|
172
|
+
|
|
173
|
+
scene = OrderArrange().create().paid().with_receipt().arrange()
|
|
174
|
+
order = scene.order
|
|
175
|
+
receipt = scene.receipt
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
> **Note:** When multiple steps share the same label (like `"order"` above), the label always points to the latest result. Steps that need the value use injection by matching the label name in their parameter list.
|
|
179
|
+
|
|
180
|
+
### Inline steps with `.then()`
|
|
181
|
+
|
|
182
|
+
Use `.then()` to add a step without defining a method. Parameter names are matched against context labels, just like `@step` methods.
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
def create_api_token(user):
|
|
186
|
+
return Token.objects.create(user=user)
|
|
187
|
+
|
|
188
|
+
scene = (
|
|
189
|
+
account_arrange
|
|
190
|
+
.register()
|
|
191
|
+
.verified()
|
|
192
|
+
.then("token", create_api_token)
|
|
193
|
+
.arrange()
|
|
194
|
+
)
|
|
195
|
+
user = scene.user
|
|
196
|
+
token = scene.token
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Works with lambdas too — the parameter name is the injection key:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
scene = (
|
|
203
|
+
account_arrange
|
|
204
|
+
.register()
|
|
205
|
+
.then("email", lambda user: user.email)
|
|
206
|
+
.arrange()
|
|
207
|
+
)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Teardown
|
|
211
|
+
|
|
212
|
+
Override `teardown` on your Arrange to clean up resources. This is where you handle cleanup that Django's transaction rollback can't cover — polymorphic model deletion, external service state, file cleanup.
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
class AccountArrange(Arrange):
|
|
216
|
+
@step("user")
|
|
217
|
+
def register(self, email="user@example.com"):
|
|
218
|
+
return register_user(email=email)
|
|
219
|
+
|
|
220
|
+
def teardown(self, scene):
|
|
221
|
+
scene.user.delete()
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Use the context manager to guarantee teardown runs, even if the test crashes:
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
with account_arrange.register().arrange() as scene:
|
|
228
|
+
user = scene.user
|
|
229
|
+
# ... test ...
|
|
230
|
+
# teardown runs automatically on exit
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
You can also call `scene.teardown()` manually if you prefer explicit control.
|
|
234
|
+
|
|
235
|
+
### Typed Scene
|
|
236
|
+
|
|
237
|
+
By default, `scene.user` returns `Any`. For full IDE autocomplete and type checking, declare a `SceneType` on your Arrange:
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
from pyrrange import Arrange, Scene, step
|
|
241
|
+
|
|
242
|
+
class AccountArrange(Arrange):
|
|
243
|
+
class SceneType(Scene):
|
|
244
|
+
user: User
|
|
245
|
+
api_client: APIClient
|
|
246
|
+
|
|
247
|
+
@step("user")
|
|
248
|
+
def register(self, email="user@example.com") -> User:
|
|
249
|
+
return register_user(email=email)
|
|
250
|
+
|
|
251
|
+
@step("api_client")
|
|
252
|
+
def with_client(self, user: User) -> APIClient:
|
|
253
|
+
return create_client(user)
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
When `SceneType` is declared, `.arrange()` returns an instance of that subclass. Your IDE sees `scene.user` as `User` and `scene.api_client` as `APIClient`.
|
|
257
|
+
|
|
258
|
+
`SceneType` is optional — without it, attribute access still works but returns `Any`. Both `scene.user` and `scene["user"]` are always available.
|
|
259
|
+
|
|
260
|
+
### Expose arranges as fixtures
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
@pytest.fixture
|
|
264
|
+
def account_arrange():
|
|
265
|
+
return AccountArrange()
|
|
266
|
+
|
|
267
|
+
def test_something(account_arrange):
|
|
268
|
+
with account_arrange.register().verified().arrange() as scene:
|
|
269
|
+
user = scene.user
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Chain shortcuts
|
|
273
|
+
|
|
274
|
+
For common step combinations, define convenience methods on your Arrange:
|
|
275
|
+
|
|
276
|
+
```python
|
|
277
|
+
class AccountArrange(Arrange):
|
|
278
|
+
@step("user")
|
|
279
|
+
def register(self, email="user@example.com"):
|
|
280
|
+
...
|
|
281
|
+
|
|
282
|
+
@step("user")
|
|
283
|
+
def verified(self, user):
|
|
284
|
+
...
|
|
285
|
+
|
|
286
|
+
@step("api_client")
|
|
287
|
+
def with_authenticated_client(self, user):
|
|
288
|
+
...
|
|
289
|
+
|
|
290
|
+
def authenticated(self):
|
|
291
|
+
return self.register().verified().with_authenticated_client()
|
|
292
|
+
|
|
293
|
+
# In tests:
|
|
294
|
+
scene = account_arrange.authenticated().arrange()
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
These are plain Python methods — no framework magic.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pyrrange/__init__.py,sha256=BSU6baGwsaSfaY2_jXIIXvkAJla_I1Ajtykix_dTIos,216
|
|
2
|
+
pyrrange/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
|
|
3
|
+
pyrrange/arrange.py,sha256=9xOTuV6onI3uixPFLvy_w34xmWpYsKG7w9ynYlH2JUA,5521
|
|
4
|
+
pyrrange/context.py,sha256=KQ9QpJsRwsj_Aje6PDe0DICY-P2rOFipZYMyQd14npM,730
|
|
5
|
+
pyrrange/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
pyrrange/scene.py,sha256=29w2ngQ0TKlygQaRILn7Ya7j3r9lXkCg8Lk96lN2eaA,1056
|
|
7
|
+
pyrrange-0.1.0.dist-info/METADATA,sha256=FMqTrrP-x94GN1IgvCYpZBJ72084I0xgYkT4R1u204s,9211
|
|
8
|
+
pyrrange-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
pyrrange-0.1.0.dist-info/licenses/LICENSE,sha256=X5h01ro10ODbeI9g2ZqD33l_L6YGuzd6g8liUET4ayY,1073
|
|
10
|
+
pyrrange-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Unay Santisteban
|
|
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.
|