diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 5a06a60c..093bdf14 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -48,6 +48,25 @@ jobs: run: | cutoff=$(date -u -d '3 days ago' +%Y-%m-%dT%H:%M:%SZ) echo "PIP_UPLOADED_PRIOR_TO=$cutoff" >> "$GITHUB_ENV" + - name: Prefetch first-party qase-* releases (cutoff-exempt) + if: steps.filter.outputs.changes == 'true' + # qase-* packages are released from this repo's own pipeline, so the + # 3-day quarantine that guards against external supply-chain attacks + # does not need to apply to them. Otherwise a cross-package PR that + # depends on a freshly-published qase-python-commons cannot pass CI + # until the cutoff window rolls past, blocking releases for 3 days. + # Download them with the cutoff disabled into a find-links cache; + # PIP_UPLOADED_PRIOR_TO does not filter wheels resolved via + # --find-links (the flag inspects PyPI metadata, which local wheels + # do not carry). Subsequent pip invocations - including those tox + # spawns inside its venv - pick up PIP_FIND_LINKS via the + # environment. + run: | + mkdir -p /tmp/qase-wheel-cache + env -u PIP_UPLOADED_PRIOR_TO python -m pip download \ + --no-deps --dest /tmp/qase-wheel-cache \ + qase-python-commons qase-api-client qase-api-v2-client + echo "PIP_FIND_LINKS=/tmp/qase-wheel-cache" >> "$GITHUB_ENV" - name: Install dependencies if: steps.filter.outputs.changes == 'true' run: | diff --git a/qase-pytest/changelog.md b/qase-pytest/changelog.md index 1e8bc1d5..0748449f 100644 --- a/qase-pytest/changelog.md +++ b/qase-pytest/changelog.md @@ -1,3 +1,13 @@ +# qase-pytest 8.3.1 + +## What's fixed + +- Fixed `testops_multi` mode under pytest-xdist. The controller now serialises the run id map as JSON in the lock file and workers reconstruct the dict, dispatching it through `QaseCoreReporter.set_run_id()` so every project's run id is seeded in each worker. Previously workers crashed with `QaseTestOpsMulti.set_run_id() missing 1 required positional argument: 'run_id'` and all results were dropped with `No run_id for project ..., skipping send`. + +## Dependencies + +- Bumped `qase-python-commons` minimum from `~=5.1.1` to `~=5.1.2` so new installs always pull the matching commons release that exposes `QaseTestOpsMulti.set_run_ids()` and the dict-aware `QaseCoreReporter.set_run_id()` dispatch this fix relies on. + # qase-pytest 8.3.0 ## What's new diff --git a/qase-pytest/pyproject.toml b/qase-pytest/pyproject.toml index fa2e9842..0ca53a98 100644 --- a/qase-pytest/pyproject.toml +++ b/qase-pytest/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "qase-pytest" -version = "8.3.0" +version = "8.3.1" description = "Qase Pytest Plugin for Qase TestOps and Qase Report" readme = "README.md" keywords = ["qase", "pytest", "plugin", "testops", "report", "qase reporting", "test observability"] @@ -28,7 +28,7 @@ classifiers = [ ] requires-python = ">=3.9" dependencies = [ - "qase-python-commons~=5.1.1", + "qase-python-commons~=5.1.2", "pytest>=7.4.4", "filelock>=3.12.2", "more_itertools", diff --git a/qase-pytest/src/qase/pytest/plugin.py b/qase-pytest/src/qase/pytest/plugin.py index c0c25d33..eb12e743 100644 --- a/qase-pytest/src/qase/pytest/plugin.py +++ b/qase-pytest/src/qase/pytest/plugin.py @@ -1,3 +1,4 @@ +import json import os import re from typing import Tuple, Union, List @@ -113,7 +114,9 @@ def pytest_sessionstart(self, session): with FileLock("qase.lock"): if self.run_id: with open(self.meta_run_file, "w") as lock_file: - lock_file.write(str(self.run_id)) + # JSON keeps the dict shape used by testops_multi mode + # intact across the xdist controller -> worker boundary. + lock_file.write(json.dumps(self.run_id)) else: self.load_run_from_lock() @@ -351,11 +354,19 @@ def add_param(self, name: str, value: str): def load_run_from_lock(self): if os.path.exists(QasePytestPlugin.meta_run_file): with open(QasePytestPlugin.meta_run_file, "r") as lock_file: - try: - self.run_id = str(lock_file.read()) - self.reporter.set_run_id(self.run_id) - except ValueError: - pass + raw = lock_file.read() + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = raw + if isinstance(parsed, dict): + self.run_id = parsed + else: + self.run_id = str(parsed) + try: + self.reporter.set_run_id(self.run_id) + except ValueError: + pass else: self.run_id = self.reporter.start_run() diff --git a/qase-pytest/tests/tests_qase_pytest/test_plugin.py b/qase-pytest/tests/tests_qase_pytest/test_plugin.py index a9ebe91c..9dd338cf 100644 --- a/qase-pytest/tests/tests_qase_pytest/test_plugin.py +++ b/qase-pytest/tests/tests_qase_pytest/test_plugin.py @@ -6,6 +6,9 @@ singleton lifecycle. """ +import json +import os + import pytest from unittest.mock import MagicMock, PropertyMock, patch, call @@ -345,6 +348,73 @@ def test_logs_not_attached_when_capture_logs_disabled(self): mock_attach.assert_not_called() +class TestXdistRunIdRoundtrip: + """xdist controller -> worker handoff of run_id via lock file. + + Regression for testops_multi + xdist where the worker crashed with + ``set_run_id() missing 1 required positional argument`` because the + controller wrote ``str(dict)`` into the lock file. + """ + + @pytest.fixture + def isolated_lock_file(self, tmp_path, monkeypatch): + """Redirect meta_run_file to a tmp path so tests don't touch repo CWD.""" + lock_path = tmp_path / "src.run" + monkeypatch.setattr(QasePytestPlugin, "meta_run_file", str(lock_path)) + monkeypatch.chdir(tmp_path) # FileLock writes qase.lock in cwd + return lock_path + + def test_controller_writes_dict_run_id_as_json(self, isolated_lock_file): + plugin = make_plugin() + plugin.reporter.start_run.return_value = {"DW": 1553, "PROJ2": 42} + + with patch("qase.pytest.plugin.is_xdist_controller", return_value=True): + plugin.pytest_sessionstart(session=MagicMock()) + + contents = isolated_lock_file.read_text() + assert json.loads(contents) == {"DW": 1553, "PROJ2": 42} + + def test_controller_writes_scalar_run_id_as_json(self, isolated_lock_file): + """Single-project mode still produces a parseable scalar lock file.""" + plugin = make_plugin() + plugin.reporter.start_run.return_value = 1553 + + with patch("qase.pytest.plugin.is_xdist_controller", return_value=True): + plugin.pytest_sessionstart(session=MagicMock()) + + assert json.loads(isolated_lock_file.read_text()) == 1553 + + def test_worker_loads_dict_run_id_and_seeds_reporter(self, isolated_lock_file): + """Worker reads dict lock file and forwards the dict to the reporter.""" + isolated_lock_file.write_text(json.dumps({"DW": 1553, "PROJ2": 42})) + plugin = make_plugin() + + plugin.load_run_from_lock() + + assert plugin.run_id == {"DW": 1553, "PROJ2": 42} + plugin.reporter.set_run_id.assert_called_once_with({"DW": 1553, "PROJ2": 42}) + + def test_worker_loads_scalar_run_id_as_string(self, isolated_lock_file): + """Single-project workers receive the run_id as a string (legacy contract).""" + isolated_lock_file.write_text(json.dumps(1553)) + plugin = make_plugin() + + plugin.load_run_from_lock() + + assert plugin.run_id == "1553" + plugin.reporter.set_run_id.assert_called_once_with("1553") + + def test_worker_tolerates_legacy_non_json_lock_file(self, isolated_lock_file): + """A pre-existing plain numeric lock file (legacy format) still loads.""" + isolated_lock_file.write_text("1553") + plugin = make_plugin() + + plugin.load_run_from_lock() + + assert plugin.run_id == "1553" + plugin.reporter.set_run_id.assert_called_once_with("1553") + + class TestSetTags: """Test _set_tags instance method."""