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 ADDED
@@ -0,0 +1,10 @@
1
+ from pyrrange._version import __version__ as __version__
2
+ from pyrrange.arrange import Arrange, StepError, step
3
+ from pyrrange.scene import Scene
4
+
5
+ __all__ = [
6
+ "Arrange",
7
+ "Scene",
8
+ "StepError",
9
+ "step",
10
+ ]
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
+ [![Build Status](https://github.com/othercodes/pyrrange/actions/workflows/test.yml/badge.svg)](https://github.com/othercodes/pyrrange/actions/workflows/test.yml)
31
+ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=othercodes_pyrrange&metric=coverage)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.