Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow custom metadata for replay file #82

Merged
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ UNRELEASED

* Add support for Python 3.13.
* Dropped support for EOL Python 3.8.
* Allow cutomization of metadata in replay file (`#78`_).

.. _`#78`: https://github.com/ESSS/pytest-replay/issues/78


1.5.3
Expand Down
32 changes: 32 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,38 @@ execute the tests in the same order with::

Hopefully this will make it easier to reproduce the problem and fix it.

Additional metadata
-------------------

In cases where it is necessary to add new metadata to the replay file to make the test reproducible, `pytest-replay`
provides a fixture called ``replay_metadata`` that allows new information to be added using the ``metadata``
attribute.

Example:

.. code-block:: python

import pytest
import numpy as np
import random

@pytest.fixture
def rng(replay_metadata):
seed = replay_metadata.metadata.setdefault("seed", random.randint(0, 100))
return np.random.default_rng(seed=seed)

def test_random(rng):
data = rng.standard_normal((100, 100))
assert data.shape == (100, 100)


When using it with pytest-replay it generates a replay file similar to

.. code-block:: json

{"nodeid": "test_bar.py::test_random", "start": 0.000}
{"nodeid": "test_bar.py::test_random", "start": 0.000, "finish": 1.5, "outcome": "passed", "metadata": {"seed": 12}}


FAQ
~~~
Expand Down
58 changes: 42 additions & 16 deletions src/pytest_replay/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import collections
import dataclasses
import json
import os
import time
from dataclasses import asdict
from glob import glob
from typing import Any
from typing import Optional

import pytest

Expand Down Expand Up @@ -39,6 +44,24 @@ def pytest_addoption(parser):
)


@dataclasses.dataclass
class ReplayTestMetadata:
nodeid: str
start: float = 0.0
finish: Optional[float] = None
outcome: Optional[str] = None
metadata: dict[str, Any] = dataclasses.field(default_factory=dict)

def to_clean_dict(self) -> dict[str, Any]:
return {k: v for k, v in asdict(self).items() if v}


class _ReplayTestMetadataDefaultDict(collections.defaultdict):
def __missing__(self, key):
self[key] = ReplayTestMetadata(nodeid=key)
return self[key]


class ReplayPlugin:
def __init__(self, config):
self.dir = config.getoption("replay_record_dir")
Expand All @@ -53,10 +76,13 @@ def __init__(self, config):
skip_cleanup = config.getoption("skip_cleanup", False)
if not skip_cleanup:
self.cleanup_scripts()
self.node_start_time = {}
self.node_outcome = {}
self.nodes = _ReplayTestMetadataDefaultDict()
self.session_start_time = config.replay_start_time

@pytest.fixture(scope="function")
def replay_metadata(self, request):
return self.nodes[request.node.nodeid]

def cleanup_scripts(self):
if self.xdist_worker_name:
# only cleanup scripts on the master node
Expand All @@ -79,31 +105,28 @@ def pytest_runtest_logstart(self, nodeid):
# only workers report running tests when running in xdist
return
if self.dir:
self.node_start_time[nodeid] = time.perf_counter() - self.session_start_time
json_content = json.dumps(
{"nodeid": nodeid, "start": self.node_start_time[nodeid]}
)
self.nodes[nodeid].start = time.perf_counter() - self.session_start_time
json_content = json.dumps(self.nodes[nodeid].to_clean_dict())
self.append_test_to_script(nodeid, json_content)

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(self, item):
report = yield
result = report.get_result()
if self.dir:
current = self.node_outcome.setdefault(item.nodeid, result.outcome)
self.nodes[item.nodeid].outcome = (
self.nodes[item.nodeid].outcome or result.outcome
)
current = self.nodes[item.nodeid].outcome
if not result.passed and current != "failed":
# do not overwrite a failed outcome with a skipped one
self.node_outcome[item.nodeid] = result.outcome
self.nodes[item.nodeid].outcome = result.outcome

