pytest-resumable-stepmetrics
When a test retries, you lose the story. Which step failed? Which HTTP calls were made on attempt 1 vs attempt 2? Which steps already passed and don't need to re-run? Your only clue is a wall of logs.
pytest-resumable-stepmetrics gives you structured step-level metadata inside
every test, with per-attempt tracking, resume-on-retry for idempotent
steps, and a JSON report you can query, store, or feed into a dashboard.
pip install pytest-resumable-stepmetrics
What you get
After a test with one retry, your terminal shows this automatically:
steplog summary:
====================================== = ======================================
Test: test_order_flow.py::test_create_order_retries_on_failure
Status: PASSED
Retries: 1
Steps:
+---+-------------------+---------+---------+-------------+--------+----------+------+
| # | Step | Attempt | Status | Duration(s) | Errors | Warnings | Info |
+===+===================+=========+=========+=============+========+==========+======+
| 1 | POST /auth/token | 1 | passed | 0.051 | 0 | 0 | 0 |
| 2 | GET /api/products | 1 | passed | 0.041 | 0 | 0 | 0 |
| 3 | POST /api/orders | 1 | failed | 0.081 | 0 | 0 | 0 |
| 4 | POST /auth/token | 2 | skipped | 0.000 | 0 | 0 | 0 |
| 5 | GET /api/products | 2 | passed | 0.041 | 0 | 0 | 0 |
| 6 | POST /api/orders | 2 | passed | 0.080 | 0 | 0 | 0 |
+---+-------------------+---------+---------+-------------+--------+----------+------+
api_requests:
+---------------+--------+-------------+------------+---------+
| Endpoint | Method | Status Code | Latency Ms | Attempt |
+===============+========+=============+============+=========+
| /auth/token | POST | 200 | 52.1 | 1 |
| /api/products | GET | 200 | 43.7 | 1 |
| /api/orders | POST | 500 | 287.4 | 1 |
| /api/products | GET | 200 | 43.7 | 2 |
| /api/orders | POST | 201 | 91.3 | 2 |
+---------------+--------+-------------+------------+---------+
Row 3: POST /api/orders failed on attempt 1 — exact failure point, no log digging.
Row 4: POST /auth/token was skipped on attempt 2 because it already passed — no wasted work.
The api_requests table shows every HTTP call across all attempts with its attempt number — a complete audit trail.
Quick start
def test_flow(steplog):
with steplog("fetch config"):
...
with steplog("call API"):
...
with steplog("validate response"):
...
pytest --steplog-json # also writes .steplog/report.json per test
End-to-end example
A fully runnable version with no extra dependencies lives in
examples/test_order_flow.py— clone and run it.
Domain model (custom record)
from dataclasses import dataclass
from pytest_resumable_stepmetrics import steplog_record
@steplog_record(key="api_requests", stamp=("attempt",))
@dataclass
class ApiRequest:
"""One HTTP call — auto-stamped with the current attempt number."""
endpoint: str
method: str
status_code: int
latency_ms: float
attempt: int = 1 # filled automatically
Registering with @steplog_record means:
api_requestsappears as its own array inreport.json- a terminal table is rendered automatically after each test
- the
attemptfield is stamped from the live context — no manual wiring
The test
def test_create_order(steplog):
api = OrdersAPI(base_url="https://api.example.com")
def run():
steplog.reset_attempt() # first line of every attempt
# Authentication is idempotent — skip it on retry, no guard needed
steplog.run("POST /auth/token", lambda: api.authenticate())
with steplog("GET /api/products"):
resp = api.get_products()
steplog.record(ApiRequest("/api/products", "GET", resp.status_code, resp.latency_ms))
assert resp.status_code == 200
with steplog("POST /api/orders"):
resp = api.create_order(product_id=resp.json()[0]["id"])
steplog.record(ApiRequest("/api/orders", "POST", resp.status_code, resp.latency_ms))
assert resp.status_code == 201, f"Order failed: {resp.json()}"
for attempt in range(2):
try:
run()
return
except AssertionError:
if attempt == 1:
raise
Sample report.json
{
"run": {
"test_nodeid": "test_order_flow.py::test_create_order",
"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": "POST /auth/token", "attempt": 1, "resumed": false, "status": "passed", "duration_seconds": 0.051, "error": null },
{ "name": "GET /api/products", "attempt": 1, "resumed": false, "status": "passed", "duration_seconds": 0.041, "error": null },
{ "name": "POST /api/orders", "attempt": 1, "resumed": false, "status": "failed", "duration_seconds": 0.081, "error": "Order failed: {'error': 'upstream timeout'}" },
{ "name": "POST /auth/token", "attempt": 2, "resumed": true, "status": "skipped", "duration_seconds": 0.0, "error": null },
{ "name": "GET /api/products", "attempt": 2, "resumed": false, "status": "passed", "duration_seconds": 0.041, "error": null },
{ "name": "POST /api/orders", "attempt": 2, "resumed": false, "status": "passed", "duration_seconds": 0.080, "error": null }
],
"api_requests": [
{ "endpoint": "/auth/token", "method": "POST", "status_code": 200, "latency_ms": 52.1, "attempt": 1 },
{ "endpoint": "/api/products", "method": "GET", "status_code": 200, "latency_ms": 43.7, "attempt": 1 },
{ "endpoint": "/api/orders", "method": "POST", "status_code": 500, "latency_ms": 287.4, "attempt": 1 },
{ "endpoint": "/api/products", "method": "GET", "status_code": 200, "latency_ms": 43.7, "attempt": 2 },
{ "endpoint": "/api/orders", "method": "POST", "status_code": 201, "latency_ms": 91.3, "attempt": 2 }
]
}
Retry & attempt tracking
Call steplog.reset_attempt() as the first line of each attempt.
run.retry_count and each step's attempt field are tracked automatically.
The Attempt column appears in the terminal table only when retries occur.
Works with any retry mechanism — tenacity, pytest-rerunfailures, a manual
loop, whatever you already use.
Resume-on-retry
Two forms — pick the one that fits your code style.
Context manager (with guard)
with steplog.resumable("download dataset") as step:
if not step.resumed: # guard required — with-blocks always run
download()
Callable (guard-free)
steplog.run("download dataset", download) # func is simply not called on retry
⚠️ Use resume only for pure / idempotent work (downloads, token fetch, name resolution). Stateful steps (writes, deployments, order creation) must re-run — use plain
steplog("name").
Custom records
steplog.record(obj) accepts any dataclass. Register it with @steplog_record
to control the JSON key, auto-stamped fields, and optional custom renderer:
@steplog_record(key="db_queries", stamp=("attempt",))
@dataclass
class DbQuery:
table: str
rows_affected: int
duration_ms: float
attempt: int = 1
An unregistered dataclass also works — it uses its snake_case class name as the key and auto-tabulates all its fields. No extra code required.
The steplog fixture API
| Call | What it does |
|---|---|
steplog("name") |
Track a step — context manager, always runs body. |
steplog.resumable("name") |
Track a resumable step — use if not step.resumed: guard. |
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 to the current attempt. |
steplog.reset_attempt() |
Advance the attempt counter — call first in each retry. |
steplog.context |
Mutable dict auto-stamped onto records (attempt, custom fields). |
steplog.collector |
The underlying StepLogCollector for advanced use. |
JSON report
pytest --steplog-json # writes .steplog/<test-id>/report.json
pytest --steplog-json-dir=reports/ # custom output directory
Each file contains run, steps, and one array per registered record type.
Ingest into Elasticsearch, a database, or a CI dashboard — the schema is stable.
License
MIT
Release files for pytest-resumable-stepmetrics 0.1.2
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.2.tar.gz | 16.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pytest_resumable_stepmetrics-0.1.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 33.6 kB
Release files / pytest_resumable_stepmetrics-0.1.2.tar.gz
| Download URL | pytest_resumable_stepmetrics-0.1.2.tar.gz |
|---|---|
| Size | 16.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
512370e397bc3bc642c525358062e5b7a3640145426951d91f8caba09714ef0a
|
|
BLAKE2b-256 checksum How to use checksums |
0dc791db532cf659e1cdac251fa3794b0e67c353588a94540661547b88d7c636
|
| 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.2-py3-none-any.whl
| Download URL | pytest_resumable_stepmetrics-0.1.2-py3-none-any.whl |
|---|---|
| Size | 16.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2b3e3fdb7e8c73d3d4585c7503be36cb85508070f2bbb709a7061f3a616eba3e
|
|
BLAKE2b-256 checksum How to use checksums |
e2321e4c96475bb8cd80cc5d66bb1c849dd8594aba685094c347128e74642734
|
| 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