{"id":49626724,"url":"https://github.com/clear-route/choreo","last_synced_at":"2026-05-05T08:01:15.204Z","repository":{"id":352135026,"uuid":"1213251651","full_name":"clear-route/choreo","owner":"clear-route","description":"Async Python test framework for message-driven systems.","archived":false,"fork":false,"pushed_at":"2026-05-05T06:29:55.000Z","size":6135,"stargazers_count":3,"open_issues_count":8,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-05-05T06:34:48.464Z","etag":null,"topics":["e2e-testing","message-driven","testing"],"latest_commit_sha":null,"homepage":"","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/clear-route.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":".github/CODEOWNERS","security":"SECURITY.md","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-04-17T07:31:42.000Z","updated_at":"2026-05-05T06:28:58.000Z","dependencies_parsed_at":null,"dependency_job_id":"ad4d2a10-bd4e-4b50-b411-b99ff841b155","html_url":"https://github.com/clear-route/choreo","commit_stats":null,"previous_names":["clear-route/choreo"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/clear-route/choreo","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/clear-route%2Fchoreo","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/clear-route%2Fchoreo/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/clear-route%2Fchoreo/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/clear-route%2Fchoreo/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/clear-route","download_url":"https://codeload.github.com/clear-route/choreo/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/clear-route%2Fchoreo/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32640538,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-04T10:08:07.713Z","status":"online","status_checked_at":"2026-05-05T02:00:06.033Z","response_time":54,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"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":["e2e-testing","message-driven","testing"],"created_at":"2026-05-05T08:00:44.849Z","updated_at":"2026-05-05T08:01:15.188Z","avatar_url":"https://github.com/clear-route.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n  \u003cimg src=\"docs/logo.png\" alt=\"Choreo\" width=\"200\"\u003e\n\u003c/p\u003e\n\n\u003ch1 align=\"center\"\u003eChoreo\u003c/h1\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003cstrong\u003ePython 3.11+\u003c/strong\u003e\u0026ensp;|\u0026ensp;\u003cstrong\u003eApache 2.0\u003c/strong\u003e\u0026ensp;|\u0026ensp;\u003ca href=\"https://github.com/clear-route/choreo/actions/workflows/ci.yml\"\u003eCI\u003c/a\u003e\u0026ensp;|\u0026ensp;\u003ca href=\"https://clear-route.github.io/choreo/\"\u003eLive Test Report\u003c/a\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\u003cem\u003eDistributed systems need a choreographer.\u003c/em\u003e\u003c/p\u003e\n\n---\n\nChoreo is an async Python test framework for distributed, message-driven systems. You declare the messages you expect, publish the ones that trigger them, and the harness handles the routing, correlation, timing, matching, and reporting. When a test fails, the report tells you *which* hop broke and *why*.\n\nSupporting natively: **NATS**, **Kafka**, **RabbitMQ**, **Redis**. \n\n---\n\n## When to use Choreo\n\nUse Choreo when your system is message-driven and your tests need to assert on what comes back over the wire: event pipelines, pub/sub fan-out, request/reply services, sagas, workflow orchestrators, IoT telemetry, CQRS read models.\n\n**Not a good fit for:** pure unit tests with no I/O, HTTP request/response testing (use `httpx` + `respx`), or synchronous Python-only fakes.\n\nThree things Choreo does that you will not find combined in any other Python test framework today.\n\n### Near-miss diagnostics: did the message arrive, or was it wrong?\n\nWhen a scenario times out, the harness has been counting. How many messages landed on the topic, how many passed the matcher, and what the last mismatch looked like.\n\n```\nTIMEOUT on 'order.settled': 0 near-misses\n```\n\nZero near-misses means nothing arrived. You have a routing problem. Wrong topic, wrong subscription, wrong correlation ID.\n\n```\nTIMEOUT on 'order.settled': 3 near-misses\n  last mismatch: status was \"PENDING\", expected \"SETTLED\"\n  last payload: {\"order_id\": \"ORD-42\", \"status\": \"PENDING\", ...}\n```\n\nThree near-misses means the message arrived and every one failed the matcher. Routing works. The service (or the test) has the wrong shape.\n\nOne diagnostic, two very different fixes. No more staring at log files guessing which step got missed.\n\n### Latency is an assertion, not a timeout\n\nEvery other async test framework treats latency as noise. You pick a generous timeout, cross your fingers, and a breach fails the test for reasons you cannot read.\n\n```python\nh = s.expect(\"order.settled\", field_equals(\"status\", \"SETTLED\"))\nh.within_ms(50)\n```\n\n- Match inside the budget: `PASS`\n- Match outside the budget, inside the outer timeout: `SLOW` (distinct from `FAIL`)\n- Miss the outer timeout entirely: `FAIL`\n\nThree outcomes, not two. You can surface latency regressions in trend reports without blocking CI, or promote `SLOW` to a build breaker once you are ready. Your SLA is in the test.\n\n### One DSL, any boundary\n\nThe queue *is* the natural test boundary. What matters is what lands on it. Because the DSL sits above the transport, the same test can cover one service in isolation, two services composed, or a graph of services running together.\n\n```python\n# Test one service. The test doesn't care what else is in the system.\nasync with harness.scenario(\"single\") as s:\n    s.expect(\"orders.processed\", field_equals(\"status\", \"COMPLETED\"))\n    s = s.publish(\"orders.created\", {\"item_id\": \"ITEM-42\"})\n    result = await s.await_all(timeout_ms=500)\n\nresult.assert_passed()\n```\n\nNeed to stand a mannequin in for a dependency you do not want to boot? Register a reply:\n\n```python\ns.on(\"fraud.check\").publish(\n    \"fraud.result\",\n    lambda msg: {\"correlation_id\": msg[\"correlation_id\"], \"decision\": \"APPROVE\"},\n)\n```\n\nWant the real upstream in the test instead? Drop the reply, let the service run against the broker, and widen the expectation. The scenario does not care how many dancers are on the floor; it cares about the cues.\n\n### Multi-transport bridges (Stage)\n\nSome services exist to translate between transports: an HTTP listener that pushes events onto Kafka; a gateway that consumes NATS requests and republishes them as RabbitMQ messages; a router that bridges a low-latency NATS edge to a durable Kafka pipeline. The natural test boundary spans both transports.\n\n`Stage` is a coordinator that wraps a named registry of harnesses. One scenario can publish on transport A, register a reactive reply on transport B, and assert on transport A again, in a single deadline-bounded block.\n\n```python\nfrom choreo import Stage, Harness, MappedBridge\nfrom choreo.transports import NatsTransport, KafkaTransport\nfrom choreo.correlation import DictFieldPolicy\n\nstage = Stage(\n    harnesses={\n        \"nats\":  Harness(NatsTransport(...),  correlation=DictFieldPolicy()),\n        \"kafka\": Harness(KafkaTransport(...), correlation=DictFieldPolicy()),\n    },\n    bridge=MappedBridge(forwards={\n        \"nats\":  lambda logical: f\"nats-{logical}\",\n        \"kafka\": lambda logical: f\"kafka-{logical}\",\n    }),\n)\n\nasync with stage.scenario(\"nats-kafka-round-trip\") as s:\n    s.on(\"orders.new\", on=\"kafka\").publish(\n        \"orders.processed\", on=\"nats\",\n        build=lambda trigger: {\"forwarded\": trigger[\"payload\"]},\n    )\n    h = s.expect(\"results\", field_equals(\"kind\", \"result\"), on=\"nats\")\n    s.publish(\"orders.new\", {\"payload\": 42}, on=\"kafka\")\n    result = await s.await_all(timeout_ms=100)\n```\n\nThe full bridge round-trip in 13 lines of scenario body. Each transport keeps its own codec and correlation policy; a `CorrelationBridge` translates per-scope correlation across the wire. Cross-scope traffic between concurrent test scopes is filtered out by construction so 100 scopes can run against shared brokers without cross-talk.\n\n---\n\n## Install\n\n```bash\npip install choreo-harness\n\n# Add the broker extra(s) you need\npip install 'choreo-harness[nats]'      # NATS\npip install 'choreo-harness[kafka]'     # Kafka\npip install 'choreo-harness[rabbitmq]'  # RabbitMQ\npip install 'choreo-harness[redis]'     # Redis\n\n# Optional: pytest reporter plugin (HTML + JSON output)\npip install choreo-reporter\n\n# Optional: longitudinal analytics server (TimescaleDB + React dashboard)\npip install choreo-chronicle\n```\n\n- Python 3.11+\n- No runtime dependencies (`pytest`, `pytest-asyncio`, and `pyyaml` are test extras only)\n- Client libraries (`nats-py`, `aiokafka`, `aio-pika`, `redis`) ship as optional extras per transport and are lazy-imported\n- Both packages ship `py.typed` for full mypy coverage\n\n---\n\n## Quickstart\n\nZero to a passing scenario against a real NATS broker in three steps. NATS is the lightest broker to boot locally (one container, small image), so it makes the easiest first touch.\n\n### 1. Bring up the broker\n\nThe repo ships a `docker compose` profile for this.\n\n```bash\ndocker compose -f docker/compose.e2e.yaml --profile nats up -d\n```\n\n### 2. Allow the endpoint\n\nChoreo refuses to connect to anything that is not on an explicit allowlist. This is a safety guard\n\nCreate `config/allowlist.yaml`:\n\n```yaml\nnats_servers: [\"nats://localhost:4222\"]\n```\n\n### 3. Write and run a scenario\n\nCreate `tests/test_happy_path.py`:\n\n```python\nfrom pathlib import Path\n\nfrom choreo import Harness\nfrom choreo.transports import NatsTransport\nfrom choreo.matchers import contains_fields, gt\n\n\nasync def test_a_created_order_should_be_processed_with_a_positive_count():\n    transport = NatsTransport(\n        servers=[\"nats://localhost:4222\"],\n        allowlist_path=Path(\"config/allowlist.yaml\"),\n    )\n    harness = Harness(transport)\n    await harness.connect()\n\n    async with harness.scenario(\"happy\") as s:\n        s.expect(\"orders.processed\", contains_fields({\n            \"status\": \"COMPLETED\",\n            \"order\": {\"item_id\": \"ITEM-42\", \"count\": gt(0)},\n        }))\n        s = s.publish(\"orders.created\", {\"item_id\": \"ITEM-42\", \"count\": 1000})\n        result = await s.await_all(timeout_ms=500)\n\n    result.assert_passed()\n    await harness.disconnect()\n```\n\nRun it:\n\n```bash\npytest tests/\n```\n\nThat is the whole loop. `pytest-asyncio` runs in `auto` mode with a session-scoped event loop, so `async def` tests need no decorator. Swap `NatsTransport` for `KafkaTransport`, `RabbitTransport`, or `RedisTransport` and nothing above the transport constructor changes.\n\nFor real consumer repos, wrap `Harness` in a session-scoped `pytest_asyncio` fixture. See [Downstream consumer pattern](#downstream-consumer-pattern) below.\n\n---\n\n## Examples\n\nSelf-contained, runnable projects in [examples/](examples/):\n\n- **[examples/01-hello-world/](examples/01-hello-world/)**: the minimum useful test. Publish, expect, assert.\n- **[examples/02-request-reply/](examples/02-request-reply/)**: stage a fake upstream service inside the test with `on(trigger).publish(reply)`.\n- **[examples/03-parallel-isolation/](examples/03-parallel-isolation/)**: opt into a `CorrelationPolicy` so parallel scenarios don't cross-match.\n- **[examples/04-transport-auth/](examples/04-transport-auth/)**: wire a typed `auth=` descriptor into a transport, see credential lifecycle and redaction in action.\n- **[examples/05-auth-resolver/](examples/05-auth-resolver/)**: fetch credentials at `connect()` time via sync/async resolvers (env vars, Vault, Secrets Manager pattern).\n- **[examples/06-multi-transport-bridge/](examples/06-multi-transport-bridge/)**: testing a service that bridges two transports with `Stage` — a vendor-protocol adapter into the energy domain, a vendor → domain → vendor round-trip, and a dispatch orchestrator fanning out commands to multiple connected devices.\n\nEach example ships its own `README.md` explaining what it shows. Run any of them with `pytest examples/\u003cdir\u003e/`.\n\n---\n\nFour dancers on the floor:\n\n**Harness**: the session-scoped coordinator. You construct one with a Transport and call `connect()`. The transport runs its allowlist check, opens its socket, and reports ready. When the suite ends, `disconnect()` tears everything down.\n\n**Scenario**: the per-test scope. Opening one owns its expectations, replies, and timeline, and cleans them up on exit (normal or exception). Per-scope correlation isolation is opt-in via a `CorrelationPolicy`: the library default is transparent passthrough (`NoCorrelationPolicy`), so `s.publish(topic, payload)` sends `payload` unchanged. Consumers who want the legacy `TEST-`-prefixed stamping pass `correlation=test_namespace()` at Harness construction (ADR-0019).\n\n**Dispatcher**: the router. Every inbound message lands here. It pulls the correlation ID out of the payload and hands the message to the scenario that claimed it. Unmatched messages go to a **surprise log** (metadata only; payloads not retained).\n\n**Loop-poster**: the thread-safe bridge. Stateful client libraries or native-code backends that deliver messages on their own thread use the loop-poster's `loop.call_soon_threadsafe` hop to move those messages onto the asyncio loop before the dispatcher sees them. Without it, you'd get race conditions that vanish when you add a `print()`.\n\nFor scenarios that span two transports, `Stage` wraps multiple `Harness` instances behind one scope and adds a `CorrelationBridge` that translates the per-scope id across wires. See [Multi-transport bridges (Stage)](#multi-transport-bridges-stage) above.\n\n---\n\n## The Scenario DSL\n\nA scenario moves through three states. Each state exposes only the methods\nvalid at that point; illegal transitions raise `AttributeError` at runtime.\n\n```mermaid\nstateDiagram-v2\n    direction LR\n    [*] --\u003e BUILDER\n    BUILDER --\u003e EXPECTING : expect() / on()\n    EXPECTING --\u003e TRIGGERED : publish()\n    TRIGGERED --\u003e ScenarioResult : await_all()\n```\n\nThe signatures below are the single-`Harness` form. When the scenario runs under `Stage`, every method (`expect`, `publish`, `on`) takes an additional `on=\u003ctransport-name\u003e` argument that selects which harness handles the call; omitting it raises `MissingTransportError`. See [Multi-transport bridges (Stage)](#multi-transport-bridges-stage).\n\n### `expect(topic, matcher) → Handle`\n\nSubscribes to `topic`, filters by the scenario's correlation ID, applies\n`matcher` to the decoded payload. Returns a **Handle** you can hold for\npost-hoc assertions.\n\n```python\nh = s.expect(\"events.processed\", field_equals(\"status\", \"COMPLETED\"))\nh.within_ms(50)           # declare a latency SLA (optional)\n```\n\nAfter `await_all()` returns, the Handle exposes:\n\n| Field | What it tells you |\n|---|---|\n| `outcome` | `PASS` / `FAIL` / `TIMEOUT` / `SLOW` / `PENDING` |\n| `message` | decoded payload |\n| `latency_ms` | elapsed time from registration to match |\n| `attempts` | count of near-misses (messages on correlation that failed matcher) |\n| `last_mismatch_reason` | prose from the most recent mismatch |\n| `last_mismatch_payload` | decoded payload of that mismatch |\n| `failures` | structured `MatchFailure` tree (capped at 20) |\n| `was_fulfilled()` | True iff outcome is `PASS` |\n\nReading `message` or `latency_ms` while `outcome == PENDING` raises\n`RuntimeError` (ADR-0014).\n\n### `publish(topic, payload) → Scenario`\n\nSends a message through the transport. `payload` can be either raw `bytes`\n(passed through untouched) or a `dict` (codec-encoded; defaults to JSON).\n\nWith the default `NoCorrelationPolicy`, the payload goes to the wire\nunchanged, with no library-injected fields. To opt into per-scope correlation\nisolation, configure a `CorrelationPolicy` at Harness construction; see\n[Correlation policy](#correlation-policy) below.\n\n### `await_all(timeout_ms) → ScenarioResult`\n\nWaits for every registered handle to resolve or the deadline to fire.\nReturns a `ScenarioResult`:\n\n| Field / method | Purpose |\n|---|---|\n| `result.passed` | True iff every handle outcome is `PASS` |\n| `result.assert_passed()` | raises `AssertionError` with breakdown on failure |\n| `result.handles` | tuple of every handle |\n| `result.failing_handles` | handles where `outcome != PASS` |\n| `result.timeline` | chronological events: PUBLISHED / RECEIVED / MATCHED / MISMATCHED / DEADLINE / REPLIED / REPLY_FAILED |\n| `result.replies` | per-`on()` reply lifecycle reports |\n| `result.failure_summary()` | multi-line diagnostic including last 20 timeline entries |\n| `result.reply_at(trigger_topic)` | lookup a reply by its trigger topic |\n\n`assert_passed()` is the canonical assertion. Its message distinguishes a\n*silent timeout* (nothing arrived, routing bug) from a *near-miss*\n(messages arrived but failed the matcher, expectation bug).\n\n---\n\n## Replies: `on().publish()`\n\nA scenario can register a **reply**: when a matching message arrives on\na trigger topic, publish a response. The framework ships the primitive;\nconsumers compose them into bundles for their own domains. Handy for\nstaging fake upstream services inside a test without standing up a second\nprocess.\n\n```python\nasync with harness.scenario(\"instant_reply\") as s:\n    h = s.expect(\n        \"reply.received\",\n        contains_fields({\"status\": \"COMPLETED\", \"count\": 100}),\n    )\n\n    s.on(\"request.sent\").publish(\n        \"reply.received\",\n        lambda msg_received: {\n            \"correlation_id\": msg_received[\"correlation_id\"],\n            \"status\": \"COMPLETED\",\n            \"count\": msg_received[\"count\"],\n        },\n    )\n\n    s = s.publish(\"request.sent\", {\"correlation_id\": \"REQ-1\", \"count\": 100})\n    result = await s.await_all(timeout_ms=500)\n\nresult.assert_passed()\n\nreport = result.reply_at(\"request.sent\")\nassert report.state.name == \"REPLIED\"\nassert report.match_count == 1\n```\n\nRules:\n\n- Must be registered **before** `publish()`. It is a pre-trigger\n  arrangement, not a background subscription.\n- Fires **once per scope**. The chain is single-use; calling `.publish()`\n  a second time on the same `ReplyChain` raises `ReplyAlreadyBoundError`.\n- The `matcher` argument is optional; `None` means *every inbound on the\n  topic matches*.\n- The `payload` can be a static `dict`, static `bytes`, or a callable\n  `Callable[[decoded_trigger], dict | bytes]`.\n- Failures in the builder function are captured as exception **class name\n  only** in the report, never `str(e)`, so secrets from a failing builder\n  never leak into the report (ADR-0017).\n\nEach registered reply produces a `ReplyReport` in `result.replies`:\n\n| Field | Meaning |\n|---|---|\n| `state` | `ARMED_NO_MATCH` / `ARMED_MATCHER_MISMATCHED` / `REPLIED` / `REPLY_FAILED` |\n| `candidate_count` | messages that arrived on the trigger topic |\n| `match_count` | how many passed the optional matcher |\n| `reply_published` | whether the reply actually went out |\n| `builder_error` | exception class name if the builder raised |\n\n### Cross-transport replies\n\nUnder `Stage`, the trigger and the response can live on different transports. The same `on().publish()` chain takes an `on=\u003cname\u003e` argument on both ends:\n\n```python\nasync with stage.scenario(\"bridge\") as s:\n    s.on(\"orders.new\", on=\"kafka\").publish(\n        \"orders.processed\", on=\"nats\",\n        build=lambda trigger: {\"forwarded\": trigger[\"payload\"]},\n    )\n```\n\nThe framework picks the trigger up on the Kafka harness, runs the builder, and emits the response on the NATS harness — correlation translation between the two is handled by the `CorrelationBridge` configured on the `Stage`. See [Multi-transport bridges (Stage)](#multi-transport-bridges-stage) and the worked example in [examples/06-multi-transport-bridge/](examples/06-multi-transport-bridge/).\n\n---\n\n## Correlation policy\n\nA `CorrelationPolicy` decides how per-scope correlation ids are generated,\nstamped onto outbound messages, and read from inbound ones. The library\nships three profiles:\n\n```python\nfrom choreo import Harness, NoCorrelationPolicy, DictFieldPolicy, test_namespace\n\n#\nHarness(transport)\nHarness(transport, correlation=NoCorrelationPolicy())   \nHarness(transport, correlation=DictFieldPolicy(field=\"trace_id\"))\n```\n\n### When do I need one?\n\n| Situation | Policy |\n|---|---|\n| Single scenario at a time against a dedicated broker | `NoCorrelationPolicy` (default) |\n| Parallel scenarios sharing one broker connection | `DictFieldPolicy(field=...)` |\n| Shared broker across tenants / CI runs / dev machines | `DictFieldPolicy(field=..., prefix=\u003crun-unique\u003e)` |\n| Downstream systems filter test traffic on `TEST-` | `test_namespace()` |\n| Correlation lives in a header, not the payload | Custom `CorrelationPolicy` |\n\n\nA working demonstration of parallel isolation with and without a policy\nlives in [examples/03-parallel-isolation/](examples/03-parallel-isolation/).\n\n### When you also have a `CorrelationBridge`\n\n`CorrelationPolicy` and `CorrelationBridge` solve different problems and\ncompose. The policy decides how a wire id is stamped onto and read from\na single message on a single transport. The bridge — only present in\n`Stage` scenarios — translates a per-scope *logical* id into a different\n*wire* id per transport, so the same scope can be filtered independently\non Kafka, NATS, and any other harness it owns.\n\nIn a multi-transport test you typically configure both: each harness\ngets its own `DictFieldPolicy(field=\"correlation_id\")` (or whatever\nyour schema uses), and the `Stage` gets a `MappedBridge` whose\n`forwards` mapping mints a deterministic per-transport id from the\nlogical scope id. The policy then stamps the bridge's per-transport id\nonto each outbound message and reads it back on inbound, keeping\nconcurrent scopes from cross-matching even on shared brokers.\n\nSee the [Stage user guide](docs/guides/stage.md) for the full\nrelationship and [examples/06-multi-transport-bridge/](examples/06-multi-transport-bridge/)\nfor a worked example.\n\n---\n\n## Matchers\n\nMatchers live in `choreo.matchers`. They compose into expressive predicates\nover decoded payloads. See [docs/guides/matchers.md](docs/guides/matchers.md)\nfor the full cookbook.\n\n### Flat field matchers\n\n```python\nfrom choreo.matchers import (\n    field_equals, field_ne, field_in, field_gt, field_lt,\n    field_exists, field_matches,\n)\n\ns.expect(\"reading\", field_equals(\"sensor_id\", \"SENSOR-01\"))\ns.expect(\"tick\",    field_in(\"region\", (\"eu-west\", \"us-east\", \"ap-south\")))\ns.expect(\"result\",  field_gt(\"count\", 0))\ns.expect(\"result\",  field_lt(\"latency_ms\", 1000.0))\ns.expect(\"evt\",     field_exists(\"trace_id\"))\ns.expect(\"evt\",     field_ne(\"status\", \"REJECTED\"))\ns.expect(\"evt\",     field_matches(\"order_id\", r\"^ORD-\\d+$\"))\n```\n\nPaths come in three forms:\n\n- **Dotted string** - sugar for the common case. Numeric segments are\n  treated as list indices, so `\"items.0.id\"` walks into a list.\n- **Bare int** - a single-level lookup, useful for integer-keyed payloads\n  (tag-value maps): `field_equals(35, \"D\")`.\n- **Sequence** (tuple or list) - the canonical form. It is the only way\n  to reach a dict key that literally contains `\".\"`, and it disambiguates\n  a string key `\"0\"` from a list index `0`:\n\n  ```python\n  field_equals((\"trace.id\",), \"abc\")          # dict key with a dot\n  field_equals((\"items\", \"0\"), \"x\")            # string key \"0\" on a dict\n  field_equals((\"items\", 0), \"x\")              # list index 0\n  field_equals((\"fills\", 0, \"price\"), 100.25)  # mixed nesting\n  ```\n\n### Nested shape matching\n\n`contains_fields` does a recursive **subset** match over dicts and lists.\nLeaves can be literals or other matchers.\n\n```python\nfrom choreo.matchers import contains_fields, eq, ne, in_, gt, lt, matches, exists, all_of, not_\n\ns.expect(\"events.processed\", contains_fields({\n    \"event\": {\n        \"status\":     \"COMPLETED\",\n        \"count\":      gt(0),\n        \"amount\":     all_of(gt(0.0), lt(10_000.0)),\n        \"order_id\":   matches(r\"^ORD-\\d+$\"),\n        \"trace_id\":   exists(),\n    },\n    \"steps\":  [{\"state\": in_((\"NEW\", \"PART\"))}],\n    \"actor\":  not_(in_((\"blocked_1\", \"blocked_2\"))),\n}))\n```\n\n### Composition and list quantifiers\n\n```python\nfrom choreo.matchers import all_of, any_of, not_, every, any_element\n\nall_of(field_equals(\"kind\", \"CREATE\"), field_gt(\"count\", 0))\nany_of(field_equals(\"status\", \"COMPLETED\"), field_equals(\"status\", \"PART_COMPLETED\"))\nnot_(field_equals(\"status\", \"REJECTED\"))\n\n# List quantifiers: use inside contains_fields or at the top level.\nevery(field_gt(\"px\", 0.0))                       # every element passes\nany_element(field_equals(\"side\", \"BUY\"))         # at least one element passes\n```\n\nA list-quantifier inside a shape match expresses \"there exists a fill with\npx \u003e 100\" in one line:\n\n```python\ncontains_fields({\"order\": {\"fills\": any_element(field_gt(\"px\", 100.0))}})\n```\n\nThree layers deep is the rule of thumb before factoring out into a named\nmatcher.\n\n### Raw-bytes escape hatch\n\nWhen you need to match on a non-JSON payload (a fixed-width wire format,\na binary blob), `payload_contains` does a substring check on the raw bytes.\nIt **requires a `bytes` payload** and raises `TypeError` otherwise. If\nyou have a decoded dict or string, use `field_matches` / `contains_fields`\ninstead.\n\n```python\nfrom choreo.matchers import payload_contains\n\ns.expect(\"frames.echo\", payload_contains(b\"MAGIC\"))\n```\n\n### Writing your own\n\nA matcher is anything implementing the `Matcher` Protocol: `description: str`\nplus `match(payload) → MatchResult`. For a side-by-side expected/actual\ndiff in the report, also implement the optional `Reportable` Protocol\n(`expected_shape()`); matchers without it fall back to `description`.\nBuild one as a frozen dataclass and compose it exactly like the built-ins.\n\n---\n\n## Transports\n\nThe library ships five transports and defines a 7-method `Transport`\nProtocol so you can drop in your own.\n\n### `NatsTransport`\n\nTalks to a real NATS broker. Lazy-imported: `nats-py` is only required if\nyou actually construct one.\n\n```python\nfrom pathlib import Path\nfrom choreo.transports import NatsTransport\n\ntransport = NatsTransport(\n    servers=[\"nats://localhost:4222\"],\n    allowlist_path=Path(\"config/allowlist.yaml\"),\n    name=\"my-suite\",           # reported to broker (default: \"choreo\")\n    connect_timeout_s=5.0,     # total connect budget\n)\n```\n\nValidates `nats_servers` in the allowlist.\n\n`KafkaTransport`, `RabbitTransport`, and `RedisTransport` follow the same\nshape with their own constructor fields and allowlist categories\n(`kafka_brokers`, `amqp_brokers`, `redis_servers`).\n\n### `MockTransport`\n\nIn-memory pub/sub for unit-scope tests. Synchronous dispatch (subscribers\nfire before `publish()` returns). Optionally validates `endpoint` against\nthe `mock_endpoints` allowlist category.\n\n```python\nfrom pathlib import Path\nfrom choreo.transports import MockTransport\n\ntransport = MockTransport(\n    allowlist_path=Path(\"config/allowlist.yaml\"),   # optional\n    endpoint=\"mock://localhost\",                    # optional\n)\n```\n\nDiagnostic methods (for testing your test code, not your system):\n`transport.sent()`, `transport.active_subscription_count()`,\n`transport.clear_subscriptions()`.\n\n### Transport authentication\n\nEvery real transport accepts an optional `auth=` parameter, a typed\ndescriptor for the auth mode your broker requires. Credentials are cleared\nfrom memory after `connect()` returns and never appear in `repr()`,\n`pickle`, error messages, or pytest assertion diffs.\n\n```python\nfrom choreo.transports import NatsTransport, NatsAuth\n\n# Literal: credentials in source (fine for local dev / CI).\ntransport = NatsTransport(\n    servers=[\"nats://broker:4222\"],\n    auth=NatsAuth.user_password(\"admin\", \"s3cret\"),\n)\n\n# Resolver: credentials fetched at connect() time (stronger lifetime).\ntransport = NatsTransport(\n    servers=[\"nats://broker:4222\"],\n    auth=lambda: NatsAuth.token(os.environ[\"NATS_TOKEN\"]),\n)\n\n# Async resolver: for Vault, Secrets Manager, etc.\nasync def fetch_creds():\n    secret = await vault_client.read(\"secret/nats\")\n    return NatsAuth.user_password(secret[\"username\"], secret[\"password\"])\n\ntransport = NatsTransport(\n    servers=[\"nats://broker:4222\"],\n    auth=fetch_creds,\n)\n```\n\nSee [docs/guides/authentication.md](docs/guides/authentication.md) for the\nfull cookbook: every NATS auth mode, TLS variants, resolver recipes for\nVault and AWS Secrets Manager, and the consumer fixture pattern.\n\n### Writing your own transport\n\nDrop a module under\n[packages/core/src/choreo/transports/](packages/core/src/choreo/transports/)\nimplementing the `Transport` Protocol. The `connect()` implementation is\nwhere your allowlist enforcement, credential handling, and socket setup\nall live. The Harness never sees those details.\n\n```python\nfrom typing import Protocol, Callable, Optional\n\nTransportCallback = Callable[[str, bytes], None]\nOnSent = Callable[[], None]\n\nclass Transport(Protocol):\n    async def connect(self) -\u003e None: ...\n    async def disconnect(self) -\u003e None: ...\n    def subscribe(self, topic: str, callback: TransportCallback) -\u003e None: ...\n    def unsubscribe(self, topic: str, callback: TransportCallback) -\u003e None: ...\n    def publish(\n        self,\n        topic: str,\n        payload: bytes,\n        *,\n        on_sent: Optional[OnSent] = None,\n    ) -\u003e None: ...\n    def active_subscription_count(self) -\u003e int: ...\n    def clear_subscriptions(self) -\u003e None: ...\n```\n\nFollow the pattern in [nats.py](packages/core/src/choreo/transports/nats.py)\n(asyncio-native) or [mock.py](packages/core/src/choreo/transports/mock.py)\n(synchronous). The `on_sent` callback is how you report post-wire\ntiming to the timeline. Fire it after the message is on the wire, on\nthe asyncio loop thread.\n\n---\n\n## Test report\n\n**[View the live test report →](https://clear-route.github.io/choreo/)**\n\nThe **choreo-reporter** package is a pytest plugin that writes an\ninteractive HTML report and a structured JSON file at the end of every\npytest run.\n\nInstall and it is active, no explicit opt-in needed (via pytest11 entry\npoint).\n\n```bash\npip install -e 'packages/core-reporter[test]'\n\n# Run your tests as usual\npytest\n\n# Output (default location)\nls test-report/\n# index.html   results.json   assets/\n\n# Custom location\npytest --harness-report=/tmp/my-report\n\n# Disable entirely\npytest --harness-report-disable\n```\n\nWhat the report includes:\n\n- **Scenario list** with pass/fail/slow/timeout counts at a glance.\n- **Jaeger-style waterfall timeline** for each scenario: every publish,\n  receive, match, mismatch, reply, and deadline event plotted on one axis.\n- **Expected-vs-actual diffs** for failing handles, driven by\n  `matcher.expected_shape()`.\n- **Per-reply lifecycle**: was it armed? did it match? did it fire?\n- **Git metadata** per test (commit, branch, author).\n- **Credential redaction (best-effort).** The default redactor strips\n  values under a denylist of field names (`password`, `token`, `secret`,\n  `api_key`, `authorization`, close variants), scrubs bearer/API-key\n  tokens and `scheme://user:pass@host` URL credentials from string\n  output, and lets consumers layer on domain rules:\n\n  ```python\n  from choreo_reporter import register_redactor\n\n  def mask_api_keys(text: str) -\u003e str:\n      return re.sub(r\"sk-[A-Za-z0-9]{32}\", \"sk-REDACTED\", text)\n\n  register_redactor(mask_api_keys)\n  ```\n\n  Redaction is best-effort. See [SECURITY.md](SECURITY.md) for the\n  scope and the recommended posture for PII-heavy test suites.\n\n- **pytest-xdist support.** Each worker writes partial JSON; the reporter\n  merges them at session end.\n\n---\n\n## Chronicle: Longitudinal Analytics\n\nChronicle is a self-hosted reporting server that ingests `test-report-v1` JSON\nover time, stores it in TimescaleDB, and serves a React dashboard for\nperformance analytics. It answers questions no single report can: \"Is this\ntopic getting slower?\", \"Did that deploy break anything?\", \"Are we within\nbudget?\"\n\n```bash\n# After your CI pipeline runs pytest:\ncurl -X POST https://chronicle.internal/api/v1/runs \\\n  -H \"Content-Type: application/json\" \\\n  -H \"X-Chronicle-Tenant: my-team\" \\\n  -d @test-report/results.json\n```\n\nChronicle lives in `packages/chronicle/`. See the\n[Chronicle README](packages/chronicle/README.md) for installation, API\nreference, and deployment instructions.\n\n**Try it locally:**\n\n```bash\npip install choreo-chronicle\ndocker compose -f docker/compose.chronicle.yaml up -d\nDATABASE_URL=postgresql+asyncpg://chronicle:chronicle@localhost:5433/chronicle \\\n  python -m chronicle migrate\nDATABASE_URL=postgresql+asyncpg://chronicle:chronicle@localhost:5433/chronicle \\\n  python -m chronicle\n# Dashboard at http://localhost:5173\n```\n\n\n---\n\n## Running end-to-end tests\n\nA separate e2e suite exercises the Transport Protocol against real\nnetworks by pointing transport implementations at disposable brokers under\nDocker Compose.\n\n```bash\n# Install the NATS extra\npip install -e 'packages/core[test,nats]'\n\n# Bring up all brokers (NATS, NATS-auth, Kafka, RabbitMQ, Redis)\ndocker compose -f docker/compose.e2e.yaml --profile all up -d\n\n# Or just one transport\ndocker compose -f docker/compose.e2e.yaml --profile nats up -d\n\n# Run just the e2e suite\npytest -m e2e\n\n# Tear down\ndocker compose -f docker/compose.e2e.yaml --profile all down\n```\n\nThe compose stack includes both unauthenticated and authenticated broker\nprofiles. The `nats` profile brings up two NATS containers: one without\nauth on port 4222 and one with user/password auth on port 4223. The e2e\nsuite exercises the Transport Protocol contract against both, verifying\nthat the `auth=` descriptor path works end-to-end.\n\nIf `nats-py` isn't installed, or no broker is reachable at `NATS_URL`\n(default `nats://localhost:4222`), the e2e suite **skips** rather than\nfails. CI should treat a skip there as the compose stack not coming up.\n\nOverride the broker URL with `NATS_URL`, but add the URL to the\n`nats_servers` category in your allowlist first, or `connect()` will refuse.\n\n---\n\n## Linting and formatting\n\nRuff is the single source of truth for lint and format across the monorepo.\nConfig lives in the root `pyproject.toml` under `[tool.ruff]`. Pre-commit\nruns it on every commit; CI enforces the same check on every PR.\n\n```bash\n# One-time local setup\npip install pre-commit\npre-commit install\n\n# Manual invocation\nruff check packages/          # lint\nruff check --fix packages/    # lint + auto-fix\nruff format packages/         # format\nruff format --check packages/ # verify formatting without rewriting\n```\n\n---\n\n## Contributing\n\nIssues, bug reports, and pull requests are welcome. See\n[CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow and\n[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for community norms. The project\nis Apache 2.0 licensed.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fclear-route%2Fchoreo","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fclear-route%2Fchoreo","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fclear-route%2Fchoreo/lists"}