Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/pythonpackage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
10 changes: 10 additions & 0 deletions qase-pytest/changelog.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions qase-pytest/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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",
Expand Down
23 changes: 17 additions & 6 deletions qase-pytest/src/qase/pytest/plugin.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import os
import re
from typing import Tuple, Union, List
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand Down
70 changes: 70 additions & 0 deletions qase-pytest/tests/tests_qase_pytest/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
singleton lifecycle.
"""

import json
import os

import pytest
from unittest.mock import MagicMock, PropertyMock, patch, call

Expand Down Expand Up @@ -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."""

Expand Down
Loading