Skip to main content

pyrrange

Build Status Coverage

Typed, composable 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 lets each test declare exactly the state it needs by composing small steps. Each step calls a real domain operation (not a factory), so the state is built the same way production builds it.

Features

  • Steps are plain functions — no base class, no inheritance
  • Operation-based: steps call real use cases, they don't create DB rows directly
  • Labeled results: reach any step's output by name, via attribute or key
  • Dependency injection: mark a parameter with on_stage() and it comes from the scene
  • Fully typed: your checker validates step arguments and resolves scene labels to their real types
  • Inline steps via then() for one-off logic
  • Teardown with a context manager for guaranteed cleanup
  • Framework-agnostic: Django, FastAPI, or any Python project

Requirements

  • Python 3.10+
  • pytest 7.0+ (optional, for pyrrange[pytest])

Installation

pip install pyrrange

With pytest integration:

pip install pyrrange[pytest]

Usage

Define steps

A step is a function decorated with @step("label"). Calling it doesn't run it — it records the call, so you can compose steps before anything executes.

from pyrrange import on_stage, step

from app.accounts.models import User
from app.accounts.services import register_user, verify_email


@step("user")
def registered(email: str = "user@example.com", password: str = "secret") -> User:
    return register_user(email=email, password=password)


@step("user")
def verified(user: User = on_stage()) -> User:
    verify_email(user)
    return user

Steps live wherever you want — one module per domain concept works well. They're imported like any other function, so go to definition works and there's no inheritance to trace.

Run them

arrange() runs the recorded steps in order and returns the resulting scene:

from pyrrange import arrange


def test_verified_user_can_log_in():
    scene = arrange(registered(), verified())

    assert scene.user.is_verified

How injection works

Mark a parameter with on_stage() and pyrrange fills it from the scene being built, using the parameter's own name as the label:

@step("user")
def registered(email: str = "user@example.com") -> User:
    # plain default → used as-is, never injected
    # override at the call site: registered(email="other@example.com")
    ...


@step("checkout")
def purchase(
    api_client: Client = on_stage(),
    payment_method: Card = on_stage(),
    config: Config | None = None,
) -> Receipt:
    # api_client, payment_method → injected from the scene
    # config → plain default, uses None
    ...

Pass a label when it differs from the parameter name:

@step("token")
def api_token(owner: User = on_stage("user")) -> Token:
    # reads the "user" label into a parameter called `owner`
    ...

The rules:

  • on_stage() → injected from the scene; raises if the label isn't there yet
  • Plain default → used as-is, never injected
  • Caller passes a keyword argument → caller wins, even over on_stage()

Because on_stage() looks like an ordinary default, the parameter is optional at the call site and your remaining arguments keep being checked:

arrange(registered(), verified())     # ok — you never pass `user` yourself
registered(emial="x")                 # error: did you mean "email"?
registered(email=123)                 # error: expected "str"
verified(user=1)                      # error: expected "User"

Note: a parameter with no default at all is also injected by name. It works, but a type checker can't tell that form apart from a required argument and will ask you to pass it — use on_stage().

Using ruff? on_stage() is a sentinel, not shared mutable state, so exempt it from B008:

[tool.ruff.lint.flake8-bugbear]
extend-immutable-calls = ["pyrrange.on_stage"]

Reusing a plan

Steps are recorded, so a chain is just a tuple. Declare it once and run it as many times as you like — each run is independent:

PAYING_CUSTOMER = (registered(), verified(), with_card(), authenticated())


def test_checkout():
    scene = arrange(*PAYING_CUSTOMER)

A shortcut is a function that returns a tuple:

def paying_customer(brand: str = "visa"):
    return registered(), verified(), with_card(brand=brand), authenticated()


def test_checkout_with_amex():
    scene = arrange(*paying_customer(brand="amex"))

No cloning, no shared state: the same records can be reused across tests and modules.

Use in tests

Pyrrange supports four consumption patterns. All examples below use these steps:

from pyrrange import Scene, arrange, on_stage, step


class AccountScene(Scene):
    user: User
    api_client: APIClient


@step("user")
def registered(email: str = "user@example.com") -> User:
    return register_user(email=email)


@step("user")
def verified(user: User = on_stage()) -> User:
    verify_email(user)
    return user


@step("api_client")
def authenticated(user: User = on_stage()) -> APIClient:
    return create_authenticated_client(user)


def delete_account(scene: AccountScene) -> None:
    scene.user.delete()

1. Direct

Call arrange() and use the scene. Teardown is manual — if the test crashes, cleanup won't run.

def test_checkout():
    scene = arrange(registered(), verified(), authenticated())

    response = scene.api_client.post("/checkout")

    assert response.status_code == 200
    scene.teardown()

2. Context manager

Wrap in with to guarantee teardown runs, even on failure.

def test_checkout():
    with arrange(registered(), verified(), authenticated(), teardown=delete_account) as scene:
        response = scene.api_client.post("/checkout")
        assert response.status_code == 200
    # teardown runs automatically on exit

3. Scenario fixtures

Install with pip install pyrrange[pytest]. Use scene_fixture to define reusable scenarios in conftest. Each test gets a fresh scene with automatic teardown.

