From 165d240688e9f5b6a901f2fda1a59d87b1976046 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Wed, 3 Jun 2026 19:35:10 +0300 Subject: [PATCH 1/3] fix: testops_multi run_id handoff for pytest-xdist workers In testops_multi mode, the xdist controller's reporter produces a {project_code: run_id} dict, but pytest_sessionstart wrote it via str(self.run_id) and load_run_from_lock read it back as a single positional string. Workers then crashed in QaseTestOpsMulti.set_run_id() with "missing 1 required positional argument: 'run_id'", silently fell through the QaseCoreReporter fallback path, and dropped every result into local JSON files instead of TestOps. Serialise the run id via json.dumps in the controller and parse it back with json.loads in load_run_from_lock; if the lock file is in the legacy non-JSON shape (a bare numeric string from a previous run), fall back to treating it as a scalar so single-project workers keep working. Pass the parsed value through to QaseCoreReporter.set_run_id(), which in commons 5.1.2 dispatches dict input to QaseTestOpsMulti.set_run_ids() so every project is seeded in each worker. --- qase-pytest/src/qase/pytest/plugin.py | 23 ++++-- .../tests/tests_qase_pytest/test_plugin.py | 70 +++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) 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.""" From 871afd6f879d1005005c59e1dda2666743c5a7ea Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Wed, 3 Jun 2026 19:35:13 +0300 Subject: [PATCH 2/3] chore: bump qase-pytest to 8.3.1 and pin qase-python-commons~=5.1.2 The xdist fix in plugin.py only works when paired with QaseTestOpsMulti.set_run_ids() and the dict-aware QaseCoreReporter.set_run_id() dispatch added in qase-python-commons 5.1.2. Pin the minimum so new installs of qase-pytest 8.3.1 never end up with the older commons release where workers would silently fall back to the local report. --- qase-pytest/changelog.md | 10 ++++++++++ qase-pytest/pyproject.toml | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) 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", From a8013c3d350d3aa47e4a71f263672b0b8fba561b Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Thu, 4 Jun 2026 09:50:41 +0300 Subject: [PATCH 3/3] ci: exempt first-party qase-* packages from supply-chain cutoff The 3-day PIP_UPLOADED_PRIOR_TO quarantine in the matrix test job blocked cross-package PRs that depended on a freshly-published qase-python-commons release: pytest pinning qase-python-commons~=5.1.2 could not be installed for up to 72 hours after the commons release landed on PyPI, even though the package is published from this same repository's pipeline. Prefetch qase-python-commons, qase-api-client and qase-api-v2-client into a find-links cache with PIP_UPLOADED_PRIOR_TO unset, then export PIP_FIND_LINKS so subsequent pip invocations - including those tox spawns inside its venv - resolve those packages from the local cache. PIP_UPLOADED_PRIOR_TO operates on PyPI metadata (upload_time_iso_8601), which local wheels do not carry, so the find-links candidates are honoured even when the index-side filter would have removed them. External dependencies remain subject to the cutoff so the supply-chain hardening for third-party packages is unchanged. --- .github/workflows/pythonpackage.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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: |