Expressive, fluent test scenario preparation for Python.
Project description
pyrrange
Expressive, fluent test scenario preparation for Python.
Why
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."
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.
Features
- Fluent, chainable API for test state preparation
- Operation-based: steps call real use cases, not create DB rows directly
- Labeled results: access any step's output by name via attribute or dict access
- Automatic dependency injection: step parameters are resolved from context by name
- Optional typed scenes: declare a
SceneTypefor full IDE autocomplete and type checking - Inline steps via
.then()for ad-hoc logic - Teardown support with context manager for guaranteed cleanup
- Framework-agnostic: works with Django, FastAPI, or any Python project
Requirements
- Python 3.10+
Installation
pip install pyrrange
Usage
Define an Arrange
Subclass Arrange and define @step methods. Each step declares what it needs via its parameter names — pyrrange injects values from the context automatically.
from pyrrange import Arrange, step
class AccountArrange(Arrange):
@step("user")
def register(self, email="user@example.com", password="secret"):
user = register_user(email=email, password=password)
return user
@step("user")
def verified(self, user):
verify_email(user)
return user
@step("user")
def as_admin(self, user):
user.is_admin = True
user.save()
return user
How injection works
Parameters are resolved using a simple rule:
- No default value + name matches a label in context → injected automatically
- Has default value → uses the default, never injected (safe from silent overrides)
- Caller provides a value → caller always wins
@step("user")
def register(self, email="user@example.com"):
# `email` has a default → not injected, uses "user@example.com"
# Override via chain: .register(email="other@example.com")
...
@step("user")
def verified(self, user):
# `user` has no default → injected from context["user"]
...
@step("checkout")
def purchase(self, api_client, payment_method, config=None):
# `api_client` → injected from context["api_client"]
# `payment_method` → injected from context["payment_method"]
# `config` has a default → not injected, uses None
...
This means every dependency is typed in the signature — your IDE gives autocomplete and your type checker validates usage.
Use in tests
With context manager (recommended — guarantees teardown):
def test_login(account_arrange):
with account_arrange.register().arrange() as scene:
response = client.post("/login", {"email": scene.user.email, "password": "secret"})
assert response.status_code == 200
Without context manager:
def test_login(account_arrange):
scene = account_arrange.register().arrange()
response = client.post("/login", {"email": scene.user.email, "password": "secret"})
assert response.status_code == 200
scene.teardown()
Each test declares only the steps it needs:
# Just a registered user
scene = account_arrange.register().arrange()
# Registered and verified
scene = account_arrange.register().verified().arrange()
# Full admin user
scene = account_arrange.register().verified().as_admin().arrange()
Labels
Steps are labeled by default with the method name. Use @step("label") to set a custom label. Same label overwrites (latest wins).
class OrderArrange(Arrange):
@step("order")
def create(self, total=100):
return create_order(total=total)
@step("order")
def paid(self, order):
process_payment(order)
return order
@step("receipt")
def with_receipt(self, order):
return generate_receipt(order)
scene = OrderArrange().create().paid().with_receipt().arrange()
order = scene.order
receipt = scene.receipt
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.
Inline steps with .then()
Use .then() to add a step without defining a method. Parameter names are matched against context labels, just like @step methods.
def create_api_token(user):
return Token.objects.create(user=user)
scene = (
account_arrange
.register()
.verified()
.then("token", create_api_token)
.arrange()
)
user = scene.user
token = scene.token
Works with lambdas too — the parameter name is the injection key:
scene = (
account_arrange
.register()
.then("email", lambda user: user.email)
.arrange()
)
Teardown
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.
class AccountArrange(Arrange):
@step("user")
def register(self, email="user@example.com"):
return register_user(email=email)
def teardown(self, scene):
scene.user.delete()
Use the context manager to guarantee teardown runs, even if the test crashes:
with account_arrange.register().arrange() as scene:
user = scene.user
# ... test ...
# teardown runs automatically on exit
You can also call scene.teardown() manually if you prefer explicit control.
Typed Scene
By default, scene.user returns Any. For full IDE autocomplete and type checking, declare a SceneType on your Arrange:
from pyrrange import Arrange, Scene, step
class AccountArrange(Arrange):
class SceneType(Scene):
user: User
api_client: APIClient
@step("user")
def register(self, email="user@example.com") -> User:
return register_user(email=email)
@step("api_client")
def with_client(self, user: User) -> APIClient:
return create_client(user)
When SceneType is declared, .arrange() returns an instance of that subclass. Your IDE sees scene.user as User and scene.api_client as APIClient.
SceneType is optional — without it, attribute access still works but returns Any. Both scene.user and scene["user"] are always available.
Expose arranges as fixtures
@pytest.fixture
def account_arrange():
return AccountArrange()
def test_something(account_arrange):
with account_arrange.register().verified().arrange() as scene:
user = scene.user
Chain shortcuts
For common step combinations, define convenience methods on your Arrange:
class AccountArrange(Arrange):
@step("user")
def register(self, email="user@example.com"):
...
@step("user")
def verified(self, user):
...
@step("api_client")
def with_authenticated_client(self, user):
...
def authenticated(self):
return self.register().verified().with_authenticated_client()
# In tests:
scene = account_arrange.authenticated().arrange()
These are plain Python methods — no framework magic.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyrrange-0.1.0.tar.gz.
File metadata
- Download URL: pyrrange-0.1.0.tar.gz
- Upload date:
- Size: 12.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
69d1c7dd8bbe4f333cf67db9fd156337e02453f75c437344589095a19d53eea8
|
|
| MD5 |
68a232077e31f5d82612e60f53dc679a
|
|
| BLAKE2b-256 |
dfcc73a362177b4fd3bdfbed2f77a989bc9795a3e6b86af3e0cd750a6e88eb5e
|
Provenance
The following attestation bundles were made for pyrrange-0.1.0.tar.gz:
Publisher:
release.yml on othercodes/pyrrange
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyrrange-0.1.0.tar.gz -
Subject digest:
69d1c7dd8bbe4f333cf67db9fd156337e02453f75c437344589095a19d53eea8 - Sigstore transparency entry: 1245709993
- Sigstore integration time:
-
Permalink:
othercodes/pyrrange@145a126d56b4841e8561248af74adf4f24f1d05d -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/othercodes
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@145a126d56b4841e8561248af74adf4f24f1d05d -
Trigger Event:
push
-
Statement type:
File details
Details for the file pyrrange-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pyrrange-0.1.0-py3-none-any.whl
- Upload date:
- Size: 8.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6ed1504648e8969bb3ef988ed7749423a6db37a3384e75b28245405f18ea4da9
|
|
| MD5 |
57e7a43778d979fd794141333be0491e
|
|
| BLAKE2b-256 |
0c4e72dd6ffde248cd6a0dbe0fca3304ac3ffb4eb5d8ec9f5acde2a2c87e724f
|
Provenance
The following attestation bundles were made for pyrrange-0.1.0-py3-none-any.whl:
Publisher:
release.yml on othercodes/pyrrange
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyrrange-0.1.0-py3-none-any.whl -
Subject digest:
6ed1504648e8969bb3ef988ed7749423a6db37a3384e75b28245405f18ea4da9 - Sigstore transparency entry: 1245710077
- Sigstore integration time:
-
Permalink:
othercodes/pyrrange@145a126d56b4841e8561248af74adf4f24f1d05d -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/othercodes
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@145a126d56b4841e8561248af74adf4f24f1d05d -
Trigger Event:
push
-
Statement type: