-
Notifications
You must be signed in to change notification settings - Fork 108
Add pytest-rerunfailures retry reporting support #433
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
base: develop
Are you sure you want to change the base?
Changes from all commits
08b9eb5
1581a2e
c5aed68
b24ac28
762b8af
346f95a
c5e12b5
3afe710
3185f49
ea57d48
a14cb21
8cfd82f
e3cd1a6
2f80610
4b54450
24ee0c5
8ee91fc
1c5e976
672581b
5590e46
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -198,6 +198,8 @@ def __init__(self, agent_config: AgentConfig) -> None: | |
| self._launch_id = None | ||
| self.agent_name = "pytest-reportportal" | ||
| self.agent_version = get_package_version(self.agent_name) or "None" | ||
| self._retry_tracker: dict[str, dict[str, Any]] = {} | ||
| self._active_leaves: dict[str, dict[str, Any]] = {} | ||
| self.ignored_attributes = [] | ||
| self.parent_item_id = None | ||
| self.rp = None | ||
|
|
@@ -457,12 +459,31 @@ def _merge_leaf_types(self, test_tree: dict[str, Any], leaf_types: set, separato | |
| self._merge_leaf_types(child_leaf, leaf_types, separator) | ||
|
|
||
| def _merge_dirs(self, test_tree: dict[str, Any]) -> None: | ||
| """Merge directory and file leaves using configured separator. | ||
|
|
||
| :param test_tree: Test tree structure to merge | ||
| """ | ||
| self._merge_leaf_types(test_tree, {LeafType.DIR, LeafType.FILE}, self._config.rp_dir_path_separator) | ||
|
|
||
| def _merge_code_with_separator(self, test_tree: dict[str, Any], separator: str) -> None: | ||
| self._merge_leaf_types(test_tree, {LeafType.CODE, LeafType.FILE, LeafType.DIR, LeafType.SUITE}, separator) | ||
| def _merge_code_with_separator(self, test_tree: dict[str, Any], separator: str, is_bdd: bool = False) -> None: | ||
| """Merge code and suite leaves, respecting hierarchy flags. | ||
|
|
||
| :param test_tree: Test tree structure to merge | ||
| :param separator: Separator to use when merging names | ||
|
ParthibanRajasekaran marked this conversation as resolved.
|
||
| :param is_bdd: If True, always merge FILE for BDD scenarios. Otherwise respect rp_hierarchy_test_file | ||
| """ | ||
| types_to_merge = {LeafType.CODE, LeafType.SUITE} | ||
| if is_bdd or not self._config.rp_hierarchy_test_file: | ||
| types_to_merge.add(LeafType.FILE) | ||
| if not self._config.rp_hierarchy_dirs: | ||
| types_to_merge.add(LeafType.DIR) | ||
| self._merge_leaf_types(test_tree, types_to_merge, separator) | ||
|
|
||
| def _merge_code(self, test_tree: dict[str, Any]) -> None: | ||
| """Merge code and suite leaves using double colon separator. | ||
|
|
||
| :param test_tree: Test tree structure to merge | ||
| """ | ||
| self._merge_code_with_separator(test_tree, "::") | ||
|
|
||
| def _build_item_paths(self, leaf: dict[str, Any], path: list[dict[str, Any]]) -> None: | ||
|
|
@@ -874,6 +895,8 @@ def _build_start_step_rq(self, leaf: dict[str, Any]) -> dict[str, Any]: | |
| "parameters": leaf.get("parameters", None), | ||
| "parent_item_id": self._lock(leaf["parent"], lambda p: p["item_id"]), | ||
| "test_case_id": leaf.get("test_case_id", None), | ||
| "retry": leaf.get("retry", False), | ||
| "retry_of": leaf.get("retry_of", None), | ||
| } | ||
| return payload | ||
|
|
||
|
|
@@ -906,6 +929,13 @@ def start_pytest_item(self, test_item: Optional[Item] = None): | |
| return | ||
|
|
||
| self._create_suite_path(test_item) | ||
|
|
||
| item_key = self._get_item_key(test_item) | ||
| if item_key in self._active_leaves: | ||
| current_leaf = self._active_leaves[item_key] | ||
| if current_leaf["item_id"] is not None: | ||
| return | ||
|
|
||
| current_leaf = self._tree_path[test_item][-1] | ||
| self._process_metadata_item_start(current_leaf) | ||
| item_id = self._start_step(self._build_start_step_rq(current_leaf)) | ||
|
|
@@ -925,7 +955,12 @@ def process_results(self, test_item: Item, report): | |
| if PYTEST_BDD and _is_pytest_bdd_scenario(test_item.location[0]): | ||
| return | ||
|
|
||
| leaf = self._tree_path[test_item][-1] | ||
| item_key = self._get_item_key(test_item) | ||
| if item_key in self._active_leaves: | ||
| leaf = self._active_leaves[item_key] | ||
| else: | ||
| leaf = self._tree_path[test_item][-1] | ||
|
|
||
| # Defining test result | ||
| if report.when == "setup": | ||
| leaf["status"] = "PASSED" | ||
|
|
@@ -951,6 +986,8 @@ def _build_finish_step_rq(self, leaf: dict[str, Any]) -> dict[str, Any]: | |
| "status": status, | ||
| "issue": issue, | ||
| "item_id": leaf["item_id"], | ||
| "retry": leaf.get("retry", False), | ||
| "retry_of": leaf.get("retry_of", None), | ||
| } | ||
| return payload | ||
|
|
||
|
|
@@ -992,6 +1029,72 @@ def _finish_parents(self, leaf: dict[str, Any]) -> None: | |
| self._lock(leaf["parent"], lambda p: self._proceed_suite_finish(p)) | ||
| self._finish_parents(leaf["parent"]) | ||
|
|
||
| def _get_item_key(self, test_item: Item) -> str: | ||
| """Get unique key for tracking an item across retries.""" | ||
| return str(id(test_item)) | ||
|
|
||
| def _detect_retry_attempt(self, test_item: Item) -> int: | ||
| """Detect current retry attempt number from execution_count.""" | ||
| return getattr(test_item, 'execution_count', 1) | ||
|
|
||
| def handle_retry_transition(self, test_item: Item, report) -> None: | ||
| """Detect and handle retry transitions when test is retried.""" | ||
| if report.when not in ("setup", "call"): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
file='pytest_reportportal/service.py'
printf '%s\n' '--- relevant symbols ---'
rg -n -C 8 'handle_retry_transition|active_leaves|retry|pytest_runtest|report.when|log|fixture' "$file"
printf '%s\n' '--- file outline ---'
ast-grep outline "$file"Repository: reportportal/agent-python-pytest Length of output: 20392 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- retry transition callers ---'
rg -n -C 12 'handle_retry_transition|report_fixture|post_log\(|pytest_runtest_makereport|pytest_runtest_setup|pytest_runtest_call|pytest_runtest_teardown' .
printf '%s\n' '--- candidate plugin files ---'
git ls-files | rg '(^|/)(plugin|pytest|service|hook|conftest)|reportportal' | head -120Repository: reportportal/agent-python-pytest Length of output: 35819 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- plugin logging and item lifecycle ---'
sed -n '180,305p' pytest_reportportal/plugin.py
printf '%s\n' '--- logging implementation ---'
sed -n '1,260p' pytest_reportportal/rp_logging.py
printf '%s\n' '--- retry tests around routing ---'
sed -n '1,340p' tests/unit/test_retry_support.pyRepository: reportportal/agent-python-pytest Length of output: 19977 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- RPLogHandler binding ---'
sed -n '1,120p' pytest_reportportal/plugin.py
rg -n -C 12 'class RPLogHandler|RPLogHandler|post_log|reportportal_client' pytest_reportportal testsRepository: reportportal/agent-python-pytest Length of output: 37240 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- dependency declarations ---'
rg -n -C 3 'reportportal-client|reportportal_client' pyproject.toml setup.py setup.cfg requirements* poetry.lock uv.lock Pipfile* tox.ini .github 2>/dev/null || true
printf '%s\n' '--- local dependency source, if present ---'
python3 - <<'PY'
import importlib.util
spec = importlib.util.find_spec("reportportal_client")
print(spec.origin if spec else "reportportal_client unavailable")
PYRepository: reportportal/agent-python-pytest Length of output: 813 🌐 Web query:
💡 Result: <search_synthesis> <source_evidence> Citations:
🌐 Web query:
💡 Result: <search_synthesis> <source_evidence> Citations: Activate the retry leaf before retry setup starts. 🤖 Prompt for AI Agents |
||
| return | ||
|
|
||
| current_execution = self._detect_retry_attempt(test_item) | ||
| item_key = self._get_item_key(test_item) | ||
|
ParthibanRajasekaran marked this conversation as resolved.
ParthibanRajasekaran marked this conversation as resolved.
|
||
|
|
||
| if item_key not in self._retry_tracker: | ||
| self._retry_tracker[item_key] = { | ||
| "last_reported_execution_count": 0, | ||
| "attempts": [] | ||
| } | ||
|
|
||
| tracker = self._retry_tracker[item_key] | ||
|
|
||
| if current_execution > tracker["last_reported_execution_count"]: | ||
| if tracker["last_reported_execution_count"] == 0 and current_execution == 1: | ||
| tree_path_leaf = self._tree_path[test_item][-1] | ||
| self._active_leaves[item_key] = tree_path_leaf | ||
| tracker["attempts"].append({ | ||
| "execution_count": 1, | ||
| "item_id": tree_path_leaf.get("item_id") | ||
| }) | ||
| tracker["last_reported_execution_count"] = 1 | ||
| elif current_execution > 1: | ||
| if item_key in self._active_leaves: | ||
| prev_leaf = self._active_leaves[item_key] | ||
| self._process_metadata_item_finish(prev_leaf) | ||
| self._finish_step(self._build_finish_step_rq(prev_leaf)) | ||
| prev_leaf["exec"] = ExecStatus.FINISHED | ||
|
|
||
| tree_path_leaf = self._tree_path[test_item][-1] | ||
| retry_leaf = { | ||
| **tree_path_leaf, | ||
| "item_id": None, | ||
| "exec": ExecStatus.CREATED, | ||
| "retry": True, | ||
| "retry_of": tracker["attempts"][-1]["item_id"] if tracker["attempts"] else None, | ||
| } | ||
|
|
||
| self._active_leaves[item_key] = retry_leaf | ||
| self._process_metadata_item_start(retry_leaf) | ||
| item_id = self._start_step(self._build_start_step_rq(retry_leaf)) | ||
| retry_leaf["item_id"] = item_id | ||
| retry_leaf["exec"] = ExecStatus.IN_PROGRESS | ||
|
|
||
| tracker["attempts"].append({ | ||
| "execution_count": current_execution, | ||
| "item_id": item_id | ||
| }) | ||
| tracker["last_reported_execution_count"] = current_execution | ||
|
|
||
| def cleanup_retry_state(self) -> None: | ||
| """Clean up retry tracking state after session ends.""" | ||
| self._retry_tracker.clear() | ||
| self._active_leaves.clear() | ||
|
|
||
| @check_rp_enabled | ||
| def finish_pytest_item(self, test_item: Optional[Item] = None) -> None: | ||
| """Finish pytest_item. | ||
|
|
@@ -1002,7 +1105,12 @@ def finish_pytest_item(self, test_item: Optional[Item] = None) -> None: | |
| if test_item is None: | ||
| return | ||
|
|
||
| leaf = self._tree_path[test_item][-1] | ||
| item_key = self._get_item_key(test_item) | ||
| if item_key in self._active_leaves: | ||
| leaf = self._active_leaves[item_key] | ||
| else: | ||
| leaf = self._tree_path[test_item][-1] | ||
|
|
||
| self._process_metadata_item_finish(leaf) | ||
|
|
||
| if PYTEST_BDD and _is_pytest_bdd_scenario(test_item.location[0]): | ||
|
|
@@ -1011,7 +1119,13 @@ def finish_pytest_item(self, test_item: Optional[Item] = None) -> None: | |
|
|
||
| self._finish_step(self._build_finish_step_rq(leaf)) | ||
| leaf["exec"] = ExecStatus.FINISHED | ||
| self._finish_parents(leaf) | ||
|
|
||
| current_execution = self._detect_retry_attempt(test_item) | ||
| tracker = self._retry_tracker.get(item_key, {}) | ||
| last_reported = tracker.get("last_reported_execution_count", 0) | ||
|
|
||
| if current_execution == last_reported or current_execution == 1: | ||
| self._finish_parents(leaf) | ||
|
|
||
| def _get_items(self, exec_status) -> list[Item]: | ||
| return [k for k, v in self._tree_path.items() if v[-1]["exec"] == exec_status] | ||
|
|
@@ -1084,7 +1198,11 @@ def post_log( | |
| LOGGER.warning( | ||
| "Incorrect loglevel = %s. Force set to INFO. " "Available levels: %s.", log_level, KNOWN_LOG_LEVELS | ||
| ) | ||
| item_id = self._tree_path[test_item][-1]["item_id"] | ||
| item_key = self._get_item_key(test_item) | ||
| if item_key in self._active_leaves: | ||
| item_id = self._active_leaves[item_key]["item_id"] | ||
| else: | ||
| item_id = self._tree_path[test_item][-1]["item_id"] | ||
| if PYTEST_BDD: | ||
| if not item_id: | ||
| # Check if we are actually a BDD scenario | ||
|
|
@@ -1185,7 +1303,7 @@ def start_bdd_scenario(self, feature: Feature, scenario: Scenario) -> None: | |
| self._generate_names(root_leaf) | ||
| if not self._config.rp_hierarchy_code: | ||
| try: | ||
| self._merge_code_with_separator(root_leaf, " - ") | ||
| self._merge_code_with_separator(root_leaf, " - ", is_bdd=True) | ||
| except Exception as e: | ||
| LOGGER.exception(e) | ||
| self._build_item_paths(root_leaf, []) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| """Integration tests for pytest-rerunfailures support.""" | ||
|
|
||
| import pytest | ||
|
|
||
|
|
||
| @pytest.mark.flaky(reruns=2) | ||
| def test_eventual_pass_with_retries(): | ||
| """Test that passes after retries are properly reported.""" | ||
| if not hasattr(test_eventual_pass_with_retries, 'attempts'): | ||
| test_eventual_pass_with_retries.attempts = 0 | ||
| test_eventual_pass_with_retries.attempts += 1 | ||
|
|
||
| # Passes on third attempt | ||
| assert test_eventual_pass_with_retries.attempts >= 3 | ||
|
|
||
|
|
||
| def test_without_retries(): | ||
| """Test that passes without retries.""" | ||
| assert True | ||
|
|
||
|
|
||
| @pytest.mark.flaky(reruns=1) | ||
| def test_passes_on_second_attempt(): | ||
| """Test that passes on second attempt.""" | ||
| if not hasattr(test_passes_on_second_attempt, 'count'): | ||
| test_passes_on_second_attempt.count = 0 | ||
| test_passes_on_second_attempt.count += 1 | ||
|
|
||
| # Fails once, passes on second attempt | ||
| assert test_passes_on_second_attempt.count >= 2 |
Uh oh!
There was an error while loading. Please reload this page.