Commit fefbd64
[FEAT]:
## Description
### Summary
Adds first-class `pytest-xdist` support to RAMPART so attack/probe
sessions can shard across worker processes and still produce a single
coherent report. Worker processes serialize their results through
xdist's `workeroutput` channel; the controller deserializes, merges, and
writes the final report.
### What's included
**New: `_xdist.py`**
- Versioned schema (`rampart.xdist.v1`) for worker → controller payloads
- `_sanitize` JSON-safe coercion at the trust boundary with depth limit,
byte-size cap, and ANSI-escape stripping on deserialize to defend
against terminal injection from worker output
- `SizeLimitError` raised by `finalize_worker` when the serialized
payload exceeds the configured cap; controller logs and continues
- Configurable cap via new pytest CLI option `--rampart-xdist-max-bytes`
/ ini option `rampart_xdist_max_bytes` (default 64 MiB), validated
positive
- Sink resolution re-raises `KeyboardInterrupt`/`SystemExit`, catches
`Exception`
**Trial-group aggregation across workers**
- Trial metadata is captured as data on `RampartSession` at collection
time and shipped through the `rampart.xdist.v1` payload (`trial_specs`),
so the controller aggregates trial groups from merged worker data
instead of `session.items` — which is not reliably populated with trial
clones on the controller at session finish. Without this, trial-group
FAIL/PASS verdicts silently disappeared under xdist even though
per-clone results were present.
- `trial_specs` is back-compatible: payloads without trials emit an
empty list; malformed entries are skipped and non-finite thresholds are
clamped on deserialize.
**Deterministic result ordering & sink resolution (review feedback)**
- Merged results sort by a stable key `(_pytest_nodeid,
_rampart_result_index, _rampart_source_worker)` so aggregated reports
are reproducible regardless of worker completion order. These
bookkeeping keys are namespaced with leading underscores so they cannot
collide with user-supplied result metadata.
`_pytest_nodeid`/`_rampart_result_index` are assigned authoritatively
(absorb-time enumerate; deserialize-time outer-key + enumerate);
`_rampart_source_worker` is tagged on `handle_testnodedown`. The full
node ID (not the trailing `test_name`) is the primary sort key, so
same-named tests in different files, parametrized cases, and trial
clones no longer collide.
- New `pytest_rampart_sinks` hook (`_hooks.py`) is the authoritative
sink source — keyed on hook-impl existence, not truthiness — and
short-circuits the fixture path so projects defining both don't
double-register. The `rampart_sinks` fixture remains the single-process
fallback.
- Controller-side discovery **unwraps a `@pytest.fixture`-wrapped
parameterless `rampart_sinks`** to its underlying function
(`_get_wrapped_function()` with `_fixture_function`/`__wrapped__`
fallbacks, wrapped in try/except) and calls it directly, restoring the
documented session-fixture fallback under `-n`. Fixtures that depend on
other fixtures now warn and point at the hook instead of silently
disappearing.
- `is_xdist_controller` keys off the active distribution (`dist != "no"`
plus `-n`/`--tx` endpoints), not the worker count alone, so `-d`/`--tx`
runs without `-n` and custom schedulers resolve to the controller branch
instead of the empty single-process path.
- Shared `strip_ansi` extracted to `rampart/common/text.py`
(CSI/OSC/DCS/SOS/PM/APC/NF + 7-bit and 8-bit C1, then residual C0/C1
stripping, preserving tab/newline/CR) and reused by both the terminal
summary and the xdist boundary.
**Incomplete runs fail loudly (review feedback)**
- A lost or crashed xdist worker can leave every surviving test green.
For a safety framework "green" must mean "ran and passed", so
`pytest_sessionfinish` forces a non-zero exit status (`OK →
TESTS_FAILED`) whenever the merged session is incomplete. An
already-failing status is left untouched so a stronger failure signal
(e.g. `INTERRUPTED`) is never masked. Workers early-return before
enforcement, so a worker's local incompleteness never decides the
controller's exit code.
- Incomplete-run reasons are also surfaced as a prominent terminal
warning (before the `has_results` guard) when workers crash or report
partial data.
**Updated plugin hooks (`plugin.py`, `_session.py`)**
- `pytest_addoption` registers the new CLI/ini option; `pytest_addhooks`
registers `pytest_rampart_sinks`
- Worker, controller, and non-xdist branches dispatched cleanly; shared
aggregation/gating is hoisted above the branch and only xdist metadata +
sink discovery are guarded by `is_xdist_controller`; the worker branch
wraps `finalize_worker` to surface size-limit truncation as a warning
**Reporting**
- `JsonFileReportSink` now projects `report.metadata`, so xdist run-mode
info (worker_count, dist_mode, incomplete reasons) lands in the emitted
JSON file. The xdist transport serializer and the JSON-report serializer
are deliberately kept separate (full-fidelity round-trip vs. flatter
public shape), with doc-comments on both explaining why they must not be
merged.
**Tests**
- 82 unit tests in `test_xdist.py` (serialization, sanitization, size
limits, ANSI stripping, controller detection, sink resolution/unwrap,
option parsing, ordering determinism incl. reverse-order merge,
trial-spec round-trip/merge)
- 13 unit tests in `test_text.py` for shared `strip_ansi` (CSI, OSC
BEL/ST, DCS, 8-bit CSI, lone C1, residual C0, whitespace preservation,
no over-stripping)
- `test_plugin.py` extended (60 tests) for sink-hook resolution, OSC
sanitizing, the incomplete-warning path, and incomplete-run exit-status
enforcement (`TestIncompleteExitStatus`)
- `test_json_file.py` extended (14 tests) for metadata projection
- Slow-marked unit `test_xdist_aggregation.py` (13 tests) exercising
end-to-end aggregation across real worker subprocesses, including
trial-group verdicts under both `--dist=loadgroup` and `--dist=load`
(run with `-m slow`)
**Docs**
- New `xdist.md` user guide, incl. the `pytest_rampart_sinks` hook, sink
precedence, `--dist=loadgroup` vs default `load` guidance, and a
"Durability limitations" section (workeroutput delivers at worker
shutdown; an over-cap payload is dropped wholesale — durable per-worker
shards are a stacked follow-up)
- Configuration, CI-integration, pytest-integration,
results-and-reporting, and API reference pages updated to describe the
new option (under a dedicated "Pytest Options" section, not under env
vars)
- Architecture page notes the controller/worker split
**Incidental**
- `onedrive.py`: file-level pyright directive suppressing the `Unknown*`
cascade caused by `msgraph-sdk` shipping without type stubs. No behavior
change.
- `_xdist.py`: `# ty: ignore[unresolved-attribute]` on the pytest-xdist
runtime `config.workeroutput` attribute so `ty check` passes under the
repo's pyright → ty migration.
### Security notes
The xdist boundary treats worker output as untrusted:
- All values pass through `_sanitize` → JSON-safe primitives only
- Strings are stripped of the full ECMA-48 escape family on deserialize
(CSI, OSC, DCS/SOS/PM/APC, 7-bit and 8-bit C1, plus residual C0/C1
control bytes), including nested strings inside dicts/lists, to prevent
terminal injection (e.g. OSC hyperlinks/title sets) from a compromised
or misbehaving worker. The sanitizer has no catastrophic-backtracking
exposure.
- Total payload size is capped to prevent controller memory exhaustion;
truncation is recorded with a marker and raised as `SizeLimitError`
- Depth cap (`MAX_METADATA_DEPTH = 6`) prevents pathological nesting
### Validation
- `uv run ruff check rampart tests` → all checks pass; `ruff format
--check` clean
- `uv run ty check` → all checks passed
- `uv run pytest tests/unit -m "not slow"` → 597 passed
- `uv run pytest tests/unit/pytest_plugin/test_xdist_aggregation.py -m
slow` → 13 passed (trial-group verdicts verified under
`--dist=loadgroup` and `--dist=load`)
- `uv run --group docs mkdocs build --strict` → no cross-reference
warnings (remaining output is Windows-only symlink noise from the
font-download plugin; clean on CI/Linux)
- Ran `pytest -n 4 --dist=loadgroup` against the HelpDesk Bot example in
`rampart-examples`: 1 report generated. Running with xdist took ~99.66s
versus ~230.73s serial (yay for speed!)
## Breaking changes
None
## Checklist
- [x] `pre-commit run --all-files` passes
- [x] Tests added or updated for changes
- [x] Documentation updated
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>pytest-xdist support for distributed test execution (#73)1 parent b7899be commit fefbd64
23 files changed
Lines changed: 4038 additions & 67 deletions
File tree
- docs
- api
- contributing
- usage
- rampart
- common
- pytest_plugin
- reporting
- surfaces
- tests/unit
- common
- pytest_plugin
- reporting
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
57 | 57 | | |
58 | 58 | | |
59 | 59 | | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
60 | 69 | | |
61 | 70 | | |
62 | 71 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
| 38 | + | |
38 | 39 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
19 | 30 | | |
20 | 31 | | |
21 | 32 | | |
| |||
57 | 68 | | |
58 | 69 | | |
59 | 70 | | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
60 | 88 | | |
61 | 89 | | |
62 | 90 | | |
63 | 91 | | |
64 | | - | |
| 92 | + | |
65 | 93 | | |
66 | 94 | | |
67 | 95 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
7 | 17 | | |
8 | 18 | | |
9 | 19 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
68 | 68 | | |
69 | 69 | | |
70 | 70 | | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
71 | 74 | | |
72 | 75 | | |
73 | 76 | | |
| |||
98 | 101 | | |
99 | 102 | | |
100 | 103 | | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
101 | 145 | | |
102 | 146 | | |
103 | 147 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
91 | 91 | | |
92 | 92 | | |
93 | 93 | | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
94 | 97 | | |
95 | 98 | | |
96 | 99 | | |
| |||
0 commit comments