pytest-resumable-stepmetrics
Structured step-level metadata, retry / attempt tracking, and resume-on-retry for pytest — plus a tiny extension system so you can attach your own domain records (and get JSON + terminal reporting for free).
pip install pytest-resumable-stepmetrics
Why
- Track named steps inside a test with status, duration and captured logs.
- Know which attempt a step (or record) belongs to when a test retries.
- Resume past already-succeeded, idempotent steps on a retry — skip the expensive work instead of re-running everything from the top.
- Attach your own structured records (a dataclass) and have them serialised
to
report.jsonand rendered as a terminal table automatically.
Quick start
def test_flow(steplog):
with steplog("setup"):
...
with steplog("do work"):
...
Run with a JSON report:
pytest --steplog-json
Retry & attempt tracking
Call steplog.reset_attempt() as the first statement of each attempt (e.g.
inside a retry loop). run.retry_count and each step's attempt are tracked
automatically:
from retry import retry # any retry mechanism works
def test_with_retries(steplog):
@retry(tries=3, delay=0)
def run():
steplog.reset_attempt() # first line of every attempt
with steplog("environment"):
...
with steplog("flash"):
... # raise to trigger a retry
run()
The steps table gains an Attempt column automatically when retries occur.
Resume-on-retry (opt-in, idempotent steps only)
steplog.resumable("name") records success and, on a later attempt, skips the
body if it already passed. Guard the body with step.resumed:
with steplog.resumable("download artifact") as step:
if not step.resumed:
download() # runs once; skipped on later attempts
⚠️ Only use
resumablefor pure / idempotent steps whose effects survive a retry (downloads, name resolution, hashing). Stateful steps (deploys, power cycles, connection setup) should use plainsteplog(...)so they re-run.
Guard-free resume with steplog.run(...)
A with block always runs its body — so resumable needs the
if not step.resumed: guard. If you'd rather skip the work automatically with
no guard, pass the work as a callable to steplog.run(...); it simply isn't
called when the step already passed:
def download():
...expensive work...
def test_flow(steplog):
steplog.reset_attempt()
steplog.run("download artifact", download) # skipped entirely on retry
steplog.run returns whatever the callable returns (or None when skipped) and
forwards any extra *args / **kwargs to it.
Custom records (the extension point)
Attach any dataclass with steplog.record(...). Register it with
@steplog_record to name its report section and auto-stamp fields (like
attempt) from the live context:
from dataclasses import dataclass
from pytest_resumable_stepmetrics import steplog_record
@steplog_record(key="deploy_actions", stamp=("attempt",))
@dataclass
class DeployAction:
component: str
action: str
attempt: int = 1 # auto-filled from the current attempt
def test_deploy(steplog):
steplog.reset_attempt()
steplog.record(DeployAction(component="api", action="deployed"))
This produces a deploy_actions array in report.json and a terminal
table — no extra wiring. A plain (unregistered) dataclass also works; it uses
the snake_case class name as its key and auto-tabulates its fields.
Provide a custom renderer for full control:
@steplog_record(key="samples", render=lambda rows: my_table(rows))
@dataclass
class BenchSample:
metric: str
value: float
The steplog API
| Call | Purpose |
|---|---|
steplog("name") |
Track a step (context manager). |
steplog.resumable("name") |
Track a step that skips on retry once passed (guard with step.resumed). |
steplog.run("name", func, *a, **kw) |
Track a callable step; skips calling func on retry (guard-free). |
steplog.record(obj) |
Attach a custom dataclass record. |
steplog.reset_attempt() |
Advance the attempt counter (call first each attempt). |
steplog.context |
Mutable dict used to auto-stamp records. |
steplog.collector |
The underlying StepLogCollector. |
End-to-end example (API testing)
The test
# tests/test_create_order_flow.py
import pytest
from dataclasses import dataclass
from pytest_resumable_stepmetrics import steplog_record
@steplog_record(key="api_requests", stamp=("attempt",))
@dataclass
class ApiRequest:
"""One HTTP call made during the test — auto-stamped with the current attempt."""
endpoint: str
method: str
status_code: int
latency_ms: float
attempt: int = 1 # filled automatically from steplog context
def test_create_order_flow(steplog):
"""Create an order via a REST API. Retries once if the first attempt fails."""
def run():
steplog.reset_attempt() # must be the first call in every attempt
# --- authenticate once; skip on retry if it already passed ---
steplog.run("authenticate", authenticate)
# --- GET /products ---
with steplog("GET /products"):
resp = get_products()
steplog.record(ApiRequest("/api/products", "GET", resp.status_code, resp.elapsed_ms))
assert resp.status_code == 200
# --- POST /orders ---
with steplog("POST /orders"):
resp = create_order(product_id=resp.json()[0]["id"])
steplog.record(ApiRequest("/api/orders", "POST", resp.status_code, resp.elapsed_ms))
assert resp.status_code == 201
assert "id" in resp.json(), "order id missing from response"
retry(run, tries=2)
Sample JSON report (report.json)
Generated by pytest --steplog-json. One file per test under .steplog/.
{
"run": {
"test_nodeid": "tests/test_create_order_flow.py::test_create_order_flow",
"status": "passed",
"started_at": "2026-08-09T08:25:39.092605+00:00",
"ended_at": "2026-08-09T08:25:41.340120+00:00",
"duration_seconds": 2.248,
"retry_count": 1,
"info": {}
},
"steps": [
{
"name": "authenticate",
"attempt": 1,
"resumed": false,
"status": "passed",
"started_at": "2026-08-09T08:25:39.092605+00:00",
"ended_at": "2026-08-09T08:25:39.512100+00:00",
"duration_seconds": 0.419,
"error": null,
"info": {}
},
{
"name": "GET /products",
"attempt": 1,
"resumed": false,
"status": "passed",
"started_at": "2026-08-09T08:25:39.512100+00:00",
"ended_at": "2026-08-09T08:25:39.654400+00:00",
"duration_seconds": 0.142,
"error": null,
"info": {}
},
{
"name": "POST /orders",
"attempt": 1,
"resumed": false,
"status": "failed",
"started_at": "2026-08-09T08:25:39.654400+00:00",
"ended_at": "2026-08-09T08:25:39.942000+00:00",
"duration_seconds": 0.288,
"error": "order id missing from response",
"info": {}
},
{
"name": "authenticate",
"attempt": 2,
"resumed": true,
"status": "skipped",
"started_at": "2026-08-09T08:25:39.942000+00:00",
"ended_at": "2026-08-09T08:25:39.942000+00:00",
"duration_seconds": 0.0,
"error": null,
"info": { "resumed": true }
},
{
"name": "GET /products",
"attempt": 2,
"resumed": false,
"status": "passed",
"started_at": "2026-08-09T08:25:39.942000+00:00",
"ended_at": "2026-08-09T08:25:40.084300+00:00",
"duration_seconds": 0.142,
"error": null,
"info": {}
},
{
"name": "POST /orders",
"attempt": 2,
"resumed": false,
"status": "passed",
"started_at": "2026-08-09T08:25:40.084300+00:00",
"ended_at": "2026-08-09T08:25:40.371400+00:00",
"duration_seconds": 0.287,
"error": null,
"info": {}
}
],
"api_requests": [
{ "endpoint": "/api/products", "method": "GET", "status_code": 200, "latency_ms": 142.3, "attempt": 1 },
{ "endpoint": "/api/orders", "method": "POST", "status_code": 201, "latency_ms": 287.1, "attempt": 1 },
{ "endpoint": "/api/products", "method": "GET", "status_code": 200, "latency_ms": 142.3, "attempt": 2 },
{ "endpoint": "/api/orders", "method": "POST", "status_code": 201, "latency_ms": 287.1, "attempt": 2 }
]
}
What the fields tell you:
retry_count: 1— the test needed one retry.authenticateat attempt 2 has"resumed": true,"status": "skipped"— it was guard-free skipped bysteplog.run.POST /ordersat attempt 1 shows"status": "failed"with the assertion message — exact failure point, no digging through logs.api_requestscontains every HTTP call across all attempts with its attempt number — full per-attempt audit trail.
JSON report
--steplog-json writes one report.json per test under .steplog/
(override with --steplog-json-dir). It contains run, steps, and one array
per registered record type.
License
MIT
Release files for pytest-resumable-stepmetrics 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pytest_resumable_stepmetrics-0.1.1.tar.gz | 15.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pytest_resumable_stepmetrics-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 31.9 kB
Release files / pytest_resumable_stepmetrics-0.1.1.tar.gz
| Download URL | pytest_resumable_stepmetrics-0.1.1.tar.gz |
|---|---|
| Size | 15.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
890240425ff4741bc13204d1a325da8a71996523b56bea3eb3de6c4d0aa81a31
|
|
BLAKE2b-256 checksum How to use checksums |
4c4634ede7cb7266268e8243f13cc1f1d5f37ec3be0b89ae28b1e0bfbae65f94
|
| 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 9, 2026.
Transparency logRelease files / pytest_resumable_stepmetrics-0.1.1-py3-none-any.whl
| Download URL | pytest_resumable_stepmetrics-0.1.1-py3-none-any.whl |
|---|---|
| Size | 16.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
46534cfb4b4cbe4c665309a3a9d8e79cf94016e3a788e78554bdedf65d2ce402
|
|
BLAKE2b-256 checksum How to use checksums |
a402640f3ebafa1405f45f55bca7300ae3be517e7aa648d2e6dcc0a6566131ae
|
| 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 9, 2026.
Transparency log