{"id":49307967,"url":"https://github.com/othercodes/pyrrange","last_synced_at":"2026-04-26T10:30:40.355Z","repository":{"id":349662555,"uuid":"1196799463","full_name":"othercodes/pyrrange","owner":"othercodes","description":"Expressive, fluent test scenario preparation for Python","archived":false,"fork":false,"pushed_at":"2026-04-07T02:22:47.000Z","size":41,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-04-07T02:24:50.965Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/othercodes.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-31T03:46:45.000Z","updated_at":"2026-04-07T02:22:56.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/othercodes/pyrrange","commit_stats":null,"previous_names":["othercodes/pyrrange"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/othercodes/pyrrange","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/othercodes%2Fpyrrange","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/othercodes%2Fpyrrange/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/othercodes%2Fpyrrange/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/othercodes%2Fpyrrange/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/othercodes","download_url":"https://codeload.github.com/othercodes/pyrrange/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/othercodes%2Fpyrrange/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32294591,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-26T09:34:17.070Z","status":"ssl_error","status_checked_at":"2026-04-26T09:34:00.993Z","response_time":129,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-04-26T10:30:39.670Z","updated_at":"2026-04-26T10:30:40.338Z","avatar_url":"https://github.com/othercodes.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# pyrrange\n\n[![Build Status](https://github.com/othercodes/pyrrange/actions/workflows/test.yml/badge.svg)](https://github.com/othercodes/pyrrange/actions/workflows/test.yml)\n[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=othercodes_pyrrange\u0026metric=coverage)](https://sonarcloud.io/summary/new_code?id=othercodes_pyrrange)\n\nExpressive, fluent test scenario preparation for Python.\n\n## Why\n\nIn 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.\"\n\nPyrrange 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.\n\n## Features\n\n- Fluent, chainable API for test state preparation\n- Operation-based: steps call real use cases, not create DB rows directly\n- Labeled results: access any step's output by name via attribute or dict access\n- Automatic dependency injection: step parameters are resolved from context by name\n- Optional typed scenes: declare a `SceneType` for full IDE autocomplete and type checking\n- Inline steps via `.then()` for ad-hoc logic\n- Teardown support with context manager for guaranteed cleanup\n- Framework-agnostic: works with Django, FastAPI, or any Python project\n\n## Requirements\n\n- Python 3.10+\n- pytest 7.0+ (optional, for `pyrrange[pytest]`)\n\n## Installation\n\n```bash\npip install pyrrange\n```\n\nWith pytest integration:\n\n```bash\npip install pyrrange[pytest]\n```\n\n## Usage\n\n### Define an Arrange\n\nSubclass `Arrange` and define `@step` methods. Each step declares what it needs via its parameter names — pyrrange injects values from the context automatically.\n\n```python\nfrom pyrrange import Arrange, step\n\n\nclass AccountArrange(Arrange):\n    @step(\"user\")\n    def register(self, email=\"user@example.com\", password=\"secret\"):\n        user = register_user(email=email, password=password)\n        return user\n\n    @step(\"user\")\n    def verified(self, user):\n        verify_email(user)\n        return user\n\n    @step(\"user\")\n    def as_admin(self, user):\n        user.is_admin = True\n        user.save()\n        return user\n```\n\n### How injection works\n\nParameters are resolved using a simple rule:\n\n- **No default value** + name matches a label in context → **injected automatically**\n- **Has default value** → **uses the default**, never injected (safe from silent overrides)\n- **Caller provides a value** → **caller always wins**\n\n```python\n@step(\"user\")\ndef register(self, email=\"user@example.com\"):\n    # `email` has a default → not injected, uses \"user@example.com\"\n    # Override via chain: .register(email=\"other@example.com\")\n    ...\n\n@step(\"user\")\ndef verified(self, user):\n    # `user` has no default → injected from context[\"user\"]\n    ...\n\n@step(\"checkout\")\ndef purchase(self, api_client, payment_method, config=None):\n    # `api_client` → injected from context[\"api_client\"]\n    # `payment_method` → injected from context[\"payment_method\"]\n    # `config` has a default → not injected, uses None\n    ...\n```\n\nThis means every dependency is **typed in the signature** — your IDE gives autocomplete and your type checker validates usage.\n\n### Use in tests\n\nPyrrange supports four consumption patterns. All examples below use the same arrange:\n\n```python\nfrom pyrrange import Arrange, Scene, step\n\nclass AccountArrange(Arrange):\n    class SceneType(Scene):\n        user: User\n        api_client: APIClient\n\n    @step(\"user\")\n    def register(self, email=\"user@example.com\"):\n        return register_user(email=email)\n\n    @step(\"user\")\n    def verified(self, user):\n        verify_email(user)\n        return user\n\n    @step(\"api_client\")\n    def with_client(self, user):\n        return create_authenticated_client(user)\n\n    def teardown(self, scene):\n        scene.user.delete()\n```\n\n#### 1. Direct\n\nCall `.arrange()` and use the scene. Teardown is manual — if the test crashes, cleanup won't run.\n\n```python\ndef test_checkout():\n    scene = AccountArrange().register().verified().with_client().arrange()\n    response = scene.api_client.post(\"/checkout\")\n    assert response.status_code == 200\n    scene.teardown()\n```\n\n#### 2. Context manager\n\nWrap in `with` to guarantee teardown runs, even on failure.\n\n```python\ndef test_checkout():\n    with AccountArrange().register().verified().with_client().arrange() as scene:\n        response = scene.api_client.post(\"/checkout\")\n        assert response.status_code == 200\n    # teardown runs automatically on exit\n```\n\n#### 3. Scenario fixtures\n\nInstall with `pip install pyrrange[pytest]`. Use `scene_fixture` to define reusable scenarios in conftest. Each test gets a fresh clone with automatic teardown.\n\n```python\n# conftest.py\nfrom pyrrange.pytest import scene_fixture\n\nregistered = scene_fixture(AccountArrange().register())\nauthenticated = scene_fixture(AccountArrange().register().verified().with_client())\n\n# test.py\ndef test_checkout(authenticated):\n    response = authenticated.api_client.post(\"/checkout\")\n    assert response.status_code == 200\n# teardown runs automatically via yield fixture\n```\n\n#### 4. Arrange marker\n\nInstall with `pip install pyrrange[pytest]`. Use `@pytest.mark.arrange` to declare a chain and have scene labels injected directly as test parameters — no scene unpacking.\n\n```python\nimport pytest\n\n_authenticated = AccountArrange().register().verified().with_client()\n\n@pytest.mark.arrange(_authenticated)\ndef test_checkout(user, api_client):\n    response = api_client.post(\"/checkout\")\n    assert response.status_code == 200\n# teardown runs automatically via plugin hook\n```\n\nThe marker coexists with regular pytest fixtures:\n\n```python\n@pytest.mark.arrange(_authenticated)\ndef test_checkout_logging(user, api_client, mocker):\n    # user, api_client → from scene\n    # mocker → from pytest as usual\n    mock_log = mocker.patch(\"app.checkout.logger\")\n    api_client.post(\"/checkout\")\n    mock_log.info.assert_called_once()\n```\n\n#### Comparison\n\n| Pattern | Teardown | Scene unpacking | Setup |\n|---|---|---|---|\n| Direct | Manual | `scene.label` | None |\n| Context manager | Automatic | `scene.label` | None |\n| Scenario fixtures | Automatic | `scene.label` | `pyrrange[pytest]` |\n| Arrange marker | Automatic | Direct params | `pyrrange[pytest]` |\n\nEach test declares only the steps it needs:\n\n```python\n# Just a registered user\nAccountArrange().register()\n\n# Registered and verified\nAccountArrange().register().verified()\n\n# Full authenticated user with API client\nAccountArrange().register().verified().with_client()\n```\n\n### Labels\n\nSteps are labeled by default with the method name. Use `@step(\"label\")` to set a custom label. Same label overwrites (latest wins).\n\n```python\nclass OrderArrange(Arrange):\n    @step(\"order\")\n    def create(self, total=100):\n        return create_order(total=total)\n\n    @step(\"order\")\n    def paid(self, order):\n        process_payment(order)\n        return order\n\n    @step(\"receipt\")\n    def with_receipt(self, order):\n        return generate_receipt(order)\n\nscene = OrderArrange().create().paid().with_receipt().arrange()\norder = scene.order\nreceipt = scene.receipt\n```\n\n\u003e **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.\n\n### Inline steps with `.then()`\n\nUse `.then()` to add a step without defining a method. Parameter names are matched against context labels, just like `@step` methods.\n\n```python\ndef create_api_token(user):\n    return Token.objects.create(user=user)\n\nscene = (\n    account_arrange\n        .register()\n        .verified()\n        .then(\"token\", create_api_token)\n        .arrange()\n)\nuser = scene.user\ntoken = scene.token\n```\n\nWorks with lambdas too — the parameter name is the injection key:\n\n```python\nscene = (\n    account_arrange\n        .register()\n        .then(\"email\", lambda user: user.email)\n        .arrange()\n)\n```\n\n### Teardown\n\nOverride `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.\n\n```python\nclass AccountArrange(Arrange):\n    @step(\"user\")\n    def register(self, email=\"user@example.com\"):\n        return register_user(email=email)\n\n    def teardown(self, scene):\n        scene.user.delete()\n```\n\nUse the context manager to guarantee teardown runs, even if the test crashes:\n\n```python\nwith account_arrange.register().arrange() as scene:\n    user = scene.user\n    # ... test ...\n# teardown runs automatically on exit\n```\n\nYou can also call `scene.teardown()` manually if you prefer explicit control.\n\n### Typed Scene\n\nBy default, `scene.user` returns `Any`. For full IDE autocomplete and type checking, declare a `SceneType` on your Arrange:\n\n```python\nfrom pyrrange import Arrange, Scene, step\n\nclass AccountArrange(Arrange):\n    class SceneType(Scene):\n        user: User\n        api_client: APIClient\n\n    @step(\"user\")\n    def register(self, email=\"user@example.com\") -\u003e User:\n        return register_user(email=email)\n\n    @step(\"api_client\")\n    def with_client(self, user: User) -\u003e APIClient:\n        return create_client(user)\n```\n\nWhen `SceneType` is declared, `.arrange()` returns an instance of that subclass. Your IDE sees `scene.user` as `User` and `scene.api_client` as `APIClient`.\n\n`SceneType` is optional — without it, attribute access still works but returns `Any`. Both `scene.user` and `scene[\"user\"]` are always available.\n\n### Chain shortcuts\n\nFor common step combinations, define convenience methods on your Arrange:\n\n```python\nclass AccountArrange(Arrange):\n    @step(\"user\")\n    def register(self, email=\"user@example.com\"):\n        ...\n\n    @step(\"user\")\n    def verified(self, user):\n        ...\n\n    @step(\"api_client\")\n    def with_authenticated_client(self, user):\n        ...\n\n    def authenticated(self):\n        return self.register().verified().with_authenticated_client()\n\n# In tests:\nscene = account_arrange.authenticated().arrange()\n```\n\nThese are plain Python methods — no framework magic.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fothercodes%2Fpyrrange","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fothercodes%2Fpyrrange","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fothercodes%2Fpyrrange/lists"}