if result.when == "teardown":
json_content = json.dumps(
{
"nodeid": item.nodeid,
"start": self.node_start_time[item.nodeid],
"finish": time.perf_counter() - self.session_start_time,
"outcome": self.node_outcome.pop(item.nodeid),
}
self.nodes[item.nodeid].finish = (
time.perf_counter() - self.session_start_time
)
json_content = json.dumps(self.nodes[item.nodeid].to_clean_dict())
self.append_test_to_script(item.nodeid, json_content)

def pytest_collection_modifyitems(self, items, config):
Expand All @@ -119,7 +142,10 @@ def pytest_collection_modifyitems(self, items, config):
stripped = line.strip()
# Ignore blank linkes and comments. (#70)
if stripped and not stripped.startswith(("#", "//")):
nodeid = json.loads(stripped)["nodeid"]
node_metadata = json.loads(stripped)
nodeid = node_metadata["nodeid"]
if "finish" in node_metadata:
self.nodes[nodeid] = ReplayTestMetadata(**node_metadata)
Copy link
Member

@prusse-martin prusse-martin Feb 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that if somehow the test crashes before the finish you metadata would no be save, but it could be relevant to the crash reproduction.

You possible want to emit to the replay file "update metadata" instructions so they could be loaded back to reproduce the problem.
Something in the lines of:

{"nodeid": "test_1.py::test_bar", "start": 3.0}
{"nodeid": "test_1.py::test_bar", "start": 3.0, "metadata": {"a": "asd"}}
{"nodeid": "test_1.py::test_bar", "start": 3.0, "metadata": {"a": "asd", "b": 7}}
{"nodeid": "test_1.py::test_bar", "start": 3.0, "metadata": {"a": "asd", "b": 7}, "finish": 4.0, "outcome": "passed"}

There we see the metadata evolving.

And probably could help pinpoint the crash source if the output is just:

{"nodeid": "test_1.py::test_bar", "start": 3.0}
{"nodeid": "test_1.py::test_bar", "start": 3.0, "metadata": {"a": "asd"}}

(a crash cause some lines to not be present)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback! You're absolutely right, and this is something I already noted in the issue under "Limitations". In my specific scenario, it's quite rare for tests to crash, they usually just fail, so the current approach works well enough for my use case. However, I do see the value in capturing incremental metadata.
To fully address this broader scenario, we'd likely need to track all setups and all teardowns associated with a test, ensuring the metadata reflects each setup and teardown, since a crash could happen at any point.

For now, I’d prefer to move forward with the current implementation, but I completely agree that your suggestion would be the ideal enhancement. It’s definitely something worth considering for future improvements! Maybe, could you create an issue about it so we can tackle that in the future or when someone sees values in it please?

nodeids[nodeid] = None

items_dict = {item.nodeid: item for item in items}
Expand Down
62 changes: 62 additions & 0 deletions tests/test_replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,68 @@ def test_filter_out_tests_not_in_file(testdir):
)


def test_replay_extra_metafunc(pytester, tmp_path):
pytester.makepyfile(
"""
import pytest
import random

@pytest.fixture
def extra_metafunc(replay_metadata):
assert replay_metadata.metadata == {}
rand_int = random.randint(0, 100)
replay_metadata.metadata["seed"] = rand_int
return rand_int

@pytest.mark.parametrize('i', range(10))
def test_abc(extra_metafunc, i):
assert i % 2 == 0
"""
)
dir = tmp_path / "replay"
result = pytester.runpytest(f"--replay-record-dir={dir}", "-n 2")
assert result.ret != 0

contents = [
json.loads(s)
for replay_file in (".pytest-replay-gw0.txt", ".pytest-replay-gw1.txt")
for s in (dir / replay_file).read_text().splitlines()
]
contents.sort(key=lambda x: x["nodeid"])
assert contents[1]["metadata"]["seed"] < 100
assert (
len(
{
val["metadata"]["seed"]
for val in contents
if val.get("metadata", {}).get("seed")
}
)
> 1
)


def test_replay_extra_metadata_load(pytester, tmp_path):
pytester.makepyfile(
"""
import pytest

def test_load(replay_metadata):
assert replay_metadata.metadata == {"seed": 1234}
"""
)
pytester.maketxtfile(
"""{\"nodeid\": \"test_replay_extra_metadata_load.py::test_load\", \"start\": 1.0}
{\"nodeid\": \"test_replay_extra_metadata_load.py::test_load\", \"start\": 1.0, \"finish\": 2.0, \"outcome\": \"passed\", \"metadata\": {\"seed\": 1234}}
"""
)
result = pytester.runpytest(
f"--replay={pytester.path / 'test_replay_extra_metadata_load.txt'}"
)
assert result.ret == 0
result.assert_outcomes(passed=1)


def test_replay_file_outcome_is_correct(testdir):
"""Tests that the outcomes in the replay file are correct."""
testdir.makepyfile(
Expand Down