Skip to content
Closed
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
6 changes: 6 additions & 0 deletions qase-pytest/changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# 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`.

# qase-pytest 8.3.0

## What's new
Expand Down
2 changes: 1 addition & 1 deletion 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 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
7 changes: 7 additions & 0 deletions qase-python-commons/changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# qase-python-commons@5.1.2

## What's new

- Added `QaseTestOpsMulti.set_run_ids()` to seed run ids for several projects in one call, and routed `QaseCoreReporter.set_run_id()` to use it when invoked with a dict. Required for xdist workers in `testops_multi` mode, where the controller produces a `{project_code: run_id}` mapping that workers must replay locally.
- Masked the API token in the debug config dump. `QaseCoreReporter` now logs the config through `sanitize_config_for_log`, which keeps the first three and last four characters of `testops.api.token` and replaces the middle with `****`. Tokens of 7 chars or fewer are fully replaced. Mirrors the `token-masker` contract used in `qase-javascript-commons`.

# qase-python-commons@5.1.1

## What's new
Expand Down
2 changes: 1 addition & 1 deletion qase-python-commons/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-python-commons"
version = "5.1.1"
version = "5.1.2"
description = "A library for Qase TestOps and Qase Report"
readme = "README.md"
authors = [{name = "Qase Team", email = "support@qase.io"}]
Expand Down
12 changes: 9 additions & 3 deletions qase-python-commons/src/qase/commons/reporters/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import Union, List, Dict

from ..util import get_host_info
from ..util.token_masker import sanitize_config_for_log
from ..status_mapping.status_mapping import StatusMapping
from ..exceptions.reporter import ReporterException