# conftest.py
from pyrrange.pytest import scene_fixture

registered_user = scene_fixture(registered())
authenticated_user = scene_fixture(registered(), verified(), authenticated(), teardown=delete_account)

# test.py
def test_checkout(authenticated_user):
    response = authenticated_user.api_client.post("/checkout")
    assert response.status_code == 200
# teardown runs automatically via yield fixture

4. Arrange marker

Install with pip install pyrrange[pytest]. Use @pytest.mark.arrange to declare the steps and have scene labels injected directly as test parameters — no scene unpacking.

import pytest

AUTHENTICATED = (registered(), verified(), authenticated())


@pytest.mark.arrange(*AUTHENTICATED)
def test_checkout(user, api_client):
    response = api_client.post("/checkout")
    assert response.status_code == 200
# teardown runs automatically via plugin hook

If a scene label collides with a fixture of the same name, the scene value wins.

The marker coexists with regular pytest fixtures:

@pytest.mark.arrange(*AUTHENTICATED)
def test_checkout_logging(user, api_client, mocker):
    # user, api_client → from scene
    # mocker → from pytest as usual
    mock_log = mocker.patch("app.checkout.logger")
    api_client.post("/checkout")
    mock_log.info.assert_called_once()

Comparison

Pattern Teardown Scene unpacking Setup
Direct Manual scene.label None
Context manager Automatic scene.label None
Scenario fixtures Automatic scene.label pyrrange[pytest]
Arrange marker Automatic Direct params pyrrange[pytest]

Each test declares only the steps it needs:

arrange(registered())                                    # just a registered user
arrange(registered(), verified())                        # registered and verified
arrange(registered(), verified(), authenticated())       # plus an API client

Labels

Steps are labeled by default with the function name. Use @step("label") to set a custom one. The same label overwrites — latest wins.

@step("order")
def created(total: int = 100) -> Order:
    return create_order(total=total)


@step("order")
def paid(order: Order = on_stage()) -> Order:
    process_payment(order)
    return order


@step("receipt")
def with_receipt(order: Order = on_stage()) -> Receipt:
    return generate_receipt(order)


scene = arrange(created(), paid(), with_receipt())
order = scene.order        # the paid one — latest wins
receipt = scene.receipt

Inline steps with then()

Use then() for logic that isn't worth naming with @step. Parameters resolve exactly the same way.

from pyrrange import then


def create_api_token(user: User = on_stage()) -> Token:
    return Token.objects.create(user=user)


scene = arrange(
    registered(),
    verified(),
    then("token", create_api_token),
)

Works with lambdas too:

scene = arrange(
    registered(),
    then("email", lambda user=on_stage(): user.email),
)

Teardown

Pass teardown to clean up resources after a test. This is where you handle what a transaction rollback can't cover — polymorphic model deletion, external service state, file cleanup.

def delete_account(scene: AccountScene) -> None:
    scene.user.delete()


with arrange(registered(), teardown=delete_account) as scene:
    ...
# teardown runs automatically on exit

You can also call scene.teardown() manually if you prefer explicit control. Without a teardown argument it's a no-op, so calling it is always safe.

Typed scenes

By default scene.user returns Any. Declare a Scene subclass and pass it to arrange() for full IDE autocomplete and type checking:

from pyrrange import Scene, arrange, on_stage, step


class AccountScene(Scene):
    user: User
    api_client: APIClient


scene = arrange(registered(), authenticated(), scene=AccountScene)

scene.user          # User
scene.api_client    # APIClient

scene_fixture takes it too:

authenticated_user = scene_fixture(registered(), authenticated(), scene=AccountScene)

It's optional — without it, attribute access still works but returns Any. Both scene.user and scene["user"] are always available.

When a step fails

A failing step raises StepError with the position in the plan, the module the step came from, and the previous result, chaining the original exception as __cause__:

StepError: Step 2/3 'verified' failed in tests.arranges.accounts
  Previous result: <User: user@example.com>

Metadata

Release files for pyrrange 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pyrrange 0.3.0
File Size Uploaded
pyrrange-0.3.0.tar.gz 16.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pyrrange 0.3.0
File Interpreter ABI Platform
pyrrange-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 27.4 kB

Release files / pyrrange-0.3.0.tar.gz

Download URL pyrrange-0.3.0.tar.gz
Size 16.1 kB
Tags Source
SHA-256 checksum
How to use checksums
e8c14c858bf61ce7b5faa6bbd595812d6e96012072d27e887b54529bfdd57056
BLAKE2b-256 checksum
How to use checksums
4359f3110c33f80b2a0d97235c1b05c05ed3f41f7de2e5dafc0bccfd9345f588
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 5, 2026.

Transparency log

Release files / pyrrange-0.3.0-py3-none-any.whl

Download URL pyrrange-0.3.0-py3-none-any.whl
Size 11.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6c6219e11be74a3c6c4796adb8bc30537280a63a9baf8d4bce6480285d5cf877
BLAKE2b-256 checksum
How to use checksums
a6053c4d2ef4cc1dc7d9b3a66ce495a54a6f18795867dacafdc9d763dd56ac98
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 5, 2026.

Transparency log

Release history Release notifications | RSS feed

0.3.1

2 release files

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page