From 0fd39ab632e965038c0614eabfbef20548a13abb Mon Sep 17 00:00:00 2001 From: "reportportal.io" Date: Tue, 11 Nov 2025 15:14:33 +0000 Subject: [PATCH 01/12] Changelog update --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8ab780..b83a0bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## [Unreleased] + +## [5.5.4] ### Added - Return back deprecated `rp_log_batch_payload_size` parameter for sake of backward compatibility, by @HardNorth From 049cef9808b8a096d8e4241c69d3f940a5f17d10 Mon Sep 17 00:00:00 2001 From: "reportportal.io" Date: Tue, 11 Nov 2025 15:14:34 +0000 Subject: [PATCH 02/12] Version update --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 943cab7..a89ab47 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ from setuptools import setup -__version__ = "5.5.4" +__version__ = "5.5.5" def read_file(fname): From a20de7bda986833b6fd31b9ffe01500dfc7dd93a Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Wed, 12 Nov 2025 12:24:01 +0300 Subject: [PATCH 03/12] README.md update --- README.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d4d7928..274488f 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,20 @@ py.test -c config.cfg The `pytest.ini` file should have next mandatory fields: -- `rp_api_key` - value could be found in the User Profile section - `rp_project` - name of project in ReportPortal - `rp_endpoint` - address of ReportPortal Server +And one type of authorization: API Key or OAuth 2.0 Password grant. You can do this by setting: +- `rp_api_key` or `RP_API_KEY` environment variable. You can get it in the User Profile section on the UI. + +Or: +- `rp_oauth_uri` - OAuth 2.0 token endpoint URL for password grant authentication. **Required** if API key is not used. +- `rp_oauth_username` - OAuth 2.0 username for password grant authentication. **Required** if OAuth 2.0 is used. +- `rp_oauth_password` - OAuth 2.0 password for password grant authentication. **Required** if OAuth 2.0 is used. +- `rp_oauth_client_id` - OAuth 2.0 client identifier. **Required** if OAuth 2.0 is used. +- `rp_oauth_client_secret` - OAuth 2.0 client secret. **Optional** for OAuth 2.0 authentication. +- `rp_oauth_scope` - OAuth 2.0 access token scope. **Optional** for OAuth 2.0 authentication. + Example of `pytest.ini`: ```text @@ -51,8 +61,6 @@ rp_launch_description = 'Smoke test' rp_ignore_attributes = 'xfail' 'usefixture' ``` -- The `rp_api_key` can also be set with the environment variable `RP_API_KEY`. This will override the value set for `rp_api_key` in pytest.ini - There are also optional parameters: https://reportportal.io/docs/log-data-in-reportportal/test-framework-integration/Python/pytest/ From c59a1aa49744d2fa768251cb36bf96659572be73 Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Tue, 2 Dec 2025 11:35:20 +0300 Subject: [PATCH 04/12] Minor version bump, Python 3.8 support remove, deprecated code remove --- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 5 +++++ pyproject.toml | 6 ++++++ pytest_reportportal/config.py | 21 ++------------------- pytest_reportportal/plugin.py | 1 - requirements-dev.txt | 1 + requirements.txt | 2 +- setup.py | 4 ++-- tox.ini | 4 ++-- 9 files changed, 20 insertions(+), 26 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 779affa..be901e6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13' ] + python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13', '3.14' ] steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index b83a0bc..1e4a833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ## [Unreleased] +### Added +- Official `Python 3.14` support, by @HardNorth +### Removed +- `Python 3.8` support, by @HardNorth +- Deprecated `retries` parameter, by @HardNorth ## [5.5.4] ### Added diff --git a/pyproject.toml b/pyproject.toml index b44120a..f1cc956 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,3 +15,9 @@ skip_gitignore = true [tool.black] line-length = 119 target-version = ["py310"] + +[tool.pytest.ini_options] +minversion = "6.0" +required_plugins = "pytest-cov" +testpaths = ["tests"] +asyncio_default_fixture_loop_scope = "session" diff --git a/pytest_reportportal/config.py b/pytest_reportportal/config.py index 2d7a630..4bdde74 100644 --- a/pytest_reportportal/config.py +++ b/pytest_reportportal/config.py @@ -138,25 +138,8 @@ def __init__(self, pytest_config: Config) -> None: self.rp_project = self.find_option(pytest_config, "rp_project") self.rp_rerun_of = self.find_option(pytest_config, "rp_rerun_of") - rp_api_retries_str = self.find_option(pytest_config, "rp_api_retries") - rp_api_retries = rp_api_retries_str and int(rp_api_retries_str) - if rp_api_retries and rp_api_retries > 0: - self.rp_api_retries = rp_api_retries - else: - rp_api_retries_str = self.find_option(pytest_config, "retries") - rp_api_retries = rp_api_retries_str and int(rp_api_retries_str) - if rp_api_retries and rp_api_retries > 0: - self.rp_api_retries = rp_api_retries - warnings.warn( - "Parameter `retries` is deprecated since 5.1.9 " - "and will be subject for removing in the next " - "major version. Use `rp_api_retries` argument " - "instead.", - DeprecationWarning, - 2, - ) - else: - self.rp_api_retries = 0 + rp_api_retries_str = self.find_option(pytest_config, "rp_api_retries", "0") + self.rp_api_retries = rp_api_retries_str and int(rp_api_retries_str) # API key auth parameter self.rp_api_key = getenv("RP_API_KEY") or self.find_option(pytest_config, "rp_api_key") diff --git a/pytest_reportportal/plugin.py b/pytest_reportportal/plugin.py index 14a46a9..427ca91 100644 --- a/pytest_reportportal/plugin.py +++ b/pytest_reportportal/plugin.py @@ -646,7 +646,6 @@ def add_shared_option(name, help_str, default=None, action="store"): "directory with certificates of trusted CAs.", ) parser.addini("rp_issue_id_marks", type="bool", default=True, help="Add tag with issue id to the test") - parser.addini("retries", default="0", help="Deprecated: use `rp_api_retries` instead") parser.addini("rp_api_retries", default="0", help="Amount of retries for performing REST calls to RP server") parser.addini( "rp_launch_timeout", diff --git a/requirements-dev.txt b/requirements-dev.txt index 1ce1013..a6ccdfb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,3 +3,4 @@ pytest-cov pytest-parallel black isort +mypy diff --git a/requirements.txt b/requirements.txt index cf5fbf4..21bc458 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ dill>=0.3.6 pytest>=4.6.10 -reportportal-client~=5.6.7 +reportportal-client~=5.7.0 aenum>=3.1.0 diff --git a/setup.py b/setup.py index a89ab47..febcf4a 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ from setuptools import setup -__version__ = "5.5.5" +__version__ = "5.6.0" def read_file(fname): @@ -46,12 +46,12 @@ def read_file(fname): keywords=["testing", "reporting", "reportportal", "pytest", "agent"], classifiers=[ "Framework :: Pytest", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ], entry_points={ "pytest11": [ diff --git a/tox.ini b/tox.ini index 43fd5f3..92c1825 100644 --- a/tox.ini +++ b/tox.ini @@ -3,12 +3,12 @@ isolated_build = True envlist = pep nobdd - py38 py39 py310 py311 py312 py313 + py314 [testenv] deps = @@ -38,9 +38,9 @@ commands = pre-commit run --all-files --show-diff-on-failure [gh-actions] python = - 3.8: py38 3.9: py39 3.10: pep, nobdd, py310 3.11: py311 3.12: py312 3.13: py313 + 3.14: py314 From cb2ecf4f5df8b08584da1a14b86b3be8c78ff8a5 Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Tue, 2 Dec 2025 11:44:54 +0300 Subject: [PATCH 05/12] Fix tests --- pyproject.toml | 1 - tests/integration/test_config_handling.py | 18 ------------------ tests/unit/test_plugin.py | 1 - 3 files changed, 20 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f1cc956..2af8ea6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,4 +20,3 @@ target-version = ["py310"] minversion = "6.0" required_plugins = "pytest-cov" testpaths = ["tests"] -asyncio_default_fixture_loop_scope = "session" diff --git a/tests/integration/test_config_handling.py b/tests/integration/test_config_handling.py index a5bfaf1..0c121e7 100644 --- a/tests/integration/test_config_handling.py +++ b/tests/integration/test_config_handling.py @@ -189,24 +189,6 @@ def test_rp_api_retries(mock_client_init): assert_expectations() -@mock.patch(REPORT_PORTAL_SERVICE) -def test_retries(mock_client_init): - retries = 5 - variables = utils.DEFAULT_VARIABLES.copy() - variables.update({"retries": str(retries)}.items()) - - with warnings.catch_warnings(record=True) as w: - result = utils.run_pytest_tests(["examples/test_rp_logging.py"], variables=variables) - assert int(result) == 0, "Exit code should be 0 (no errors)" - - expect(mock_client_init.call_count == 1) - - constructor_args = mock_client_init.call_args_list[0][1] - expect(constructor_args["retries"] == retries) - expect(len(filter_agent_calls(w)) == 1) - assert_expectations() - - @mock.patch(REPORT_PORTAL_SERVICE) def test_rp_issue_system_url_warning(mock_client_init): url = "https://bugzilla.some.com/show_bug.cgi?id={issue_id}" diff --git a/tests/unit/test_plugin.py b/tests/unit/test_plugin.py index 41d7ac0..d1ab7b8 100644 --- a/tests/unit/test_plugin.py +++ b/tests/unit/test_plugin.py @@ -272,7 +272,6 @@ def test_pytest_addoption_adds_correct_ini_file_arguments(): "rp_bts_url", "rp_verify_ssl", "rp_issue_id_marks", - "retries", "rp_api_retries", "rp_launch_timeout", "rp_client_type", From 0887b2c9ae2d0153d4fcb7a29c298bbe7f49ca56 Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Tue, 2 Dec 2025 12:53:40 +0300 Subject: [PATCH 06/12] .gitignore update --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index eb64fa1..e142e84 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,6 @@ dmypy.json # Pyre type checker .pyre/ + +AGENTS.md +PROMPTS.md From 7c8496a1dae27c0554ea80fec5edcf0dfc435371 Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Tue, 2 Dec 2025 13:23:17 +0300 Subject: [PATCH 07/12] Implements issue #396 --- CHANGELOG.md | 1 + .../test_case_id_decorator_params_mark.py | 23 +++++ pytest_reportportal/service.py | 85 +++++++++++-------- tests/integration/test_case_id_report.py | 5 ++ 4 files changed, 79 insertions(+), 35 deletions(-) create mode 100644 examples/test_case_id/test_case_id_decorator_params_mark.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e4a833..859d2a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added - Official `Python 3.14` support, by @HardNorth +- Issue [#396](https://github.com/reportportal/agent-python-pytest/issues/396) parametrize marker IDs, by @HardNorth ### Removed - `Python 3.8` support, by @HardNorth - Deprecated `retries` parameter, by @HardNorth diff --git a/examples/test_case_id/test_case_id_decorator_params_mark.py b/examples/test_case_id/test_case_id_decorator_params_mark.py new file mode 100644 index 0000000..275528e --- /dev/null +++ b/examples/test_case_id/test_case_id_decorator_params_mark.py @@ -0,0 +1,23 @@ +"""A simple example test with Test Case ID decorator and parameters.""" + +# Copyright (c) 2022 https://reportportal.io . +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License + +import pytest + +TEST_CASE_ID = "ISSUE-231" + + +@pytest.mark.parametrize(("param1", "param2"), [("value1", "value2")], ids=[TEST_CASE_ID]) +def test_case_id_decorator(param1, param2): + assert True diff --git a/pytest_reportportal/service.py b/pytest_reportportal/service.py index 710c591..4bb0591 100644 --- a/pytest_reportportal/service.py +++ b/pytest_reportportal/service.py @@ -602,47 +602,32 @@ def _get_code_ref(self, item: Item) -> str: class_path = ".".join(classes) return "{0}:{1}".format(path, class_path) - def _get_test_case_id(self, mark, leaf: Dict[str, Any]) -> str: - parameters: Optional[Dict[str, Any]] = leaf.get("parameters", None) - parameters_indices: Optional[Dict[str, Any]] = leaf.get("parameters_indices") or {} - parameterized = True - selected_params: Optional[List[str]] = None - use_index = False - if mark is not None: - parameterized = mark.kwargs.get("parameterized", False) - selected_params: Optional[Union[str, List[str]]] = mark.kwargs.get("params", None) - use_index = mark.kwargs.get("use_index", False) - if selected_params is not None and not isinstance(selected_params, list): - selected_params = [selected_params] - + def _get_test_case_id( + self, + base_name: str, + parameterized: bool, + include_params: Optional[List[str]], + use_index: bool, + parameters: Optional[Dict[str, Any]], + parameters_indices: Optional[Dict[str, Any]], + ) -> str: param_str = None if parameterized and parameters is not None and len(parameters) > 0: - if selected_params is not None and len(selected_params) > 0: + if include_params is not None and len(include_params) > 0: if use_index: - param_list = [str((param, parameters_indices.get(param, None))) for param in selected_params] + param_list = [str((param, parameters_indices.get(param, None))) for param in include_params] else: - param_list = [str(parameters.get(param, None)) for param in selected_params] + param_list = [str(parameters.get(param, None)) for param in include_params] elif use_index: param_list = [str(param) for param in parameters_indices.items()] else: param_list = [str(param) for param in parameters.values()] - param_str = "[{}]".format(",".join(sorted(param_list))) + param_str = f"[{','.join(sorted(param_list))}]" - basic_name_part = leaf["code_ref"] - if mark is None: - if param_str is None: - return basic_name_part - else: - return basic_name_part + param_str + if param_str is None: + return base_name else: - if mark.args is not None and len(mark.args) > 0: - basic_name_part = str(mark.args[0]) - else: - basic_name_part = "" - if param_str is None: - return basic_name_part - else: - return basic_name_part + param_str + return base_name + param_str def _get_issue_ids(self, mark): issue_ids = mark.kwargs.get("issue_id", []) @@ -762,10 +747,40 @@ def _process_test_case_id(self, leaf: Dict[str, Any]) -> str: :param leaf: item context :return: Test Case ID string """ - tc_ids = [m for m in leaf["item"].iter_markers() if m.name == "tc_id"] - if len(tc_ids) > 0: - return self._get_test_case_id(tc_ids[0], leaf) - return self._get_test_case_id(None, leaf) + item = leaf["item"] + base_name = leaf["code_ref"] + parameterized = True + include_params = None + use_index = False + parameters: Optional[Dict[str, Any]] = leaf.get("parameters", None) + parameters_indices: Optional[Dict[str, Any]] = leaf.get("parameters_indices") or {} + + parametrize_markers = [m for m in item.iter_markers() if m.name == "parametrize"] + if parametrize_markers: + mark = parametrize_markers[0] + mark_kwargs = getattr(mark, "kwargs", None) + if mark_kwargs and "ids" in mark_kwargs and mark_kwargs["ids"]: + base_name = item.callspec.id + parameterized = False + + tc_ids = [m for m in item.iter_markers() if m.name == "tc_id"] + if tc_ids: + mark = tc_ids[0] + parameterized = mark.kwargs.get("parameterized", False) + include_params: Optional[Union[str, List[str]]] = mark.kwargs.get("params", None) + use_index = mark.kwargs.get("use_index", False) + + if include_params is not None and not isinstance(include_params, list): + include_params = [include_params] + + if mark.args is not None and len(mark.args) > 0: + base_name = str(mark.args[0]) + else: + base_name = "" + + return self._get_test_case_id( + base_name, parameterized, include_params, use_index, parameters, parameters_indices + ) def _process_issue(self, item: Item) -> Optional[Issue]: """ diff --git a/tests/integration/test_case_id_report.py b/tests/integration/test_case_id_report.py index b1ddffd..eb94cb8 100644 --- a/tests/integration/test_case_id_report.py +++ b/tests/integration/test_case_id_report.py @@ -20,6 +20,7 @@ from examples.test_case_id import ( test_case_id_decorator, test_case_id_decorator_params_false, + test_case_id_decorator_params_mark, test_case_id_decorator_params_no, test_case_id_decorator_params_partially, test_case_id_decorator_params_true, @@ -55,6 +56,10 @@ ("examples/test_case_id/test_case_id_decorator_no_id_params_false.py", ""), ("examples/test_case_id/test_case_id_decorator_no_id_params_true.py", "[value1,value2]"), ("examples/test_case_id/test_case_id_decorator_no_id_partial_params_true.py", "[value2]"), + ( + "examples/test_case_id/test_case_id_decorator_params_mark.py", + test_case_id_decorator_params_mark.TEST_CASE_ID, + ), ], ) def test_parameters(mock_client_init, test, expected_id): From f828c91050cfb4fb2bca6808885856c9b1f97a90 Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Tue, 2 Dec 2025 13:46:06 +0300 Subject: [PATCH 08/12] Update types --- pytest_reportportal/config.py | 8 +- pytest_reportportal/plugin.py | 6 +- pytest_reportportal/rp_logging.py | 4 +- pytest_reportportal/service.py | 124 +++++++++++++++--------------- 4 files changed, 71 insertions(+), 71 deletions(-) diff --git a/pytest_reportportal/config.py b/pytest_reportportal/config.py index 4bdde74..e2692b5 100644 --- a/pytest_reportportal/config.py +++ b/pytest_reportportal/config.py @@ -15,7 +15,7 @@ import warnings from os import getenv -from typing import Any, List, Optional, Tuple, Union +from typing import Any, Optional, Union from _pytest.config import Config from reportportal_client import ClientType, OutputType @@ -50,8 +50,8 @@ class AgentConfig: rp_bts_url: str rp_launch: str rp_launch_id: Optional[str] - rp_launch_attributes: Optional[List[str]] - rp_tests_attributes: Optional[List[str]] + rp_launch_attributes: Optional[list[str]] + rp_tests_attributes: Optional[list[str]] rp_launch_description: str rp_log_batch_size: int rp_log_batch_payload_limit: int @@ -78,7 +78,7 @@ class AgentConfig: rp_launch_timeout: int rp_launch_uuid_print: bool rp_launch_uuid_print_output: Optional[OutputType] - rp_http_timeout: Optional[Union[Tuple[float, float], float]] + rp_http_timeout: Optional[Union[tuple[float, float], float]] rp_report_fixtures: bool def __init__(self, pytest_config: Config) -> None: diff --git a/pytest_reportportal/plugin.py b/pytest_reportportal/plugin.py index 427ca91..cabf1ce 100644 --- a/pytest_reportportal/plugin.py +++ b/pytest_reportportal/plugin.py @@ -17,7 +17,7 @@ import os.path import time from logging import Logger -from typing import Any, Callable, Dict, Generator +from typing import Any, Callable, Generator import _pytest.logging import dill as pickle @@ -410,7 +410,7 @@ def pytest_bdd_after_step( scenario: Scenario, step: Step, step_func: Callable[..., Any], - step_func_args: Dict[str, Any], + step_func_args: dict[str, Any], ) -> Generator[None, Any, None]: """Report BDD step finish. @@ -439,7 +439,7 @@ def pytest_bdd_step_error( scenario: Scenario, step: Step, step_func: Callable[..., Any], - step_func_args: Dict[str, Any], + step_func_args: dict[str, Any], exception, ) -> Generator[None, Any, None]: """Report BDD step error. diff --git a/pytest_reportportal/rp_logging.py b/pytest_reportportal/rp_logging.py index a53ce81..d6ce9a0 100644 --- a/pytest_reportportal/rp_logging.py +++ b/pytest_reportportal/rp_logging.py @@ -18,7 +18,7 @@ import threading from contextlib import contextmanager from functools import wraps -from typing import Any, Dict, List +from typing import Any from reportportal_client import RPLogger, current, set_current from reportportal_client.core.worker import APIWorker @@ -114,7 +114,7 @@ def patching_logger_class(): def wrap_log(original_func): @wraps(original_func) - def _log(self, *args: List[Any], **kwargs: Dict[str, Any]): + def _log(self, *args: list[Any], **kwargs: dict[str, Any]): my_kwargs = kwargs.copy() attachment = my_kwargs.pop("attachment", None) if attachment is not None: diff --git a/pytest_reportportal/service.py b/pytest_reportportal/service.py index 4bb0591..f6b3883 100644 --- a/pytest_reportportal/service.py +++ b/pytest_reportportal/service.py @@ -23,7 +23,7 @@ from functools import wraps from os import curdir from time import sleep, time -from typing import Any, Callable, Dict, Generator, List, Optional, Set, Union +from typing import Any, Callable, Generator, Optional, Union from _pytest.doctest import DoctestItem from aenum import Enum, auto, unique @@ -83,7 +83,7 @@ MAX_ITEM_NAME_LENGTH: int = 1024 TRUNCATION_STR: str = "..." ROOT_DIR: str = str(os.path.abspath(curdir)) -PYTEST_MARKS_IGNORE: Set[str] = {"parametrize", "usefixtures", "filterwarnings"} +PYTEST_MARKS_IGNORE: set[str] = {"parametrize", "usefixtures", "filterwarnings"} NOT_ISSUE: Issue = Issue("NOT_ISSUE") ISSUE_DESCRIPTION_LINE_TEMPLATE: str = "* {}:{}" ISSUE_DESCRIPTION_URL_TEMPLATE: str = " [{issue_id}]({url})" @@ -163,20 +163,20 @@ class PyTestService: """Pytest service class for reporting test results to the Report Portal.""" _config: AgentConfig - _issue_types: Dict[str, str] - _tree_path: Dict[Any, List[Dict[str, Any]]] - _bdd_tree: Optional[Dict[str, Any]] - _bdd_item_by_name: Dict[str, Item] - _bdd_scenario_by_item: Dict[Item, Scenario] - _bdd_item_by_scenario: Dict[Scenario, Item] - _start_tracker: Set[str] + _issue_types: dict[str, str] + _tree_path: dict[Any, list[dict[str, Any]]] + _bdd_tree: Optional[dict[str, Any]] + _bdd_item_by_name: dict[str, Item] + _bdd_scenario_by_item: dict[Item, Scenario] + _bdd_item_by_scenario: dict[Scenario, Item] + _start_tracker: set[str] _launch_id: Optional[str] agent_name: str agent_version: str - ignored_attributes: List[str] + ignored_attributes: list[str] parent_item_id: Optional[str] rp: Optional[RP] - project_settings: Union[Dict[str, Any], Task] + project_settings: Union[dict[str, Any], Task] def __init__(self, agent_config: AgentConfig) -> None: """Initialize instance attributes.""" @@ -197,7 +197,7 @@ def __init__(self, agent_config: AgentConfig) -> None: self.project_settings = {} @property - def issue_types(self) -> Dict[str, str]: + def issue_types(self) -> dict[str, str]: """Issue types for the Report Portal project.""" if self._issue_types: return self._issue_types @@ -213,7 +213,7 @@ def issue_types(self) -> Dict[str, str]: self._issue_types[item["shortName"]] = item["locator"] return self._issue_types - def _get_launch_attributes(self, ini_attrs: Optional[List[Dict[str, str]]]) -> List[Dict[str, str]]: + def _get_launch_attributes(self, ini_attrs: Optional[list[dict[str, str]]]) -> list[dict[str, str]]: """Generate launch attributes in the format supported by the client. :param list ini_attrs: List for attributes from the pytest.ini file @@ -223,7 +223,7 @@ def _get_launch_attributes(self, ini_attrs: Optional[List[Dict[str, str]]]) -> L system_attributes["agent"] = "{}|{}".format(self.agent_name, self.agent_version) return attributes + dict_to_payload(system_attributes) - def _build_start_launch_rq(self) -> Dict[str, Any]: + def _build_start_launch_rq(self) -> dict[str, Any]: rp_launch_attributes = self._config.rp_launch_attributes attributes = gen_attributes(rp_launch_attributes) if rp_launch_attributes else None @@ -250,7 +250,7 @@ def start_launch(self) -> Optional[str]: LOGGER.debug("ReportPortal - Launch started: id=%s", self._launch_id) return self._launch_id - def _get_item_dirs(self, item: Item) -> List[local]: + def _get_item_dirs(self, item: Item) -> list[local]: """ Get directory of item. @@ -262,7 +262,7 @@ def _get_item_dirs(self, item: Item) -> List[local]: rel_dir = dir_path.new(dirname=dir_path.relto(root_path), basename="", drive="") return [d for d in rel_dir.parts(reverse=False) if d.basename] - def _get_tree_path(self, item: Item) -> List[Item]: + def _get_tree_path(self, item: Item) -> list[Item]: """Get item of parents. :param item: pytest.Item @@ -279,8 +279,8 @@ def _get_tree_path(self, item: Item) -> List[Item]: return path def _create_leaf( - self, leaf_type, parent_item: Optional[Dict[str, Any]], item: Optional[Any], item_id: Optional[str] = None - ) -> Dict[str, Any]: + self, leaf_type, parent_item: Optional[dict[str, Any]], item: Optional[Any], item_id: Optional[str] = None + ) -> dict[str, Any]: """Construct a leaf for the itest tree. :param leaf_type: the leaf type @@ -298,7 +298,7 @@ def _create_leaf( "item_id": item_id, } - def _build_test_tree(self, session: Session) -> Dict[str, Any]: + def _build_test_tree(self, session: Session) -> dict[str, Any]: """Construct a tree of tests and their suites. :param session: pytest.Session object of the current execution @@ -325,7 +325,7 @@ def _build_test_tree(self, session: Session) -> Dict[str, Any]: current_leaf = children_leafs[leaf] return test_tree - def _remove_root_dirs(self, test_tree: Dict[str, Any], max_dir_level: int, dir_level: int = 0) -> None: + def _remove_root_dirs(self, test_tree: dict[str, Any], max_dir_level: int, dir_level: int = 0) -> None: if test_tree["type"] == LeafType.ROOT: items = list(test_tree["children"].items()) for item, child_leaf in items: @@ -341,7 +341,7 @@ def _remove_root_dirs(self, test_tree: Dict[str, Any], max_dir_level: int, dir_l child_leaf["parent"] = parent_leaf self._remove_root_dirs(child_leaf, max_dir_level, new_level) - def _remove_file_names(self, test_tree: Dict[str, Any]) -> None: + def _remove_file_names(self, test_tree: dict[str, Any]) -> None: if test_tree["type"] != LeafType.FILE: items = list(test_tree["children"].items()) for item, child_leaf in items: @@ -367,7 +367,7 @@ def _get_scenario_template(self, scenario: Scenario) -> Optional[ScenarioTemplat if scenario_template and isinstance(scenario_template, ScenarioTemplate): return scenario_template - def _generate_names(self, test_tree: Dict[str, Any]) -> None: + def _generate_names(self, test_tree: dict[str, Any]) -> None: if test_tree["type"] == LeafType.ROOT: test_tree["name"] = "root" @@ -404,7 +404,7 @@ def _generate_names(self, test_tree: Dict[str, Any]) -> None: for item, child_leaf in test_tree["children"].items(): self._generate_names(child_leaf) - def _merge_leaf_types(self, test_tree: Dict[str, Any], leaf_types: Set, separator: str) -> None: + def _merge_leaf_types(self, test_tree: dict[str, Any], leaf_types: set, separator: str) -> None: child_items = list(test_tree["children"].items()) if test_tree["type"] not in leaf_types: for item, child_leaf in child_items: @@ -423,16 +423,16 @@ def _merge_leaf_types(self, test_tree: Dict[str, Any], leaf_types: Set, separato child_leaf["name"] = current_name + separator + child_leaf["name"] self._merge_leaf_types(child_leaf, leaf_types, separator) - def _merge_dirs(self, test_tree: Dict[str, Any]) -> None: + def _merge_dirs(self, test_tree: dict[str, Any]) -> None: 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: + 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(self, test_tree: Dict[str, Any]) -> None: + def _merge_code(self, test_tree: dict[str, Any]) -> None: self._merge_code_with_separator(test_tree, "::") - def _build_item_paths(self, leaf: Dict[str, Any], path: List[Dict[str, Any]]) -> None: + def _build_item_paths(self, leaf: dict[str, Any], path: list[dict[str, Any]]) -> None: children = leaf.get("children", {}) if PYTEST_BDD: all_background_steps = all([isinstance(child, Background) for child in children.keys()]) @@ -496,7 +496,7 @@ def _get_item_description(self, test_item: Any) -> Optional[str]: if description: return description.lstrip() # There is a bug in pytest-bdd that adds an extra space - def _lock(self, leaf: Dict[str, Any], func: Callable[[Dict[str, Any]], Any]) -> Any: + def _lock(self, leaf: dict[str, Any], func: Callable[[dict[str, Any]], Any]) -> Any: """ Lock test tree leaf and execute a function, bypass the leaf to it. @@ -509,7 +509,7 @@ def _lock(self, leaf: Dict[str, Any], func: Callable[[Dict[str, Any]], Any]) -> return func(leaf) return func(leaf) - def _process_bdd_attributes(self, item: Union[Feature, Scenario, Rule]) -> List[Dict[str, str]]: + def _process_bdd_attributes(self, item: Union[Feature, Scenario, Rule]) -> list[dict[str, str]]: tags = [] tags.extend(item.tags) if isinstance(item, Scenario): @@ -526,7 +526,7 @@ def _process_bdd_attributes(self, item: Union[Feature, Scenario, Rule]) -> List[ tags.extend(getattr(example, "tags", [])) return gen_attributes(tags) - def _get_suite_code_ref(self, leaf: Dict[str, Any]) -> str: + def _get_suite_code_ref(self, leaf: dict[str, Any]) -> str: item = leaf["item"] if leaf["type"] == LeafType.DIR: code_ref = str(item) @@ -541,7 +541,7 @@ def _get_suite_code_ref(self, leaf: Dict[str, Any]) -> str: code_ref = str(item.fspath) return code_ref - def _build_start_suite_rq(self, leaf: Dict[str, Any]) -> Dict[str, Any]: + def _build_start_suite_rq(self, leaf: dict[str, Any]) -> dict[str, Any]: code_ref = self._get_suite_code_ref(leaf) parent_item_id = self._lock(leaf["parent"], lambda p: p.get("item_id")) if "parent" in leaf else None item = leaf["item"] @@ -557,11 +557,11 @@ def _build_start_suite_rq(self, leaf: Dict[str, Any]) -> Dict[str, Any]: payload["attributes"] = self._process_bdd_attributes(item) return payload - def _start_suite(self, suite_rq: Dict[str, Any]) -> Optional[str]: + def _start_suite(self, suite_rq: dict[str, Any]) -> Optional[str]: LOGGER.debug("ReportPortal - Start Suite: request_body=%s", suite_rq) return self.rp.start_test_item(**suite_rq) - def _create_suite(self, leaf: Dict[str, Any]) -> None: + def _create_suite(self, leaf: dict[str, Any]) -> None: if leaf["exec"] != ExecStatus.CREATED: return item_id = self._start_suite(self._build_start_suite_rq(leaf)) @@ -606,10 +606,10 @@ def _get_test_case_id( self, base_name: str, parameterized: bool, - include_params: Optional[List[str]], + include_params: Optional[list[str]], use_index: bool, - parameters: Optional[Dict[str, Any]], - parameters_indices: Optional[Dict[str, Any]], + parameters: Optional[dict[str, Any]], + parameters_indices: Optional[dict[str, Any]], ) -> str: param_str = None if parameterized and parameters is not None and len(parameters) > 0: @@ -699,7 +699,7 @@ def _to_attribute(self, attribute_tuple): else: return {"value": attribute_tuple[1]} - def _process_item_name(self, leaf: Dict[str, Any]) -> str: + def _process_item_name(self, leaf: dict[str, Any]) -> str: """ Process Item Name if set. @@ -715,7 +715,7 @@ def _process_item_name(self, leaf: Dict[str, Any]) -> str: name = mark_name return name - def _get_parameters(self, item) -> Optional[Dict[str, Any]]: + def _get_parameters(self, item) -> Optional[dict[str, Any]]: """ Get params of item. @@ -727,7 +727,7 @@ def _get_parameters(self, item) -> Optional[Dict[str, Any]]: return None return {str(k): v.replace("\0", "\\0") if isinstance(v, str) else v for k, v in params.items()} - def _get_parameters_indices(self, item) -> Optional[Dict[str, Any]]: + def _get_parameters_indices(self, item) -> Optional[dict[str, Any]]: """ Get params indices of item. @@ -740,7 +740,7 @@ def _get_parameters_indices(self, item) -> Optional[Dict[str, Any]]: return indices - def _process_test_case_id(self, leaf: Dict[str, Any]) -> str: + def _process_test_case_id(self, leaf: dict[str, Any]) -> str: """ Process Test Case ID if set. @@ -752,8 +752,8 @@ def _process_test_case_id(self, leaf: Dict[str, Any]) -> str: parameterized = True include_params = None use_index = False - parameters: Optional[Dict[str, Any]] = leaf.get("parameters", None) - parameters_indices: Optional[Dict[str, Any]] = leaf.get("parameters_indices") or {} + parameters: Optional[dict[str, Any]] = leaf.get("parameters", None) + parameters_indices: Optional[dict[str, Any]] = leaf.get("parameters_indices") or {} parametrize_markers = [m for m in item.iter_markers() if m.name == "parametrize"] if parametrize_markers: @@ -767,7 +767,7 @@ def _process_test_case_id(self, leaf: Dict[str, Any]) -> str: if tc_ids: mark = tc_ids[0] parameterized = mark.kwargs.get("parameterized", False) - include_params: Optional[Union[str, List[str]]] = mark.kwargs.get("params", None) + include_params: Optional[Union[str, list[str]]] = mark.kwargs.get("params", None) use_index = mark.kwargs.get("use_index", False) if include_params is not None and not isinstance(include_params, list): @@ -793,7 +793,7 @@ def _process_issue(self, item: Item) -> Optional[Issue]: if len(issues) > 0: return self._get_issue(issues[0]) - def _process_attributes(self, item: Item) -> List[Dict[str, Any]]: + def _process_attributes(self, item: Item) -> list[dict[str, Any]]: """ Process attributes of item. @@ -824,7 +824,7 @@ def _process_attributes(self, item: Item) -> List[Dict[str, Any]]: return [self._to_attribute(attribute) for attribute in attributes] - def _process_metadata_item_start(self, leaf: Dict[str, Any]) -> None: + def _process_metadata_item_start(self, leaf: dict[str, Any]) -> None: """ Process all types of item metadata for its start event. @@ -840,7 +840,7 @@ def _process_metadata_item_start(self, leaf: Dict[str, Any]) -> None: leaf["issue"] = self._process_issue(item) leaf["attributes"] = self._process_attributes(item) - def _process_metadata_item_finish(self, leaf: Dict[str, Any]) -> None: + def _process_metadata_item_finish(self, leaf: dict[str, Any]) -> None: """ Process all types of item metadata for its finish event. @@ -850,7 +850,7 @@ def _process_metadata_item_finish(self, leaf: Dict[str, Any]) -> None: leaf["attributes"] = self._process_attributes(item) leaf["issue"] = self._process_issue(item) - def _build_start_step_rq(self, leaf: Dict[str, Any]) -> Dict[str, Any]: + def _build_start_step_rq(self, leaf: dict[str, Any]) -> dict[str, Any]: payload = { "attributes": leaf.get("attributes", None), "name": self._truncate_item_name(leaf["name"]), @@ -864,7 +864,7 @@ def _build_start_step_rq(self, leaf: Dict[str, Any]) -> Dict[str, Any]: } return payload - def _start_step(self, step_rq: Dict[str, Any]) -> Optional[str]: + def _start_step(self, step_rq: dict[str, Any]) -> Optional[str]: LOGGER.debug("ReportPortal - Start TestItem: request_body=%s", step_rq) return self.rp.start_test_item(**step_rq) @@ -925,7 +925,7 @@ def process_results(self, test_item: Item, report): if leaf["status"] in (None, "PASSED"): leaf["status"] = "SKIPPED" - def _build_finish_step_rq(self, leaf: Dict[str, Any]) -> Dict[str, Any]: + def _build_finish_step_rq(self, leaf: dict[str, Any]) -> dict[str, Any]: issue = leaf.get("issue", None) status = leaf.get("status", "PASSED") if status == "SKIPPED" and not self._config.rp_is_skipped_an_issue: @@ -941,15 +941,15 @@ def _build_finish_step_rq(self, leaf: Dict[str, Any]) -> Dict[str, Any]: } return payload - def _finish_step(self, finish_rq: Dict[str, Any]) -> None: + def _finish_step(self, finish_rq: dict[str, Any]) -> None: LOGGER.debug("ReportPortal - Finish TestItem: request_body=%s", finish_rq) self.rp.finish_test_item(**finish_rq) - def _finish_suite(self, finish_rq: Dict[str, Any]) -> None: + def _finish_suite(self, finish_rq: dict[str, Any]) -> None: LOGGER.debug("ReportPortal - End TestSuite: request_body=%s", finish_rq) self.rp.finish_test_item(**finish_rq) - def _build_finish_suite_rq(self, leaf) -> Dict[str, Any]: + def _build_finish_suite_rq(self, leaf) -> dict[str, Any]: payload = {"end_time": timestamp(), "item_id": leaf["item_id"]} return payload @@ -960,7 +960,7 @@ def _proceed_suite_finish(self, leaf) -> None: self._finish_suite(self._build_finish_suite_rq(leaf)) leaf["exec"] = ExecStatus.FINISHED - def _finish_parents(self, leaf: Dict[str, Any]) -> None: + def _finish_parents(self, leaf: dict[str, Any]) -> None: if ( "parent" not in leaf or leaf["parent"] is None @@ -1000,7 +1000,7 @@ def finish_pytest_item(self, test_item: Optional[Item] = None) -> None: leaf["exec"] = ExecStatus.FINISHED self._finish_parents(leaf) - def _get_items(self, exec_status) -> List[Item]: + def _get_items(self, exec_status) -> list[Item]: return [k for k, v in self._tree_path.items() if v[-1]["exec"] == exec_status] def finish_suites(self) -> None: @@ -1026,7 +1026,7 @@ def finish_suites(self) -> None: if leaf["exec"] == ExecStatus.IN_PROGRESS: self._lock(leaf, lambda p: self._proceed_suite_finish(p)) - def _build_finish_launch_rq(self) -> Dict[str, Any]: + def _build_finish_launch_rq(self) -> dict[str, Any]: finish_rq = {"end_time": timestamp()} return finish_rq @@ -1042,7 +1042,7 @@ def finish_launch(self) -> None: def _build_log( self, item_id: str, message: str, log_level: str, attachment: Optional[Any] = None - ) -> Dict[str, Any]: + ) -> dict[str, Any]: sl_rq = { "item_id": item_id, "time": timestamp(), @@ -1142,7 +1142,7 @@ def start_bdd_scenario(self, feature: Feature, scenario: Scenario) -> None: if not root_leaf: self._bdd_tree = root_leaf = self._create_leaf(LeafType.ROOT, None, None, item_id=self.parent_item_id) # noinspection PyTypeChecker - children_leafs: Dict[Any, Any] = root_leaf["children"] + children_leafs: dict[Any, Any] = root_leaf["children"] if feature in children_leafs: feature_leaf = children_leafs[feature] else: @@ -1193,7 +1193,7 @@ def finish_bdd_scenario(self, feature: Feature, scenario: Scenario) -> None: leaf["exec"] = ExecStatus.FINISHED self._finish_parents(leaf) - def _get_scenario_parameters_from_template(self, scenario: Scenario) -> Optional[Dict[str, str]]: + def _get_scenario_parameters_from_template(self, scenario: Scenario) -> Optional[dict[str, str]]: """Get scenario parameters from its template by comparing steps. :param scenario: The scenario instance @@ -1228,9 +1228,9 @@ def _get_scenario_code_ref(self, scenario: Scenario, scenario_template: Optional return code_ref - def _get_scenario_test_case_id(self, leaf: Dict[str, Any]) -> str: + def _get_scenario_test_case_id(self, leaf: dict[str, Any]) -> str: attributes = leaf.get("attributes", []) - params: Optional[Dict[str, str]] = leaf.get("parameters", None) + params: Optional[dict[str, str]] = leaf.get("parameters", None) for attribute in attributes: if attribute.get("key", None) == "tc_id": tc_id = attribute["value"] @@ -1241,7 +1241,7 @@ def _get_scenario_test_case_id(self, leaf: Dict[str, Any]) -> str: return f"{tc_id}{params_str}" return leaf["code_ref"] - def _process_scenario_metadata(self, leaf: Dict[str, Any]) -> None: + def _process_scenario_metadata(self, leaf: dict[str, Any]) -> None: """ Process all types of scenario metadata for its start event. @@ -1266,7 +1266,7 @@ def _process_scenario_metadata(self, leaf: Dict[str, Any]) -> None: leaf["attributes"] = self._process_bdd_attributes(scenario) leaf["test_case_id"] = self._get_scenario_test_case_id(leaf) - def _finish_bdd_step(self, leaf: Dict[str, Any], status: str) -> None: + def _finish_bdd_step(self, leaf: dict[str, Any], status: str) -> None: if leaf["exec"] != ExecStatus.IN_PROGRESS: return From 84dff8f1f4f4dc73a167ddb5a45b55cddbd064fa Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Tue, 2 Dec 2025 14:34:23 +0300 Subject: [PATCH 09/12] Add custom log level handling --- examples/test_rp_custom_logging.py | 25 ++++++++++++ pytest_reportportal/config.py | 13 ++++++ pytest_reportportal/plugin.py | 7 ++++ pytest_reportportal/service.py | 2 +- tests/integration/__init__.py | 50 +++++++++++++++++++++++ tests/integration/test_bdd.py | 48 +--------------------- tests/integration/test_config_handling.py | 18 ++++++++ tests/unit/test_plugin.py | 1 + 8 files changed, 116 insertions(+), 48 deletions(-) create mode 100644 examples/test_rp_custom_logging.py diff --git a/examples/test_rp_custom_logging.py b/examples/test_rp_custom_logging.py new file mode 100644 index 0000000..cfc018c --- /dev/null +++ b/examples/test_rp_custom_logging.py @@ -0,0 +1,25 @@ +# Copyright (c) 2022 https://reportportal.io . +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License + +import logging + +logging.basicConfig(level=logging.INFO) + +logger = logging.getLogger(__name__) + +LOG_LEVEL: int = 35 +LOG_MESSAGE: str = "Assertion error" + + +def test_report_portal_logging(): + logger.log(LOG_LEVEL, LOG_MESSAGE) diff --git a/pytest_reportportal/config.py b/pytest_reportportal/config.py index e2692b5..9e48473 100644 --- a/pytest_reportportal/config.py +++ b/pytest_reportportal/config.py @@ -81,6 +81,9 @@ class AgentConfig: rp_http_timeout: Optional[Union[tuple[float, float], float]] rp_report_fixtures: bool + # Custom log levels and overrides + rp_log_custom_levels: Optional[dict[int, str]] + def __init__(self, pytest_config: Config) -> None: """Initialize required attributes.""" self.rp_enabled = to_bool(getattr(pytest_config.option, "rp_enabled", True)) @@ -177,6 +180,16 @@ def __init__(self, pytest_config: Config) -> None: self.rp_http_timeout = connect_timeout or read_timeout self.rp_report_fixtures = to_bool(self.find_option(pytest_config, "rp_report_fixtures", False)) + # Custom log levels and overrides + log_custom_levels = self.find_option(pytest_config, "rp_log_custom_levels") + self.rp_log_custom_levels = None + if log_custom_levels: + levels = {} + for custom_level in log_custom_levels: + level, level_name = str(custom_level).split(":", maxsplit=1) + levels[int(level)] = level_name + self.rp_log_custom_levels = levels + # noinspection PyMethodMayBeStatic def find_option(self, pytest_config: Config, option_name: str, default: Any = None) -> Any: """ diff --git a/pytest_reportportal/plugin.py b/pytest_reportportal/plugin.py index cabf1ce..8d8465b 100644 --- a/pytest_reportportal/plugin.py +++ b/pytest_reportportal/plugin.py @@ -254,6 +254,7 @@ def pytest_runtest_protocol(item: Item) -> Generator[None, Any, None]: filter_client_logs=True, endpoint=agent_config.rp_endpoint, ignored_record_names=("reportportal_client", "pytest_reportportal"), + custom_levels=agent_config.rp_log_custom_levels, ) log_format = agent_config.rp_log_format if log_format: @@ -600,6 +601,12 @@ def add_shared_option(name, help_str, default=None, action="store"): "rp_log_batch_payload_size", help="DEPRECATED: Maximum payload size in bytes of async batch log requests", ) + parser.addini( + "rp_log_custom_levels", + type="args", + help="Custom log levels specified as 'int level:string'. E.G.: '35:ASSERTION'. Overrides existing level if int" + " level matches.", + ) parser.addini("rp_ignore_attributes", type="args", help="Ignore specified pytest markers, i.e parametrize") parser.addini( "rp_is_skipped_an_issue", default=True, type="bool", help="Treat skipped tests as required investigation" diff --git a/pytest_reportportal/service.py b/pytest_reportportal/service.py index f6b3883..521242c 100644 --- a/pytest_reportportal/service.py +++ b/pytest_reportportal/service.py @@ -631,7 +631,7 @@ def _get_test_case_id( def _get_issue_ids(self, mark): issue_ids = mark.kwargs.get("issue_id", []) - if not isinstance(issue_ids, List): + if not isinstance(issue_ids, list): issue_ids = [issue_ids] return issue_ids diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 4a238f7..d57be79 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -12,6 +12,11 @@ # limitations under the License """This package contains integration tests for the project.""" +from collections import defaultdict +from typing import Optional + +from reportportal_client import set_current +from reportportal_client.steps import StepReporter from tests.helpers import utils @@ -272,3 +277,48 @@ (test, HIERARCHY_TEST_VARIABLES[idx], HIERARCHY_TEST_EXPECTED_ITEMS[idx]) for idx, test in enumerate(HIERARCHY_TESTS) ] + +ITEM_ID_DICT = defaultdict(lambda: 0) +ITEM_ID_LIST = [] + + +def generate_item_id(*args, **kwargs) -> str: + global ITEM_ID_DICT + global ITEM_ID_LIST + if args: + name = args[0] + else: + name = kwargs["name"] + count = ITEM_ID_DICT[name] + count += 1 + ITEM_ID_DICT[name] = count + item_id = f"{name}_{count}" + ITEM_ID_LIST.append(item_id) + return item_id + + +def get_last_item_id() -> Optional[str]: + global ITEM_ID_LIST + if len(ITEM_ID_LIST) > 0: + return ITEM_ID_LIST[-1] + + +def remove_last_item_id(*_, **__) -> Optional[str]: + global ITEM_ID_LIST + if len(ITEM_ID_LIST) > 0: + return ITEM_ID_LIST.pop() + + +def setup_mock(mock_client_init): + mock_client = mock_client_init.return_value + mock_client.step_reporter = StepReporter(mock_client) + set_current(mock_client) + return mock_client + + +def setup_mock_for_logging(mock_client_init): + mock_client = setup_mock(mock_client_init) + mock_client.start_test_item.side_effect = generate_item_id + mock_client.finish_test_item.side_effect = remove_last_item_id + mock_client.current_item.side_effect = get_last_item_id + return mock_client diff --git a/tests/integration/test_bdd.py b/tests/integration/test_bdd.py index c33a71f..5872602 100644 --- a/tests/integration/test_bdd.py +++ b/tests/integration/test_bdd.py @@ -18,59 +18,13 @@ from unittest import mock import pytest -from reportportal_client import set_current -from reportportal_client.steps import StepReporter +from integration import setup_mock, setup_mock_for_logging from tests import REPORT_PORTAL_SERVICE from tests.helpers import utils pytest_bdd_version = [int(p) for p in importlib.metadata.version("pytest-bdd").split(".")] -ITEM_ID_DICT = defaultdict(lambda: 0) -ITEM_ID_LIST = [] - - -def generate_item_id(*args, **kwargs) -> str: - global ITEM_ID_DICT - global ITEM_ID_LIST - if args: - name = args[0] - else: - name = kwargs["name"] - count = ITEM_ID_DICT[name] - count += 1 - ITEM_ID_DICT[name] = count - item_id = f"{name}_{count}" - ITEM_ID_LIST.append(item_id) - return item_id - - -def get_last_item_id() -> Optional[str]: - global ITEM_ID_LIST - if len(ITEM_ID_LIST) > 0: - return ITEM_ID_LIST[-1] - - -def remove_last_item_id(*_, **__) -> Optional[str]: - global ITEM_ID_LIST - if len(ITEM_ID_LIST) > 0: - return ITEM_ID_LIST.pop() - - -def setup_mock(mock_client_init): - mock_client = mock_client_init.return_value - mock_client.step_reporter = StepReporter(mock_client) - set_current(mock_client) - return mock_client - - -def setup_mock_for_logging(mock_client_init): - mock_client = setup_mock(mock_client_init) - mock_client.start_test_item.side_effect = generate_item_id - mock_client.finish_test_item.side_effect = remove_last_item_id - mock_client.current_item.side_effect = get_last_item_id - return mock_client - STEP_NAMES = [ "Given there are 5 cucumbers", diff --git a/tests/integration/test_config_handling.py b/tests/integration/test_config_handling.py index 0c121e7..8ea03c6 100644 --- a/tests/integration/test_config_handling.py +++ b/tests/integration/test_config_handling.py @@ -17,7 +17,9 @@ from unittest import mock import pytest +import test_rp_custom_logging from delayed_assert import assert_expectations, expect +from integration import setup_mock_for_logging from reportportal_client import OutputType from examples.test_rp_logging import LOG_MESSAGE @@ -268,3 +270,19 @@ def test_client_timeouts(mock_client_init, connect_value, read_value, expected_r assert int(result) == 0, "Exit code should be 0 (no errors)" assert mock_client_init.call_count == 1 assert mock_client_init.call_args_list[0][1]["http_timeout"] == expected_result + + +@mock.patch(REPORT_PORTAL_SERVICE) +def test_rp_log_custom_levels(mock_client_init): + setup_mock_for_logging(mock_client_init) + custom_log_level = test_rp_custom_logging.LOG_LEVEL + custom_log_name = "ASSERTION" + variables = dict(utils.DEFAULT_VARIABLES) + variables.update({"rp_log_custom_levels": str(custom_log_level) + ":" + custom_log_name}) + + result = utils.run_pytest_tests(["examples/test_rp_custom_logging.py"], variables=variables) + assert int(result) == 0, "Exit code should be 0 (no errors)" + + mock_client = mock_client_init.return_value + assert mock_client.log.call_count == 1 + assert mock_client.log.call_args_list[0][1]["level"] == custom_log_name diff --git a/tests/unit/test_plugin.py b/tests/unit/test_plugin.py index d1ab7b8..9912f73 100644 --- a/tests/unit/test_plugin.py +++ b/tests/unit/test_plugin.py @@ -259,6 +259,7 @@ def test_pytest_addoption_adds_correct_ini_file_arguments(): "rp_log_batch_size", "rp_log_batch_payload_limit", "rp_log_batch_payload_size", + "rp_log_custom_levels", "rp_ignore_attributes", "rp_is_skipped_an_issue", "rp_hierarchy_code", From 10c3f461e2911444ad41625322ae11f955e96c22 Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Tue, 2 Dec 2025 14:35:16 +0300 Subject: [PATCH 10/12] CHANGELOG.md update --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 859d2a5..7427836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Official `Python 3.14` support, by @HardNorth - Issue [#396](https://github.com/reportportal/agent-python-pytest/issues/396) parametrize marker IDs, by @HardNorth +- Custom log level handling with `rp_log_custom_levels` configuration parameter, by @HardNorth ### Removed - `Python 3.8` support, by @HardNorth - Deprecated `retries` parameter, by @HardNorth From a7ac7ce6e6fc61f764f297558a173f3a752d5d82 Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Tue, 2 Dec 2025 14:36:43 +0300 Subject: [PATCH 11/12] Fix tests --- tests/integration/test_bdd.py | 5 +---- tests/integration/test_config_handling.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/integration/test_bdd.py b/tests/integration/test_bdd.py index 5872602..6e26132 100644 --- a/tests/integration/test_bdd.py +++ b/tests/integration/test_bdd.py @@ -13,19 +13,16 @@ # limitations under the License. import importlib.metadata -from collections import defaultdict -from typing import Optional from unittest import mock import pytest -from integration import setup_mock, setup_mock_for_logging from tests import REPORT_PORTAL_SERVICE from tests.helpers import utils +from tests.integration import setup_mock, setup_mock_for_logging pytest_bdd_version = [int(p) for p in importlib.metadata.version("pytest-bdd").split(".")] - STEP_NAMES = [ "Given there are 5 cucumbers", "When I eat 3 cucumbers", diff --git a/tests/integration/test_config_handling.py b/tests/integration/test_config_handling.py index 8ea03c6..5dd060f 100644 --- a/tests/integration/test_config_handling.py +++ b/tests/integration/test_config_handling.py @@ -19,12 +19,12 @@ import pytest import test_rp_custom_logging from delayed_assert import assert_expectations, expect -from integration import setup_mock_for_logging from reportportal_client import OutputType from examples.test_rp_logging import LOG_MESSAGE from tests import REPORT_PORTAL_SERVICE from tests.helpers import utils +from tests.integration import setup_mock_for_logging TEST_LAUNCH_ID = "test_launch_id" From 655740c6fc2709c2ccf2f23bd104ec3a3ee0b556 Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Tue, 2 Dec 2025 14:39:27 +0300 Subject: [PATCH 12/12] Fix tests --- tests/integration/test_config_handling.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/integration/test_config_handling.py b/tests/integration/test_config_handling.py index 5dd060f..16d421f 100644 --- a/tests/integration/test_config_handling.py +++ b/tests/integration/test_config_handling.py @@ -17,11 +17,10 @@ from unittest import mock import pytest -import test_rp_custom_logging from delayed_assert import assert_expectations, expect from reportportal_client import OutputType -from examples.test_rp_logging import LOG_MESSAGE +from examples import test_rp_custom_logging, test_rp_logging from tests import REPORT_PORTAL_SERVICE from tests.helpers import utils from tests.integration import setup_mock_for_logging @@ -115,7 +114,7 @@ def test_rp_log_format(mock_client_init): expect(mock_client.log.call_count == 1) message = mock_client.log.call_args_list[0][0][1] expect(len(message) > 0) - expect(message == f"(test_rp_logging) {LOG_MESSAGE} (test_rp_logging.py:24)") + expect(message == f"(test_rp_logging) {test_rp_logging.LOG_MESSAGE} (test_rp_logging.py:24)") assert_expectations()