Expand Down Expand Up @@ -68,7 +69,7 @@ def __init__(self, config: ConfigManager, framework: Union[str, None] = None,

self.fallback = self._fallback_setup()

self.logger.log_debug(f"Config: {self.config}")
self.logger.log_debug(f"Config: {sanitize_config_for_log(self.config)}")

host_data = get_host_info(framework, reporter_name)
self.logger.log_debug(f"Host data: {host_data}")
Expand Down Expand Up @@ -260,10 +261,15 @@ def disable_profilers(self) -> None:
for profiler in self.profilers:
profiler.disable()

def set_run_id(self, run_id: str) -> None:
def set_run_id(self, run_id) -> None:
if self.reporter:
try:
self.reporter.set_run_id(run_id)
# Multi-project mode: run_id is a dict of project -> run_id.
# Dispatch to set_run_ids so each project gets seeded.
if isinstance(run_id, dict) and hasattr(self.reporter, 'set_run_ids'):
self.reporter.set_run_ids(run_id)
else:
self.reporter.set_run_id(run_id)
except Exception as e:
# Log error and run fallback
self.logger.log('Failed to set run id', 'info')
Expand Down
12 changes: 12 additions & 0 deletions qase-python-commons/src/qase/commons/reporters/testops_multi.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,18 @@ def set_run_id(self, project_code: str, run_id: Union[str, int]) -> None:
else:
self.logger.log(f"Unknown project code: {project_code}", "warning")

def set_run_ids(self, run_ids: Dict[str, Union[str, int]]) -> None:
"""Set run_ids for multiple projects at once.

Used by xdist workers to seed run ids produced by the controller.
Unknown project codes are skipped with a warning but do not raise.
"""
for project_code, run_id in run_ids.items():
if project_code in self.project_configs:
self.project_runs[project_code] = int(run_id) if isinstance(run_id, str) else run_id
else:
self.logger.log(f"Unknown project code: {project_code}", "warning")

def start_run(self) -> Dict[str, int]:
"""
Create or verify test runs for all projects.
Expand Down
46 changes: 46 additions & 0 deletions qase-python-commons/src/qase/commons/util/token_masker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Token masking utilities for safe config logging.

Mirrors the qase-javascript-commons `token-masker.ts` contract so leaked
debug logs cannot expose customer API tokens. The only secret we currently
need to mask sits at ``config.testops.api.token``.
"""

import json
from typing import Any


def mask_token(token: str) -> str:
"""Return a masked representation of an API token.

Tokens of 7 chars or fewer are fully replaced with asterisks.
Longer tokens keep the first 3 and last 4 characters: ``abc****wxyz``.
"""
if not isinstance(token, str):
return token
if len(token) <= 7:
return "*" * len(token)
return f"{token[:3]}****{token[-4:]}"


def sanitize_config_for_log(config: Any) -> str:
"""Return a JSON string of ``config`` with the API token masked.

Accepts any object with a JSON-serialisable ``__str__`` (e.g. ``BaseModel``)
or a dict. Falls back to ``str(config)`` if the serialised form cannot be
parsed as JSON, so callers never lose log output to a masking bug.
"""
try:
raw = config if isinstance(config, str) else str(config)
parsed = json.loads(raw)
except (TypeError, ValueError):
return str(config)

token = (
parsed.get("testops", {}).get("api", {}).get("token")
if isinstance(parsed, dict)
else None
)
if token:
parsed["testops"]["api"]["token"] = mask_token(token)

return json.dumps(parsed, indent=4, sort_keys=True)
44 changes: 44 additions & 0 deletions qase-python-commons/tests/tests_qase_commons/test_core_reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,50 @@ def test_fallback_on_testops_multi_exception(
assert reporter.reporter is reporter.fallback


class TestCoreReporterSetRunId:
"""set_run_id dispatch between single and multi reporters."""

def _make_reporter_with_inner(self, inner):
"""Build a QaseCoreReporter bypassing __init__ to attach any inner reporter."""
reporter = QaseCoreReporter.__new__(QaseCoreReporter)
reporter.reporter = inner
reporter.fallback = None
reporter.logger = Mock()
reporter.overhead = 0.0
return reporter

def test_dict_run_id_routes_to_set_run_ids(self):
"""A dict run_id from xdist controller must be applied via set_run_ids."""
inner = Mock(spec=["set_run_ids", "set_run_id"])
reporter = self._make_reporter_with_inner(inner)

reporter.set_run_id({"PROJ1": 111, "PROJ2": 222})

inner.set_run_ids.assert_called_once_with({"PROJ1": 111, "PROJ2": 222})
inner.set_run_id.assert_not_called()

def test_scalar_run_id_routes_to_set_run_id(self):
"""A scalar run_id keeps the single-project code path."""
inner = Mock(spec=["set_run_id"])
reporter = self._make_reporter_with_inner(inner)

reporter.set_run_id("1553")

inner.set_run_id.assert_called_once_with("1553")

def test_dict_run_id_falls_back_when_inner_has_no_set_run_ids(self):
"""If inner reporter lacks set_run_ids, the call must not raise — caller never crashes."""
inner = Mock(spec=["set_run_id"]) # no set_run_ids attribute
inner.set_run_id.side_effect = TypeError("missing arg")
reporter = self._make_reporter_with_inner(inner)

# Should swallow the TypeError just like any other exception
reporter.set_run_id({"PROJ1": 111})

inner.set_run_id.assert_called_once()
reporter.logger.log.assert_any_call("Failed to set run id", "info")


class TestCoreReporterErrorHandling:
"""TEST-03: Error classification helpers and diagnostic logging."""

Expand Down
45 changes: 43 additions & 2 deletions qase-python-commons/tests/tests_qase_commons/test_testops_multi.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,16 +152,57 @@ def test_set_run_id_unknown_project(self):
mock_config = self._create_mock_config()
mock_logger = Mock()
mock_client = self._create_mock_client()

reporter = QaseTestOpsMulti(mock_config, mock_logger, mock_client)
reporter.set_run_id("UNKNOWN_PROJ", "123")

# Verify warning was logged
mock_logger.log.assert_called_once()
call_args = mock_logger.log.call_args
assert "Unknown project code" in call_args[0][0]
assert "warning" in call_args[0] or call_args[1].get("level") == "warning"

def test_set_run_ids_bulk(self):
"""set_run_ids stores int run ids for every known project at once."""
mock_config = self._create_mock_config()
mock_logger = Mock()
mock_client = self._create_mock_client()

reporter = QaseTestOpsMulti(mock_config, mock_logger, mock_client)
reporter.set_run_ids({"PROJ1": 111, "PROJ2": 222})

assert reporter.project_runs["PROJ1"] == 111
assert reporter.project_runs["PROJ2"] == 222
mock_logger.log.assert_not_called()

def test_set_run_ids_converts_strings(self):
"""set_run_ids coerces stringified ids to int."""
mock_config = self._create_mock_config()
mock_logger = Mock()
mock_client = self._create_mock_client()

reporter = QaseTestOpsMulti(mock_config, mock_logger, mock_client)
reporter.set_run_ids({"PROJ1": "333", "PROJ2": "444"})

assert reporter.project_runs["PROJ1"] == 333
assert reporter.project_runs["PROJ2"] == 444
assert isinstance(reporter.project_runs["PROJ1"], int)

def test_set_run_ids_warns_on_unknown_project(self):
"""set_run_ids warns about unknown codes but still applies known ones."""
mock_config = self._create_mock_config()
mock_logger = Mock()
mock_client = self._create_mock_client()

reporter = QaseTestOpsMulti(mock_config, mock_logger, mock_client)
reporter.set_run_ids({"PROJ1": 555, "UNKNOWN": 999})

assert reporter.project_runs["PROJ1"] == 555
assert "UNKNOWN" not in reporter.project_runs
warn_calls = [c for c in mock_logger.log.call_args_list
if "Unknown project code" in c[0][0]]
assert len(warn_calls) == 1

def test_add_result_with_project_mapping(self):
"""Test adding result with project mapping."""
mock_config = self._create_mock_config()
Expand Down
Loading
Loading