From 5f10f158335cdf8d54fb44e06e235b7bfc4c719d Mon Sep 17 00:00:00 2001 From: "reportportal.io" Date: Mon, 6 Apr 2026 20:24:43 +0000 Subject: [PATCH 01/12] Changelog update --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b426ba4..dee0aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## [Unreleased] + +## [5.7.2] ### Changed - `aiohttp` version updated to 3.13.4, by @HardNorth ### Removed From 5ed2e67634f5bf1b07a033a6ac3290d5fd9e4205 Mon Sep 17 00:00:00 2001 From: "reportportal.io" Date: Mon, 6 Apr 2026 20:24:44 +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 63d8db6..54c0f72 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import find_packages, setup -__version__ = "5.7.2" +__version__ = "5.7.3" TYPE_STUBS = ["*.pyi"] From d3220bef1cbbc7c3bb8adf48701407b8a35f36a7 Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Wed, 8 Apr 2026 23:06:51 +0300 Subject: [PATCH 03/12] Add auxiliary code for microseconds --- reportportal_client/helpers/__init__.py | 2 + reportportal_client/helpers/common_helpers.py | 113 +++++++++++++++++- tests/helpers/test_helpers.py | 34 ++++++ 3 files changed, 143 insertions(+), 6 deletions(-) diff --git a/reportportal_client/helpers/__init__.py b/reportportal_client/helpers/__init__.py index 92f9a0e..c694b2f 100644 --- a/reportportal_client/helpers/__init__.py +++ b/reportportal_client/helpers/__init__.py @@ -18,6 +18,7 @@ calculate_file_part_size, calculate_json_part_size, caseless_equal, + compare_semantic_versions, dict_to_payload, gen_attributes, generate_uuid, @@ -56,6 +57,7 @@ "calculate_file_part_size", "calculate_json_part_size", "caseless_equal", + "compare_semantic_versions", "dict_to_payload", "gen_attributes", "generate_uuid", diff --git a/reportportal_client/helpers/common_helpers.py b/reportportal_client/helpers/common_helpers.py index 9457b3e..bdcb679 100644 --- a/reportportal_client/helpers/common_helpers.py +++ b/reportportal_client/helpers/common_helpers.py @@ -16,12 +16,11 @@ import asyncio import fnmatch import inspect -import logging import re import threading -import time import unicodedata import uuid +from datetime import datetime, timezone from platform import machine, processor, system from types import MappingProxyType from typing import Any, Callable, Generic, Iterable, Optional, Sized, TypeVar, Union @@ -34,7 +33,7 @@ except ImportError: import json # type: ignore -logger: logging.Logger = logging.getLogger(__name__) +ISO_MICRO_FORMAT = "%Y-%m-%dT%H:%M:%S.%f%z" _T = TypeVar("_T") ATTRIBUTE_LENGTH_LIMIT: int = 128 ATTRIBUTE_NUMBER_LIMIT: int = 256 @@ -277,9 +276,12 @@ def verify_value_length(attributes: list[dict]) -> Optional[list[dict]]: return result -def timestamp() -> str: - """Return string representation of the current time in milliseconds.""" - return str(int(time.time() * 1000)) +def timestamp(use_microseconds=False) -> str: + """Return string representation of the current time in milli/microseconds.""" + now = datetime.now(tz=timezone.utc) + if use_microseconds: + return now.strftime(ISO_MICRO_FORMAT) + return str(int(now.timestamp() * 1000)) def uri_join(*uri_parts: str) -> str: @@ -586,3 +588,102 @@ def clean_binary_characters(text: str) -> str: if not text: return "" return text.translate(CLEANUP_TABLE) + + +def compare_semantic_versions(compared: str, basic: str) -> int: + """Compare semantic versions using SemVer precedence rules.""" + compared_norm = _normalize_version(compared) + basic_norm = _normalize_version(basic) + + compared_base, compared_pre_release = _split_base_and_pre_release(compared_norm) + basic_base, basic_pre_release = _split_base_and_pre_release(basic_norm) + + core_comparison = _compare_core_versions(compared_base, basic_base) + if core_comparison != 0: + return core_comparison + + if compared_pre_release == basic_pre_release: + return 0 + if compared_pre_release is None: + return 1 + if basic_pre_release is None: + return -1 + return _compare_pre_release(compared_pre_release, basic_pre_release) + + +def _normalize_version(version: str) -> str: + normalized_version = version.strip() + if normalized_version.startswith("v") or normalized_version.startswith("V"): + normalized_version = normalized_version[1:] + plus_index = normalized_version.find("+") + if plus_index >= 0: + normalized_version = normalized_version[:plus_index] + return normalized_version + + +def _split_base_and_pre_release(version: str) -> tuple[str, Optional[str]]: + dash_index = version.find("-") + if dash_index < 0: + return version, None + return version[:dash_index], version[dash_index + 1 :] + + +def _compare_core_versions(core_1: str, core_2: str) -> int: + parts_1 = _split_without_trailing_empty_segments(core_1, ".") + parts_2 = _split_without_trailing_empty_segments(core_2, ".") + for i in range(max(len(parts_1), len(parts_2))): + part_1 = _parse_int_safe(parts_1[i]) if i < len(parts_1) else 0 + part_2 = _parse_int_safe(parts_2[i]) if i < len(parts_2) else 0 + if part_1 != part_2: + return -1 if part_1 < part_2 else 1 + return 0 + + +def _compare_pre_release(pre_release_1: str, pre_release_2: str) -> int: + tokens_1 = _split_without_trailing_empty_segments(pre_release_1, ".") + tokens_2 = _split_without_trailing_empty_segments(pre_release_2, ".") + for i in range(max(len(tokens_1), len(tokens_2))): + token_1: Optional[str] = tokens_1[i] if i < len(tokens_1) else None + token_2: Optional[str] = tokens_2[i] if i < len(tokens_2) else None + if token_1 == token_2: + continue + if token_1 is None: + return -1 + if token_2 is None: + return 1 + + token_1_is_numeric = _is_numeric(token_1) + token_2_is_numeric = _is_numeric(token_2) + if token_1_is_numeric and token_2_is_numeric: + number_1 = _parse_int_safe(token_1) + number_2 = _parse_int_safe(token_2) + if number_1 != number_2: + return -1 if number_1 < number_2 else 1 + elif token_1_is_numeric != token_2_is_numeric: + return -1 if token_1_is_numeric else 1 + else: + if token_1 < token_2: + return -1 + if token_1 > token_2: + return 1 + return 0 + + +def _parse_int_safe(value: str) -> int: + try: + return int(value) + except ValueError: + return 0 + + +def _is_numeric(value: str) -> bool: + if not value: + return False + return value.isdigit() + + +def _split_without_trailing_empty_segments(value: str, separator: str) -> list[str]: + parts = value.split(separator) + while len(parts) > 1 and parts[-1] == "": + parts.pop() + return parts diff --git a/tests/helpers/test_helpers.py b/tests/helpers/test_helpers.py index d2f7539..b898389 100644 --- a/tests/helpers/test_helpers.py +++ b/tests/helpers/test_helpers.py @@ -21,6 +21,7 @@ from reportportal_client.helpers import ( ATTRIBUTE_LENGTH_LIMIT, TRUNCATE_REPLACEMENT, + compare_semantic_versions, gen_attributes, get_launch_sys_attrs, guess_content_type_from_bytes, @@ -241,3 +242,36 @@ def test_to_bool_invalid_value(): ) def test_match_with_glob_pattern(pattern: Optional[str], line: Optional[str], expected: bool): assert match_pattern(translate_glob_to_regex(pattern), line) == expected + + +@pytest.mark.parametrize( + ["compared", "basic", "expected"], + [ + ("5.13.2", "5.13.2", 0), + ("5.13.1", "5.13.2", -1), + ("5.13.3", "5.13.2", 1), + ("5.12.2", "5.13.2", -1), + ("5.14.2", "5.13.2", 1), + ("4.13.2", "5.13.2", -1), + ("6.13.2", "5.13.2", 1), + ("v5.13.2", "5.13.2", 0), + ("v5.13.1", "v5.13.2", -1), + ("5.13.3+12345", "5.13.2+54321", 1), + ("5.13.2", "5.13.2+54321", 0), + ("5.13.2-1.1", "5.13.2-1.1", 0), + ("5.13.2-1.2", "5.13.2-1.1", 1), + ("5.13.2-0.9", "5.13.2-1.1", -1), + ("5.13.2-1.0", "5.13.2-1.1", -1), + ("5.13.2-1", "5.13.2-1.1", -1), + ("5.13.2-1.1", "5.13.2-1", 1), + ("5.13.2-1.", "5.13.2-1", 0), + ("5.13.2-1.", "5.13.2-1.1", -1), + ("5.13.2-1.a", "5.13.2-1.1", 1), + ("5.13.2-1.1", "5.13.2-1.a", -1), + ("5.13.2-1.a", "5.13.2-1.a", 0), + ("5.13.2-1.b", "5.13.2-1.a", 1), + ("5.13.2-1.a", "5.13.2-1.b", -1), + ], +) +def test_compare_semver(compared: str, basic: str, expected: int): + assert compare_semantic_versions(compared, basic) == expected From a63f30373d17b0c17b162cf4ad30fb566672cc4a Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Thu, 9 Apr 2026 09:51:01 +0300 Subject: [PATCH 04/12] Update types to support microseconds --- reportportal_client/aio/client.py | 37 ++++++++++++++++--------------- reportportal_client/client.py | 21 +++++++++--------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/reportportal_client/aio/client.py b/reportportal_client/aio/client.py index 8061b43..60a9685 100644 --- a/reportportal_client/aio/client.py +++ b/reportportal_client/aio/client.py @@ -19,8 +19,9 @@ import logging import ssl import threading -import time as datetime +import time import warnings +from datetime import datetime from os import getenv from typing import Any, Coroutine, Optional, TypeVar, Union @@ -349,7 +350,7 @@ async def __get_launch_url(self, launch_uuid_future: Union[Optional[str], Task[O async def start_launch( self, name: str, - start_time: str, + start_time: Union[str, datetime], *, description: Optional[str] = None, attributes: Optional[Union[list, dict]] = None, @@ -402,7 +403,7 @@ async def start_test_item( self, launch_uuid: Union[str, Task[str]], name: str, - start_time: str, + start_time: Union[str, datetime], item_type: str, *, parent_item_id: Optional[Union[str, Task[str]]] = None, @@ -478,7 +479,7 @@ async def finish_test_item( self, launch_uuid: Union[str, Task[str]], item_id: Union[str, Task[str]], - end_time: str, + end_time: Union[str, datetime], *, status: Optional[str] = None, description: Optional[str] = None, @@ -535,7 +536,7 @@ async def finish_test_item( async def finish_launch( self, launch_uuid: Union[str, Task[str]], - end_time: str, + end_time: Union[str, datetime], *, status: Optional[str] = None, attributes: Optional[Union[list, dict]] = None, @@ -883,7 +884,7 @@ def __init__( async def start_launch( self, name: str, - start_time: str, + start_time: Union[str, datetime], description: Optional[str] = None, attributes: Optional[Union[list, dict]] = None, rerun: bool = False, @@ -912,7 +913,7 @@ async def start_launch( async def start_test_item( self, name: str, - start_time: str, + start_time: Union[str, datetime], item_type: str, description: Optional[str] = None, attributes: Optional[list[dict]] = None, @@ -973,7 +974,7 @@ async def start_test_item( async def finish_test_item( self, item_id: str, - end_time: str, + end_time: Union[str, datetime], status: Optional[str] = None, issue: Optional[Issue] = None, attributes: Optional[Union[list, dict]] = None, @@ -1017,7 +1018,7 @@ async def finish_test_item( async def finish_launch( self, - end_time: str, + end_time: Union[str, datetime], status: Optional[str] = None, attributes: Optional[Union[list, dict]] = None, **kwargs: Any, @@ -1110,7 +1111,7 @@ async def get_project_settings(self) -> Optional[dict]: async def log( self, - time: str, + time: Union[str, datetime], message: str, level: Optional[Union[int, str]] = None, attachment: Optional[dict] = None, @@ -1340,7 +1341,7 @@ async def __int_value(self) -> int: def start_launch( self, name: str, - start_time: str, + start_time: Union[str, datetime], description: Optional[str] = None, attributes: Optional[Union[list, dict]] = None, rerun: bool = False, @@ -1369,7 +1370,7 @@ def start_launch( def start_test_item( self, name: str, - start_time: str, + start_time: Union[str, datetime], item_type: str, description: Optional[str] = None, attributes: Optional[list[dict]] = None, @@ -1428,7 +1429,7 @@ def start_test_item( def finish_test_item( self, item_id: Task[str], - end_time: str, + end_time: Union[str, datetime], status: Optional[str] = None, issue: Optional[Issue] = None, attributes: Optional[Union[list, dict]] = None, @@ -1473,7 +1474,7 @@ def finish_test_item( def finish_launch( self, - end_time: str, + end_time: Union[str, datetime], status: Optional[str] = None, attributes: Optional[Union[list, dict]] = None, **kwargs: Any, @@ -1572,7 +1573,7 @@ async def _log(self, log_rq: AsyncRPRequestLog) -> Optional[tuple[str, ...]]: def log( self, - time: str, + time: Union[str, datetime], message: str, level: Optional[Union[int, str]] = None, attachment: Optional[dict] = None, @@ -1747,13 +1748,13 @@ def create_task(self, coro: Coroutine[Any, Any, _T]) -> Optional[Task[_T]]: def finish_tasks(self): """Ensure all pending Tasks are finished, block current Thread if necessary.""" - shutdown_start_time = datetime.time() + shutdown_start_time = time.time() with self._task_mutex: tasks = self._task_list.flush() if tasks: for task in tasks: task.blocking_result() - if datetime.time() - shutdown_start_time >= self.shutdown_timeout: + if time.time() - shutdown_start_time >= self.shutdown_timeout: break logs = self._log_batcher.flush() if logs: @@ -1916,7 +1917,7 @@ def __init__( self.trigger_num = trigger_num self.trigger_interval = trigger_interval self.__init_task_list(task_list, task_mutex) - self.__last_run_time = datetime.time() + self.__last_run_time = time.time() self.__init_loop(loop) if type(launch_uuid) is str: super().__init__( diff --git a/reportportal_client/client.py b/reportportal_client/client.py index 3b55fa0..f018e13 100644 --- a/reportportal_client/client.py +++ b/reportportal_client/client.py @@ -19,6 +19,7 @@ import sys import warnings from abc import abstractmethod +from datetime import datetime from enum import Enum from os import getenv from typing import Any, Optional, TextIO, Union @@ -147,7 +148,7 @@ def step_reporter(self) -> StepReporter: def start_launch( self, name: str, - start_time: str, + start_time: Union[str, datetime], description: Optional[str] = None, attributes: Optional[Union[list, dict]] = None, rerun: bool = False, @@ -171,7 +172,7 @@ def start_launch( def start_test_item( self, name: str, - start_time: str, + start_time: Union[str, datetime], item_type: str, description: Optional[str] = None, attributes: Optional[Union[list[dict], dict]] = None, @@ -212,7 +213,7 @@ def start_test_item( def finish_test_item( self, item_id: str, - end_time: str, + end_time: Union[str, datetime], status: Optional[str] = None, issue: Optional[Issue] = None, attributes: Optional[Union[list, dict]] = None, @@ -243,7 +244,7 @@ def finish_test_item( @abstractmethod def finish_launch( self, - end_time: str, + end_time: Union[str, datetime], status: Optional[str] = None, attributes: Optional[Union[list, dict]] = None, **kwargs: Any, @@ -318,7 +319,7 @@ def get_project_settings(self) -> Optional[dict]: @abstractmethod def log( self, - time: str, + time: Union[str, datetime], message: str, level: Optional[Union[int, str]] = None, attachment: Optional[dict] = None, @@ -637,7 +638,7 @@ def __init__( def start_launch( self, name: str, - start_time: str, + start_time: Union[str, datetime], description: Optional[str] = None, attributes: Optional[Union[list, dict]] = None, rerun: bool = False, @@ -693,7 +694,7 @@ def start_launch( def start_test_item( self, name: str, - start_time: str, + start_time: Union[str, datetime], item_type: str, description: Optional[str] = None, attributes: Optional[Union[list[dict], dict]] = None, @@ -772,7 +773,7 @@ def start_test_item( def finish_test_item( self, item_id: Any, - end_time: str, + end_time: Union[str, datetime], status: Optional[str] = None, issue: Optional[Issue] = None, attributes: Optional[Union[list, dict]] = None, @@ -834,7 +835,7 @@ def finish_test_item( def finish_launch( self, - end_time: str, + end_time: Union[str, datetime], status: Optional[str] = None, attributes: Optional[Union[list, dict]] = None, **kwargs: Any, @@ -936,7 +937,7 @@ def _log(self, batch: Optional[list[RPRequestLog]]) -> Optional[tuple[str, ...]] def log( self, - time: str, + time: Union[str, datetime], message: str, level: Optional[Union[int, str]] = None, attachment: Optional[dict] = None, From 0041c80e30482192060c3dd33db03fd6e4831f22 Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Thu, 9 Apr 2026 10:21:39 +0300 Subject: [PATCH 05/12] Add `get_api_info` method --- reportportal_client/aio/client.py | 25 +++++++++++++++++++++++++ reportportal_client/client.py | 23 +++++++++++++++++++++++ tests/aio/test_aio_client.py | 16 ++++++++++++++++ tests/aio/test_async_client.py | 13 +++++++++++++ tests/aio/test_batched_client.py | 12 ++++++++++++ tests/aio/test_threaded_client.py | 12 ++++++++++++ tests/test_client.py | 13 +++++++++++++ 7 files changed, 114 insertions(+) diff --git a/reportportal_client/aio/client.py b/reportportal_client/aio/client.py index 60a9685..e573464 100644 --- a/reportportal_client/aio/client.py +++ b/reportportal_client/aio/client.py @@ -683,6 +683,15 @@ async def get_project_settings(self) -> Optional[dict]: response = await AsyncHttpRequest((await self.session()).get, url=url, name="get_project_settings").make() return await response.json if response else None + async def get_api_info(self) -> Optional[dict]: + """Get server information, like version. + + :return: server information. + """ + url = root_uri_join("api/info") + response = await AsyncHttpRequest((await self.session()).get, url=url, name="get_api_info").make() + return await response.json if response else None + async def log_batch(self, log_batch: Optional[list[AsyncRPRequestLog]]) -> Optional[tuple[str, ...]]: """Send batch logging message to the ReportPortal. @@ -1109,6 +1118,13 @@ async def get_project_settings(self) -> Optional[dict]: """ return await self.__client.get_project_settings() + async def get_api_info(self) -> Optional[dict]: + """Get server information, like version. + + :return: server information. + """ + return await self.__client.get_api_info() + async def log( self, time: Union[str, datetime], @@ -1565,6 +1581,15 @@ def get_project_settings(self) -> Task[Optional[str]]: result_task = self.create_task(result_coro) return result_task + def get_api_info(self) -> Task[Optional[dict]]: + """Get server information, like version. + + :return: server information. + """ + result_coro = self.__client.get_api_info() + result_task = self.create_task(result_coro) + return result_task + async def _log_batch(self, log_rq: Optional[list[AsyncRPRequestLog]]) -> Optional[tuple[str, ...]]: return await self.__client.log_batch(log_rq) diff --git a/reportportal_client/client.py b/reportportal_client/client.py index f018e13..e705e29 100644 --- a/reportportal_client/client.py +++ b/reportportal_client/client.py @@ -316,6 +316,14 @@ def get_project_settings(self) -> Optional[dict]: """ raise NotImplementedError('"get_project_settings" method is not implemented!') + @abstractmethod + def get_api_info(self) -> Optional[dict]: + """Get server information, like version. + + :return: server information. + """ + raise NotImplementedError('"get_api_info" method is not implemented!') + @abstractmethod def log( self, @@ -1055,6 +1063,21 @@ def get_project_settings(self) -> Optional[dict]: ).make() return response.json if response else None + def get_api_info(self) -> Optional[dict]: + """Get server information, like version. + + :return: server information. + """ + url = uri_join(self.__endpoint, "api/info") + response = HttpRequest( + self.session.get, + url=url, + verify_ssl=self.verify_ssl, + http_timeout=self.http_timeout, + name="get_api_info", + ).make() + return response.json if response else None + def _add_current_item(self, item: str) -> None: """Add the last item from the self._items queue.""" self._item_stack.put(item) diff --git a/tests/aio/test_aio_client.py b/tests/aio/test_aio_client.py index 86b8188..6a40cb6 100644 --- a/tests/aio/test_aio_client.py +++ b/tests/aio/test_aio_client.py @@ -565,6 +565,7 @@ def request_error(*_, **__): ("get", "get_launch_ui_id", ["launch_uuid"]), ("get", "get_launch_ui_url", ["launch_uuid"]), ("get", "get_project_settings", []), + ("get", "get_api_info", []), ( "post", "log_batch", @@ -909,6 +910,21 @@ async def test_get_launch_ui_url(aio_client: Client): assert expected_uri == call_args[0][0] +@pytest.mark.asyncio +async def test_get_api_info(aio_client: Client): + # noinspection PyTypeChecker + session: mock.AsyncMock = await aio_client.session() + mock_basic_get_response(session) + + expected_uri = "/api/info" + + result = await aio_client.get_api_info() + assert result == RETURN_GET_JSON + session.get.assert_called_once() + call_args = session.get.call_args_list[0] + assert expected_uri == call_args[0][0] + + @pytest.mark.parametrize( "method, mock_method, call_method, arguments", [ diff --git a/tests/aio/test_async_client.py b/tests/aio/test_async_client.py index d0a5c0c..ab023a1 100644 --- a/tests/aio/test_async_client.py +++ b/tests/aio/test_async_client.py @@ -193,3 +193,16 @@ async def test_logs_flush_on_close(async_client: AsyncRPClient): batcher.flush.assert_called_once() client.log_batch.assert_called_once() client.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_get_api_info(async_client: AsyncRPClient): + # noinspection PyTypeChecker + client: mock.AsyncMock = async_client.client + expected_info = {"version": "5.14.0"} + client.get_api_info.return_value = expected_info + + result = await async_client.get_api_info() + + assert result == expected_info + client.get_api_info.assert_called_once_with() diff --git a/tests/aio/test_batched_client.py b/tests/aio/test_batched_client.py index e080e3f..03f7721 100644 --- a/tests/aio/test_batched_client.py +++ b/tests/aio/test_batched_client.py @@ -167,3 +167,15 @@ def test_logs_flush_on_close(batched_client: BatchedRPClient): batcher.flush.assert_called_once() client.log_batch.assert_called_once() client.close.assert_called_once() + + +def test_get_api_info(): + aio_client = mock.AsyncMock() + expected_info = {"version": "5.14.0"} + aio_client.get_api_info.return_value = expected_info + client = BatchedRPClient("http://endpoint", "project", api_key="api_key", client=aio_client) + + result = client.get_api_info().blocking_result() + + assert result == expected_info + aio_client.get_api_info.assert_called_once_with() diff --git a/tests/aio/test_threaded_client.py b/tests/aio/test_threaded_client.py index ee5f6b0..28f6d28 100644 --- a/tests/aio/test_threaded_client.py +++ b/tests/aio/test_threaded_client.py @@ -165,3 +165,15 @@ def test_logs_flush_on_close(batched_client: ThreadedRPClient): batcher.flush.assert_called_once() client.log_batch.assert_called_once() client.close.assert_called_once() + + +def test_get_api_info(): + aio_client = mock.AsyncMock() + expected_info = {"version": "5.14.0"} + aio_client.get_api_info.return_value = expected_info + client = ThreadedRPClient("http://endpoint", "project", api_key="api_key", client=aio_client) + + result = client.get_api_info().blocking_result() + + assert result == expected_info + aio_client.get_api_info.assert_called_once_with() diff --git a/tests/test_client.py b/tests/test_client.py index 19b3b80..d2ec660 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -54,6 +54,7 @@ def invalid_response(*args, **kwargs): ("get", "get_launch_ui_id", []), ("get", "get_launch_ui_url", []), ("get", "get_project_settings", []), + ("get", "get_api_info", []), ("post", "start_launch", ["Test Launch", timestamp()]), ("post", "start_test_item", ["Test Item", timestamp(), "STEP"]), ("put", "update_test_item", ["test_item_id"]), @@ -297,6 +298,7 @@ def test_attribute_sanitization_binary_and_number_limit(rp_client: RPClient): ("update_test_item", "put", ["test_item_uuid"]), ("get_launch_info", "get", []), ("get_project_settings", "get", []), + ("get_api_info", "get", []), ("get_item_id_by_uuid", "get", ["test_item_uuid"]), ("log", "post", [timestamp(), "Test Message"]), ], @@ -344,6 +346,17 @@ def test_logs_flush_on_close(rp_client: RPClient): session.close.assert_called_once() +def test_get_api_info_url(rp_client: RPClient): + # noinspection PyTypeChecker + session: mock.Mock = rp_client.session + + rp_client.get_api_info() + + session.get.assert_called_once() + request_args = session.get.call_args_list[0][0] + assert request_args[0] == "http://endpoint/api/info" + + def test_oauth_authentication_parameters(): """Test that OAuth 2.0 authentication parameters work correctly.""" client = RPClient( From f6bcfd1333b3701c4932596c281f6a976bbb1f8e Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Thu, 9 Apr 2026 10:49:33 +0300 Subject: [PATCH 06/12] Add `EmptyTask` class --- reportportal_client/aio/__init__.py | 3 ++- reportportal_client/aio/client.py | 18 +++++++------ reportportal_client/aio/tasks.py | 25 ++++++++++++++++++- reportportal_client/helpers/common_helpers.py | 5 ++-- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/reportportal_client/aio/__init__.py b/reportportal_client/aio/__init__.py index 131ff24..4646052 100644 --- a/reportportal_client/aio/__init__.py +++ b/reportportal_client/aio/__init__.py @@ -22,10 +22,11 @@ BatchedRPClient, ThreadedRPClient, ) -from reportportal_client.aio.tasks import BlockingOperationError, Task +from reportportal_client.aio.tasks import BlockingOperationError, EmptyTask, Task __all__ = [ "Task", + "EmptyTask", "BlockingOperationError", "DEFAULT_TASK_TIMEOUT", "DEFAULT_SHUTDOWN_TIMEOUT", diff --git a/reportportal_client/aio/client.py b/reportportal_client/aio/client.py index e573464..6052544 100644 --- a/reportportal_client/aio/client.py +++ b/reportportal_client/aio/client.py @@ -56,6 +56,7 @@ # noinspection PyProtectedMember from reportportal_client._internal.static.abstract import AbstractBaseClass, abstractmethod from reportportal_client._internal.static.defines import DEFAULT_LOG_LEVEL +from reportportal_client.aio import EmptyTask # noinspection PyProtectedMember from reportportal_client.aio.tasks import Task @@ -1311,7 +1312,7 @@ def __init__( set_current(self) @abstractmethod - def create_task(self, coro: Coroutine[Any, Any, _T]) -> Optional[Task[_T]]: + def create_task(self, coro: Coroutine[Any, Any, _T]) -> Task[_T]: """Create a Task from given Coroutine. :param coro: Coroutine which will be used for the Task creation. @@ -1572,7 +1573,7 @@ def get_launch_ui_url(self) -> Task[Optional[str]]: result_task = self.create_task(result_coro) return result_task - def get_project_settings(self) -> Task[Optional[str]]: + def get_project_settings(self) -> Task[Optional[dict]]: """Get settings of the current Project. :return: Settings response in Dictionary. @@ -1685,8 +1686,9 @@ def __init_loop(self, loop: Optional[asyncio.AbstractEventLoop] = None): self._loop = asyncio.new_event_loop() self._loop.set_task_factory(ThreadedTaskFactory(self.task_timeout)) self.__heartbeat() - self._thread = threading.Thread(target=self._loop.run_forever, name="RP-Async-Client", daemon=True) - self._thread.start() + thread = threading.Thread(target=self._loop.run_forever, name="RP-Async-Client", daemon=True) + thread.start() + self._thread = thread async def __return_value(self, value): return value @@ -1758,14 +1760,14 @@ def __init__( else: super().__init__(endpoint, project, launch_uuid=launch_uuid, **kwargs) - def create_task(self, coro: Coroutine[Any, Any, _T]) -> Optional[Task[_T]]: + def create_task(self, coro: Coroutine[Any, Any, _T]) -> Task[_T]: """Create a Task from given Coroutine. :param coro: Coroutine which will be used for the Task creation. :return: Task instance. """ if not getattr(self, "_loop", None): - return None + return EmptyTask() result = self._loop.create_task(coro) with self._task_mutex: self._task_list.append(result) @@ -1951,14 +1953,14 @@ def __init__( else: super().__init__(endpoint, project, launch_uuid=launch_uuid, **kwargs) - def create_task(self, coro: Coroutine[Any, Any, _T]) -> Optional[Task[_T]]: + def create_task(self, coro: Coroutine[Any, Any, _T]) -> Task[_T]: """Create a Task from given Coroutine. :param coro: Coroutine which will be used for the Task creation. :return: Task instance. """ if not getattr(self, "_loop", None): - return None + return EmptyTask() result = self._loop.create_task(coro) with self._task_mutex: tasks = self._task_list.append(result) diff --git a/reportportal_client/aio/tasks.py b/reportportal_client/aio/tasks.py index 4989537..b9913ea 100644 --- a/reportportal_client/aio/tasks.py +++ b/reportportal_client/aio/tasks.py @@ -55,7 +55,7 @@ def __init__( self, coro: _TaskCompatibleCoro, *, - loop: asyncio.AbstractEventLoop, + loop: Optional[asyncio.AbstractEventLoop], name: Optional[str] = None, ) -> None: """Initialize an instance of the Task. @@ -92,3 +92,26 @@ def __str__(self): if self.done(): return str(self.result()) return super().__str__() + + +class EmptyTask(Task[None]): + """Task implementation which always returns None.""" + + @staticmethod + async def __empty_coro() -> None: + return None + + def __init__(self) -> None: + """Initialize an EmptyTask. + + The class provides a no-op coroutine because ``asyncio.Task`` requires a non-None coroutine object. + """ + super().__init__(self.__empty_coro(), loop=None) + + def blocking_result(self) -> None: + """Return None without blocking.""" + return None + + def result(self) -> None: + """Return None regardless of the task state.""" + return None diff --git a/reportportal_client/helpers/common_helpers.py b/reportportal_client/helpers/common_helpers.py index bdcb679..97f3e84 100644 --- a/reportportal_client/helpers/common_helpers.py +++ b/reportportal_client/helpers/common_helpers.py @@ -23,8 +23,9 @@ from datetime import datetime, timezone from platform import machine, processor, system from types import MappingProxyType -from typing import Any, Callable, Generic, Iterable, Optional, Sized, TypeVar, Union +from typing import Any, Callable, Coroutine, Generic, Iterable, Optional, Sized, TypeVar, Union +from reportportal_client.aio import Task from reportportal_client.core.rp_file import RPFile try: @@ -401,7 +402,7 @@ def agent_name_version(attributes: Optional[Union[list, dict]] = None) -> tuple[ return agent_name, agent_version -async def await_if_necessary(obj: Optional[Any]) -> Optional[Any]: +async def await_if_necessary(obj: Union[_T, Task[_T], Coroutine[_T, None, None]]) -> Optional[_T]: """Await Coroutine, Feature or coroutine Function if given argument is one of them, or return immediately. :param obj: value, Coroutine, Feature or coroutine Function From 3c403f093e871aba7511992a20ed2bd2c69d1ffa Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Thu, 9 Apr 2026 11:26:12 +0300 Subject: [PATCH 07/12] A try to fix imports --- reportportal_client/aio/__init__.py | 20 +++++++++++++++++++ reportportal_client/aio/client.py | 3 +-- reportportal_client/core/rp_requests.py | 3 ++- reportportal_client/helpers/__init__.py | 2 -- reportportal_client/helpers/common_helpers.py | 18 +---------------- 5 files changed, 24 insertions(+), 22 deletions(-) diff --git a/reportportal_client/aio/__init__.py b/reportportal_client/aio/__init__.py index 4646052..0a08359 100644 --- a/reportportal_client/aio/__init__.py +++ b/reportportal_client/aio/__init__.py @@ -12,6 +12,8 @@ # limitations under the License """Common package for Asynchronous I/O clients and utilities.""" +import asyncio +from typing import Coroutine, Optional, TypeVar, Union from reportportal_client.aio.client import ( DEFAULT_SHUTDOWN_TIMEOUT, @@ -24,7 +26,25 @@ ) from reportportal_client.aio.tasks import BlockingOperationError, EmptyTask, Task +_T = TypeVar("_T") + + +async def await_if_necessary(obj: Union[_T, Task[_T], Coroutine[_T, None, None]]) -> Optional[_T]: + """Await Coroutine, Feature or coroutine Function if given argument is one of them, or return immediately. + + :param obj: value, Coroutine, Feature or coroutine Function + :return: result which was returned by Coroutine, Feature or coroutine Function + """ + if obj: + if asyncio.isfuture(obj) or asyncio.iscoroutine(obj): + return await obj + elif asyncio.iscoroutinefunction(obj): + return await obj() + return obj + + __all__ = [ + "await_if_necessary", "Task", "EmptyTask", "BlockingOperationError", diff --git a/reportportal_client/aio/client.py b/reportportal_client/aio/client.py index 6052544..fd83a35 100644 --- a/reportportal_client/aio/client.py +++ b/reportportal_client/aio/client.py @@ -56,7 +56,7 @@ # noinspection PyProtectedMember from reportportal_client._internal.static.abstract import AbstractBaseClass, abstractmethod from reportportal_client._internal.static.defines import DEFAULT_LOG_LEVEL -from reportportal_client.aio import EmptyTask +from reportportal_client.aio import EmptyTask, await_if_necessary # noinspection PyProtectedMember from reportportal_client.aio.tasks import Task @@ -81,7 +81,6 @@ LAUNCH_NAME_LENGTH_LIMIT, LifoQueue, agent_name_version, - await_if_necessary, root_uri_join, uri_join, ) diff --git a/reportportal_client/core/rp_requests.py b/reportportal_client/core/rp_requests.py index 8b5ada0..fa8f395 100644 --- a/reportportal_client/core/rp_requests.py +++ b/reportportal_client/core/rp_requests.py @@ -37,10 +37,11 @@ # noinspection PyProtectedMember from reportportal_client._internal.static.defines import DEFAULT_LOG_LEVEL, DEFAULT_PRIORITY, LOW_PRIORITY, Priority +from reportportal_client.aio import await_if_necessary from reportportal_client.core.rp_file import RPFile from reportportal_client.core.rp_issues import Issue from reportportal_client.core.rp_responses import AsyncRPResponse, RPResponse -from reportportal_client.helpers import await_if_necessary, dict_to_payload +from reportportal_client.helpers import dict_to_payload from reportportal_client.helpers.common_helpers import clean_binary_characters, verify_value_length try: diff --git a/reportportal_client/helpers/__init__.py b/reportportal_client/helpers/__init__.py index c694b2f..6168fae 100644 --- a/reportportal_client/helpers/__init__.py +++ b/reportportal_client/helpers/__init__.py @@ -14,7 +14,6 @@ TYPICAL_MULTIPART_FOOTER_LENGTH, LifoQueue, agent_name_version, - await_if_necessary, calculate_file_part_size, calculate_json_part_size, caseless_equal, @@ -53,7 +52,6 @@ "TYPICAL_FILE_PART_HEADER", "LifoQueue", "agent_name_version", - "await_if_necessary", "calculate_file_part_size", "calculate_json_part_size", "caseless_equal", diff --git a/reportportal_client/helpers/common_helpers.py b/reportportal_client/helpers/common_helpers.py index 97f3e84..7ee9067 100644 --- a/reportportal_client/helpers/common_helpers.py +++ b/reportportal_client/helpers/common_helpers.py @@ -13,7 +13,6 @@ """This module contains common functions-helpers of the client and agents.""" -import asyncio import fnmatch import inspect import re @@ -23,9 +22,8 @@ from datetime import datetime, timezone from platform import machine, processor, system from types import MappingProxyType -from typing import Any, Callable, Coroutine, Generic, Iterable, Optional, Sized, TypeVar, Union +from typing import Any, Callable, Generic, Iterable, Optional, Sized, TypeVar, Union -from reportportal_client.aio import Task from reportportal_client.core.rp_file import RPFile try: @@ -402,20 +400,6 @@ def agent_name_version(attributes: Optional[Union[list, dict]] = None) -> tuple[ return agent_name, agent_version -async def await_if_necessary(obj: Union[_T, Task[_T], Coroutine[_T, None, None]]) -> Optional[_T]: - """Await Coroutine, Feature or coroutine Function if given argument is one of them, or return immediately. - - :param obj: value, Coroutine, Feature or coroutine Function - :return: result which was returned by Coroutine, Feature or coroutine Function - """ - if obj: - if asyncio.isfuture(obj) or asyncio.iscoroutine(obj): - return await obj - elif asyncio.iscoroutinefunction(obj): - return await obj() - return obj - - def is_binary(iterable: Union[bytes, bytearray, str]) -> bool: """Check if given iterable is binary. From 8ee41006b2b08d40cf33dde9f971b1e1b31aad3f Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Thu, 9 Apr 2026 11:35:16 +0300 Subject: [PATCH 08/12] A try to fix imports --- reportportal_client/aio/__init__.py | 20 --------------- reportportal_client/aio/client.py | 4 +-- reportportal_client/aio/util.py | 34 +++++++++++++++++++++++++ reportportal_client/core/rp_requests.py | 2 +- 4 files changed, 37 insertions(+), 23 deletions(-) create mode 100644 reportportal_client/aio/util.py diff --git a/reportportal_client/aio/__init__.py b/reportportal_client/aio/__init__.py index 0a08359..4646052 100644 --- a/reportportal_client/aio/__init__.py +++ b/reportportal_client/aio/__init__.py @@ -12,8 +12,6 @@ # limitations under the License """Common package for Asynchronous I/O clients and utilities.""" -import asyncio -from typing import Coroutine, Optional, TypeVar, Union from reportportal_client.aio.client import ( DEFAULT_SHUTDOWN_TIMEOUT, @@ -26,25 +24,7 @@ ) from reportportal_client.aio.tasks import BlockingOperationError, EmptyTask, Task -_T = TypeVar("_T") - - -async def await_if_necessary(obj: Union[_T, Task[_T], Coroutine[_T, None, None]]) -> Optional[_T]: - """Await Coroutine, Feature or coroutine Function if given argument is one of them, or return immediately. - - :param obj: value, Coroutine, Feature or coroutine Function - :return: result which was returned by Coroutine, Feature or coroutine Function - """ - if obj: - if asyncio.isfuture(obj) or asyncio.iscoroutine(obj): - return await obj - elif asyncio.iscoroutinefunction(obj): - return await obj() - return obj - - __all__ = [ - "await_if_necessary", "Task", "EmptyTask", "BlockingOperationError", diff --git a/reportportal_client/aio/client.py b/reportportal_client/aio/client.py index fd83a35..1abd08e 100644 --- a/reportportal_client/aio/client.py +++ b/reportportal_client/aio/client.py @@ -56,10 +56,10 @@ # noinspection PyProtectedMember from reportportal_client._internal.static.abstract import AbstractBaseClass, abstractmethod from reportportal_client._internal.static.defines import DEFAULT_LOG_LEVEL -from reportportal_client.aio import EmptyTask, await_if_necessary # noinspection PyProtectedMember -from reportportal_client.aio.tasks import Task +from reportportal_client.aio.tasks import EmptyTask, Task +from reportportal_client.aio.util import await_if_necessary from reportportal_client.client import RP, OutputType from reportportal_client.core.rp_issues import Issue from reportportal_client.core.rp_requests import ( diff --git a/reportportal_client/aio/util.py b/reportportal_client/aio/util.py new file mode 100644 index 0000000..40bf74c --- /dev/null +++ b/reportportal_client/aio/util.py @@ -0,0 +1,34 @@ +# Copyright 2026 EPAM Systems +# +# 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 asyncio +from typing import Coroutine, Optional, TypeVar, Union + +from reportportal_client.aio.tasks import Task + +_T = TypeVar("_T") + + +async def await_if_necessary(obj: Union[_T, Task[_T], Coroutine[_T, None, None]]) -> Optional[_T]: + """Await Coroutine, Feature or coroutine Function if given argument is one of them, or return immediately. + + :param obj: value, Coroutine, Feature or coroutine Function + :return: result which was returned by Coroutine, Feature or coroutine Function + """ + if obj: + if asyncio.isfuture(obj) or asyncio.iscoroutine(obj): + return await obj + elif asyncio.iscoroutinefunction(obj): + return await obj() + return obj diff --git a/reportportal_client/core/rp_requests.py b/reportportal_client/core/rp_requests.py index fa8f395..8ede72a 100644 --- a/reportportal_client/core/rp_requests.py +++ b/reportportal_client/core/rp_requests.py @@ -37,7 +37,7 @@ # noinspection PyProtectedMember from reportportal_client._internal.static.defines import DEFAULT_LOG_LEVEL, DEFAULT_PRIORITY, LOW_PRIORITY, Priority -from reportportal_client.aio import await_if_necessary +from reportportal_client.aio.util import await_if_necessary from reportportal_client.core.rp_file import RPFile from reportportal_client.core.rp_issues import Issue from reportportal_client.core.rp_responses import AsyncRPResponse, RPResponse From 943c037fa8427dda76528acaec1b122c580dc6f0 Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Thu, 9 Apr 2026 11:41:30 +0300 Subject: [PATCH 09/12] Fix pydocs --- reportportal_client/aio/util.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/reportportal_client/aio/util.py b/reportportal_client/aio/util.py index 40bf74c..82d99a7 100644 --- a/reportportal_client/aio/util.py +++ b/reportportal_client/aio/util.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""This module contains auxiliary functions for async code.""" + import asyncio from typing import Coroutine, Optional, TypeVar, Union From bf9b6135d2978cd9eff4759d8aafe248bb6dec36 Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Fri, 10 Apr 2026 09:04:49 +0300 Subject: [PATCH 10/12] Add `use_microseconds` method --- reportportal_client/aio/client.py | 158 ++++++++++++++++-- reportportal_client/client.py | 78 ++++++++- reportportal_client/helpers/__init__.py | 2 + reportportal_client/helpers/common_helpers.py | 15 ++ tests/aio/test_async_client.py | 14 ++ tests/aio/test_batched_client.py | 10 ++ tests/aio/test_threaded_client.py | 10 ++ tests/test_client.py | 22 +++ 8 files changed, 290 insertions(+), 19 deletions(-) diff --git a/reportportal_client/aio/client.py b/reportportal_client/aio/client.py index 1abd08e..42590f2 100644 --- a/reportportal_client/aio/client.py +++ b/reportportal_client/aio/client.py @@ -23,7 +23,7 @@ import warnings from datetime import datetime from os import getenv -from typing import Any, Coroutine, Optional, TypeVar, Union +from typing import Any, Coroutine, Optional, TypeVar, Union, cast import aiohttp import certifi @@ -81,6 +81,8 @@ LAUNCH_NAME_LENGTH_LIMIT, LifoQueue, agent_name_version, + compare_semantic_versions, + extract_server_version, root_uri_join, uri_join, ) @@ -94,6 +96,7 @@ DEFAULT_TASK_TIMEOUT: float = 60.0 DEFAULT_SHUTDOWN_TIMEOUT: float = 120.0 +MICROSECONDS_MIN_VERSION = "5.13.2" class Client: @@ -785,6 +788,9 @@ class AsyncRPClient(RP): __launch_uuid: Optional[str] __step_reporter: StepReporter use_own_launch: bool + _api_info_task: Optional[asyncio.Task[Optional[dict]]] + _api_info_cache: Optional[dict] + _use_microseconds: Optional[bool] @property def client(self) -> Client: @@ -888,8 +894,35 @@ def __init__( self.use_own_launch = False else: self.use_own_launch = True + self._api_info_task = None + self._api_info_cache = None + self._use_microseconds = None set_current(self) + def __cache_api_info(self, api_info: Optional[dict]) -> Optional[dict]: + if not isinstance(api_info, dict): + return None + self._api_info_cache = api_info + version = extract_server_version(api_info) + self._use_microseconds = bool(version and compare_semantic_versions(version, MICROSECONDS_MIN_VERSION) >= 0) + return api_info + + async def __prefetch_api_info(self) -> Optional[dict]: + try: + api_info = await self.__client.get_api_info() + return self.__cache_api_info(api_info) + except Exception as exc: + logger.warning("Unable to prefetch API info in background: %s", exc) + return None + + def __init_api_info_prefetch(self) -> None: + try: + loop = asyncio.get_running_loop() + self._api_info_task = loop.create_task(self.__prefetch_api_info()) + except RuntimeError: + # Construction may happen without an active loop. + self._api_info_task = None + async def start_launch( self, name: str, @@ -1123,7 +1156,22 @@ async def get_api_info(self) -> Optional[dict]: :return: server information. """ - return await self.__client.get_api_info() + if self._api_info_cache is not None: + return self.__cache_api_info(self._api_info_cache) + if self._api_info_task: + return await self._api_info_task + api_info = await self.__client.get_api_info() + return self.__cache_api_info(api_info) + + async def use_microseconds(self) -> Optional[bool]: + """Return if current server version supports microseconds.""" + if self._use_microseconds is not None: + return self._use_microseconds + + await self.get_api_info() + if self._use_microseconds is None: + self._use_microseconds = False + return self._use_microseconds async def log( self, @@ -1205,6 +1253,9 @@ class _RPClient(RP, metaclass=AbstractBaseClass): __endpoint: str __project: str __step_reporter: StepReporter + _api_info_task: Optional[Task[Optional[dict]]] + _api_info_cache: Optional[dict] + _use_microseconds: Optional[bool] @property def client(self) -> Client: @@ -1252,7 +1303,7 @@ def __init__( project: str, *, client: Optional[Client] = None, - launch_uuid: Optional[Task[Optional[str]]] = None, + launch_uuid: Optional[Task[str]] = None, log_batch_size: int = 20, log_batch_payload_limit: int = MAX_LOG_BATCH_PAYLOAD_SIZE, log_batcher: Optional[LogBatcher] = None, @@ -1308,6 +1359,9 @@ def __init__( else: self.own_launch = True + self._api_info_task = None + self._api_info_cache = None + self._use_microseconds = None set_current(self) @abstractmethod @@ -1354,6 +1408,49 @@ async def __empty_dict(self) -> dict: async def __int_value(self) -> int: return -1 + async def _return_value(self, value: _T) -> _T: + return value + + async def _prefetch_api_info(self) -> Optional[dict]: + try: + api_info = await self.__client.get_api_info() + self.__cache_api_info(api_info) + return api_info + except Exception as exc: + logger.warning("Unable to prefetch API info in background: %s", exc) + return None + + def __cache_api_info(self, api_info: Optional[dict]) -> None: + if not isinstance(api_info, dict): + return + self._api_info_cache = api_info + version = extract_server_version(api_info) + self._use_microseconds = bool(version and compare_semantic_versions(version, MICROSECONDS_MIN_VERSION) >= 0) + + async def __resolve_use_microseconds(self) -> bool: + if self._use_microseconds is not None: + return self._use_microseconds + + if self._api_info_task: + try: + api_info = await self._api_info_task + self.__cache_api_info(api_info) + except Exception as exc: + logger.warning("Unable to await API info prefetch: %s", exc) + + if self._use_microseconds is not None: + return self._use_microseconds or False + + if self._api_info_cache is None: + try: + self.__cache_api_info(await self.__client.get_api_info()) + except Exception as exc: + logger.warning("Unable to fetch API info for microseconds check: %s", exc) + + if self._use_microseconds is None: + self._use_microseconds = False + return self._use_microseconds or False + def start_launch( self, name: str, @@ -1586,9 +1683,19 @@ def get_api_info(self) -> Task[Optional[dict]]: :return: server information. """ - result_coro = self.__client.get_api_info() - result_task = self.create_task(result_coro) - return result_task + if self._api_info_cache is not None: + return self.create_task(self._return_value(self._api_info_cache)) + if self._api_info_task: + return self._api_info_task + api_task = self.create_task(self._prefetch_api_info()) + self._api_info_task = api_task + return api_task + + def use_microseconds(self) -> Task[bool]: + """Return if current server version supports microseconds.""" + if self._use_microseconds is not None: + return self.create_task(self._return_value(self._use_microseconds)) + return self.create_task(self.__resolve_use_microseconds()) async def _log_batch(self, log_rq: Optional[list[AsyncRPRequestLog]]) -> Optional[tuple[str, ...]]: return await self.__client.log_batch(log_rq) @@ -1689,9 +1796,6 @@ def __init_loop(self, loop: Optional[asyncio.AbstractEventLoop] = None): thread.start() self._thread = thread - async def __return_value(self, value): - return value - def __init__( self, endpoint: str, @@ -1753,11 +1857,21 @@ def __init__( self.__init_task_list(task_list, task_mutex) self.__init_loop(loop) if type(launch_uuid) is str: + my_launch_uuid = str(launch_uuid) super().__init__( - endpoint, project, launch_uuid=self.create_task(self.__return_value(launch_uuid)), **kwargs + endpoint, project, launch_uuid=self.create_task(self._return_value(my_launch_uuid)), **kwargs ) else: - super().__init__(endpoint, project, launch_uuid=launch_uuid, **kwargs) + my_launch_uuid_task = cast(Task[str], launch_uuid) + super().__init__(endpoint, project, launch_uuid=my_launch_uuid_task, **kwargs) + self.__init_api_info_prefetch() + + def __init_api_info_prefetch(self) -> None: + if self._use_microseconds is not None or self._api_info_cache is not None: + return + if self._loop is None: + return + self._api_info_task = self._loop.create_task(self._prefetch_api_info()) def create_task(self, coro: Coroutine[Any, Any, _T]) -> Task[_T]: """Create a Task from given Coroutine. @@ -1765,7 +1879,7 @@ def create_task(self, coro: Coroutine[Any, Any, _T]) -> Task[_T]: :param coro: Coroutine which will be used for the Task creation. :return: Task instance. """ - if not getattr(self, "_loop", None): + if self._loop is None: return EmptyTask() result = self._loop.create_task(coro) with self._task_mutex: @@ -1825,6 +1939,7 @@ def __getstate__(self) -> dict[str, Any]: del state["_task_mutex"] del state["_loop"] del state["_thread"] + del state["_api_info_task"] return state def __setstate__(self, state: dict[str, Any]) -> None: @@ -1835,6 +1950,8 @@ def __setstate__(self, state: dict[str, Any]) -> None: self.__dict__.update(state) self.__init_task_list(self._task_list, threading.RLock()) self.__init_loop() + self._api_info_task = None + self.__init_api_info_prefetch() class BatchedRPClient(_RPClient): @@ -1875,9 +1992,6 @@ def __init_loop(self, loop: Optional[asyncio.AbstractEventLoop] = None): self._loop = asyncio.new_event_loop() self._loop.set_task_factory(BatchedTaskFactory()) - async def __return_value(self, value): - return value - def __init__( self, endpoint: str, @@ -1946,11 +2060,18 @@ def __init__( self.__last_run_time = time.time() self.__init_loop(loop) if type(launch_uuid) is str: + my_launch_uuid = str(launch_uuid) super().__init__( - endpoint, project, launch_uuid=self.create_task(self.__return_value(launch_uuid)), **kwargs + endpoint, project, launch_uuid=self.create_task(self._return_value(my_launch_uuid)), **kwargs ) else: - super().__init__(endpoint, project, launch_uuid=launch_uuid, **kwargs) + my_launch_uuid_task = cast(Task[str], launch_uuid) + super().__init__(endpoint, project, launch_uuid=my_launch_uuid_task, **kwargs) + self.__init_api_info_prefetch() + + def __init_api_info_prefetch(self) -> None: + # Batched client loop runs on demand, so prefetch starts lazily. + self._api_info_task = None def create_task(self, coro: Coroutine[Any, Any, _T]) -> Task[_T]: """Create a Task from given Coroutine. @@ -2016,6 +2137,7 @@ def __getstate__(self) -> dict[str, Any]: # Don't pickle 'session' field, since it contains unpickling 'socket' del state["_task_mutex"] del state["_loop"] + del state["_api_info_task"] return state def __setstate__(self, state: dict[str, Any]) -> None: @@ -2026,3 +2148,5 @@ def __setstate__(self, state: dict[str, Any]) -> None: self.__dict__.update(state) self.__init_task_list(self._task_list, threading.RLock()) self.__init_loop() + self._api_info_task = None + self.__init_api_info_prefetch() diff --git a/reportportal_client/client.py b/reportportal_client/client.py index e705e29..9e39405 100644 --- a/reportportal_client/client.py +++ b/reportportal_client/client.py @@ -17,6 +17,7 @@ import logging import queue import sys +import threading import warnings from abc import abstractmethod from datetime import datetime @@ -56,7 +57,13 @@ RPLogBatch, RPRequestLog, ) -from reportportal_client.helpers import LifoQueue, agent_name_version, uri_join +from reportportal_client.helpers import ( + LifoQueue, + agent_name_version, + compare_semantic_versions, + extract_server_version, + uri_join, +) from reportportal_client.helpers.common_helpers import ( ITEM_DESCRIPTION_LENGTH_LIMIT, ITEM_NAME_LENGTH_LIMIT, @@ -69,6 +76,8 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) +MICROSECONDS_MIN_VERSION = "5.13.2" + class OutputType(Enum): """Enum of possible print output types.""" @@ -144,6 +153,14 @@ def step_reporter(self) -> StepReporter: """ raise NotImplementedError('"step_reporter" property is not implemented!') + @abstractmethod + def use_microseconds(self) -> Optional[bool]: + """Return if current server version supports microseconds. + + :return: True if current server version supports microseconds. + """ + raise NotImplementedError('"use_microseconds" method is not implemented!') + @abstractmethod def start_launch( self, @@ -435,6 +452,10 @@ class RPClient(RP): _skip_analytics: Optional[str] _item_stack: LifoQueue _log_batcher: LogBatcher[RPRequestLog] + _api_info_cache: Optional[dict] + _use_microseconds: Optional[bool] + _api_info_lock: threading.Lock + _api_info_prefetched: threading.Event @property def launch_uuid(self) -> Optional[str]: @@ -587,6 +608,10 @@ def __init__( self.item_name_length_limit = item_name_length_limit self.launch_description_length_limit = launch_description_length_limit self.item_description_length_limit = item_description_length_limit + self._api_info_cache = None + self._use_microseconds = None + self._api_info_lock = threading.Lock() + self._api_info_prefetched = threading.Event() self.api_key = api_key # Handle deprecated token argument @@ -642,6 +667,26 @@ def __init__( ) self.__init_session() + self.__init_api_info_prefetch() + + def __cache_api_info(self, api_info: Optional[dict]) -> None: + if api_info is None: + return + with self._api_info_lock: + self._api_info_cache = api_info + version = extract_server_version(api_info) + self._use_microseconds = bool( + version and compare_semantic_versions(version, MICROSECONDS_MIN_VERSION) >= 0 + ) + + def __prefetch_api_info(self) -> None: + try: + self.get_api_info() + finally: + self._api_info_prefetched.set() + + def __init_api_info_prefetch(self) -> None: + threading.Thread(target=self.__prefetch_api_info, daemon=True, name="RP-API-Info-Prefetch").start() def start_launch( self, @@ -1076,7 +1121,28 @@ def get_api_info(self) -> Optional[dict]: http_timeout=self.http_timeout, name="get_api_info", ).make() - return response.json if response else None + api_info = response.json if response else None + self.__cache_api_info(api_info) + return api_info + + def use_microseconds(self) -> Optional[bool]: + """Return if current server version supports microseconds.""" + if self._use_microseconds is not None: + return self._use_microseconds + + if not self._api_info_prefetched.is_set(): + self._api_info_prefetched.wait(timeout=10.0) + + if self._use_microseconds is not None: + return self._use_microseconds + + if self._api_info_cache is not None: + self.__cache_api_info(self._api_info_cache) + else: + self.get_api_info() + if self._use_microseconds is None: + self._use_microseconds = False + return self._use_microseconds def _add_current_item(self, item: str) -> None: """Add the last item from the self._items queue.""" @@ -1152,6 +1218,8 @@ def __getstate__(self) -> dict[str, Any]: state = self.__dict__.copy() # Don't pickle 'session' field, since it contains unpickling 'socket' del state["session"] + del state["_api_info_lock"] + del state["_api_info_prefetched"] return state def __setstate__(self, state: dict[str, Any]) -> None: @@ -1162,3 +1230,9 @@ def __setstate__(self, state: dict[str, Any]) -> None: self.__dict__.update(state) # Restore 'session' field self.__init_session() + self._api_info_lock = threading.Lock() + self._api_info_prefetched = threading.Event() + if self._use_microseconds is not None or self._api_info_cache is not None: + self._api_info_prefetched.set() + else: + self.__init_api_info_prefetch() diff --git a/reportportal_client/helpers/__init__.py b/reportportal_client/helpers/__init__.py index 6168fae..32ccc69 100644 --- a/reportportal_client/helpers/__init__.py +++ b/reportportal_client/helpers/__init__.py @@ -19,6 +19,7 @@ caseless_equal, compare_semantic_versions, dict_to_payload, + extract_server_version, gen_attributes, generate_uuid, get_function_params, @@ -57,6 +58,7 @@ "caseless_equal", "compare_semantic_versions", "dict_to_payload", + "extract_server_version", "gen_attributes", "generate_uuid", "get_function_params", diff --git a/reportportal_client/helpers/common_helpers.py b/reportportal_client/helpers/common_helpers.py index 7ee9067..a7bdd29 100644 --- a/reportportal_client/helpers/common_helpers.py +++ b/reportportal_client/helpers/common_helpers.py @@ -596,6 +596,21 @@ def compare_semantic_versions(compared: str, basic: str) -> int: return _compare_pre_release(compared_pre_release, basic_pre_release) +def extract_server_version(api_info: Optional[dict]) -> Optional[str]: + """Extract server version from API info payload.""" + if not api_info: + return None + build_info = api_info.get("build") + if isinstance(build_info, dict): + build_version = build_info.get("version") + if isinstance(build_version, str): + return build_version + version = api_info.get("version") + if isinstance(version, str): + return version + return None + + def _normalize_version(version: str) -> str: normalized_version = version.strip() if normalized_version.startswith("v") or normalized_version.startswith("V"): diff --git a/tests/aio/test_async_client.py b/tests/aio/test_async_client.py index ab023a1..56fd7c4 100644 --- a/tests/aio/test_async_client.py +++ b/tests/aio/test_async_client.py @@ -206,3 +206,17 @@ async def test_get_api_info(async_client: AsyncRPClient): assert result == expected_info client.get_api_info.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_use_microseconds_cached(async_client: AsyncRPClient): + # noinspection PyTypeChecker + client: mock.AsyncMock = async_client.client + async_client._api_info_task = None + async_client._api_info_cache = {"build": {"version": "5.13.2"}} + async_client._use_microseconds = None + client.get_api_info = mock.AsyncMock(return_value={"build": {"version": "5.1.0"}}) + + assert await async_client.use_microseconds() is True + assert await async_client.use_microseconds() is True + client.get_api_info.assert_not_called() diff --git a/tests/aio/test_batched_client.py b/tests/aio/test_batched_client.py index 03f7721..173363d 100644 --- a/tests/aio/test_batched_client.py +++ b/tests/aio/test_batched_client.py @@ -179,3 +179,13 @@ def test_get_api_info(): assert result == expected_info aio_client.get_api_info.assert_called_once_with() + + +def test_use_microseconds_cached(): + aio_client = mock.AsyncMock() + aio_client.get_api_info.return_value = {"build": {"version": "5.13.2"}} + client = BatchedRPClient("http://endpoint", "project", api_key="api_key", client=aio_client) + + assert client.use_microseconds().blocking_result() is True + assert client.use_microseconds().blocking_result() is True + aio_client.get_api_info.assert_called_once_with() diff --git a/tests/aio/test_threaded_client.py b/tests/aio/test_threaded_client.py index 28f6d28..e61e2bd 100644 --- a/tests/aio/test_threaded_client.py +++ b/tests/aio/test_threaded_client.py @@ -177,3 +177,13 @@ def test_get_api_info(): assert result == expected_info aio_client.get_api_info.assert_called_once_with() + + +def test_use_microseconds_cached(): + aio_client = mock.AsyncMock() + aio_client.get_api_info.return_value = {"build": {"version": "5.13.2"}} + client = ThreadedRPClient("http://endpoint", "project", api_key="api_key", client=aio_client) + + assert client.use_microseconds().blocking_result() is True + assert client.use_microseconds().blocking_result() is True + aio_client.get_api_info.assert_called_once_with() diff --git a/tests/test_client.py b/tests/test_client.py index d2ec660..89625aa 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -357,6 +357,28 @@ def test_get_api_info_url(rp_client: RPClient): assert request_args[0] == "http://endpoint/api/info" +def test_use_microseconds_cached(rp_client: RPClient): + rp_client._api_info_prefetched.set() + rp_client._api_info_cache = {"build": {"version": "5.13.2"}} + rp_client._use_microseconds = None + rp_client.get_api_info = mock.Mock(return_value={"build": {"version": "5.1.0"}}) + + assert rp_client.use_microseconds() is True + assert rp_client.use_microseconds() is True + rp_client.get_api_info.assert_not_called() + + +def test_use_microseconds_default_false(rp_client: RPClient): + rp_client._api_info_prefetched.set() + rp_client._api_info_cache = None + rp_client._use_microseconds = None + rp_client.get_api_info = mock.Mock(return_value=None) + + assert rp_client.use_microseconds() is False + assert rp_client.use_microseconds() is False + rp_client.get_api_info.assert_called_once_with() + + def test_oauth_authentication_parameters(): """Test that OAuth 2.0 authentication parameters work correctly.""" client = RPClient( From 7e943b35d6763ac5618dca0deb39de3470ae4ef4 Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Fri, 10 Apr 2026 12:33:33 +0300 Subject: [PATCH 11/12] Add time conversion --- reportportal_client/aio/client.py | 54 +++++++++++++++++++++++-------- reportportal_client/client.py | 25 ++++++++++---- tests/aio/test_async_client.py | 20 ++++++++++++ tests/aio/test_batched_client.py | 23 +++++++++++++ tests/aio/test_threaded_client.py | 23 +++++++++++++ tests/test_client.py | 19 +++++++++++ 6 files changed, 144 insertions(+), 20 deletions(-) diff --git a/reportportal_client/aio/client.py b/reportportal_client/aio/client.py index 42590f2..f76c7bf 100644 --- a/reportportal_client/aio/client.py +++ b/reportportal_client/aio/client.py @@ -353,7 +353,7 @@ async def __get_launch_url(self, launch_uuid_future: Union[Optional[str], Task[O async def start_launch( self, name: str, - start_time: Union[str, datetime], + start_time: str, *, description: Optional[str] = None, attributes: Optional[Union[list, dict]] = None, @@ -406,7 +406,7 @@ async def start_test_item( self, launch_uuid: Union[str, Task[str]], name: str, - start_time: Union[str, datetime], + start_time: str, item_type: str, *, parent_item_id: Optional[Union[str, Task[str]]] = None, @@ -482,7 +482,7 @@ async def finish_test_item( self, launch_uuid: Union[str, Task[str]], item_id: Union[str, Task[str]], - end_time: Union[str, datetime], + end_time: str, *, status: Optional[str] = None, description: Optional[str] = None, @@ -539,7 +539,7 @@ async def finish_test_item( async def finish_launch( self, launch_uuid: Union[str, Task[str]], - end_time: Union[str, datetime], + end_time: str, *, status: Optional[str] = None, attributes: Optional[Union[list, dict]] = None, @@ -947,7 +947,13 @@ async def start_launch( if not self.use_own_launch: return self.launch_uuid launch_uuid = await self.__client.start_launch( - name, start_time, description=description, attributes=attributes, rerun=rerun, rerun_of=rerun_of, **kwargs + name, + await self._convert_time(start_time), + description=description, + attributes=attributes, + rerun=rerun, + rerun_of=rerun_of, + **kwargs, ) self.__launch_uuid = launch_uuid return self.launch_uuid @@ -993,7 +999,7 @@ async def start_test_item( item_id = await self.__client.start_test_item( self.__launch_uuid, name, - start_time, + await self._convert_time(start_time), item_type, description=description, attributes=attributes, @@ -1045,7 +1051,7 @@ async def finish_test_item( result = await self.__client.finish_test_item( self.__launch_uuid, item_id, - end_time, + await self._convert_time(end_time), status=status, issue=issue, attributes=attributes, @@ -1075,7 +1081,7 @@ async def finish_launch( """ if self.use_own_launch: result = await self.__client.finish_launch( - self.__launch_uuid, end_time, status=status, attributes=attributes, **kwargs + self.__launch_uuid, await self._convert_time(end_time), status=status, attributes=attributes, **kwargs ) else: result = "" @@ -1173,6 +1179,13 @@ async def use_microseconds(self) -> Optional[bool]: self._use_microseconds = False return self._use_microseconds + async def _convert_time(self, time_value: Union[str, datetime]) -> str: + if isinstance(time_value, str): + return time_value + if await self.use_microseconds(): + return time_value.strftime("%Y-%m-%dT%H:%M:%S.%f%z") + return str(int(time_value.timestamp() * 1000)) + async def log( self, time: Union[str, datetime], @@ -1200,7 +1213,7 @@ async def log( truncate_fields_enabled=None, replace_binary_characters=None, launch_uuid=self.__launch_uuid, - time=time, + time=await self._convert_time(time), file=rp_file, item_uuid=item_id, level=rp_level, @@ -1475,7 +1488,13 @@ def start_launch( if not self.own_launch: return self.launch_uuid launch_uuid_coro = self.__client.start_launch( - name, start_time, description=description, attributes=attributes, rerun=rerun, rerun_of=rerun_of, **kwargs + name, + self._convert_time(start_time), + description=description, + attributes=attributes, + rerun=rerun, + rerun_of=rerun_of, + **kwargs, ) self.__launch_uuid = self.create_task(launch_uuid_coro) return self.launch_uuid @@ -1521,7 +1540,7 @@ def start_test_item( item_id_coro = self.__client.start_test_item( self.launch_uuid, name, - start_time, + self._convert_time(start_time), item_type, description=description, attributes=attributes, @@ -1571,7 +1590,7 @@ def finish_test_item( result_coro = self.__client.finish_test_item( self.launch_uuid, item_id, - end_time, + self._convert_time(end_time), status=status, issue=issue, attributes=attributes, @@ -1603,7 +1622,7 @@ def finish_launch( self.create_task(self.__client.log_batch(self._log_batcher.flush())) if self.own_launch: result_coro = self.__client.finish_launch( - self.launch_uuid, end_time, status=status, attributes=attributes, **kwargs + self.launch_uuid, self._convert_time(end_time), status=status, attributes=attributes, **kwargs ) else: result_coro = self.__empty_str() @@ -1697,6 +1716,13 @@ def use_microseconds(self) -> Task[bool]: return self.create_task(self._return_value(self._use_microseconds)) return self.create_task(self.__resolve_use_microseconds()) + def _convert_time(self, time_value: Union[str, datetime]) -> str: + if isinstance(time_value, str): + return time_value + if self.use_microseconds().blocking_result(): + return time_value.strftime("%Y-%m-%dT%H:%M:%S.%f%z") + return str(int(time_value.timestamp() * 1000)) + async def _log_batch(self, log_rq: Optional[list[AsyncRPRequestLog]]) -> Optional[tuple[str, ...]]: return await self.__client.log_batch(log_rq) @@ -1730,7 +1756,7 @@ def log( truncate_fields_enabled=None, replace_binary_characters=None, launch_uuid=self.launch_uuid, - time=time, + time=self._convert_time(time), file=rp_file, item_uuid=item_id, level=rp_level, diff --git a/reportportal_client/client.py b/reportportal_client/client.py index 9e39405..9fc5b92 100644 --- a/reportportal_client/client.py +++ b/reportportal_client/client.py @@ -161,6 +161,11 @@ def use_microseconds(self) -> Optional[bool]: """ raise NotImplementedError('"use_microseconds" method is not implemented!') + @abstractmethod + def _convert_time(self, time: Union[str, datetime]) -> str: + """Convert time to the format expected by ReportPortal.""" + raise NotImplementedError('"convert_time" method is not implemented!') + @abstractmethod def start_launch( self, @@ -714,7 +719,7 @@ def start_launch( url = uri_join(self.base_url_v2, "launch") request_payload = LaunchStartRequest( name=name, - start_time=start_time, + start_time=self._convert_time(start_time), attributes=attributes, truncate_attributes_enabled=self.truncate_attributes, truncate_fields_enabled=self.truncate_fields, @@ -788,7 +793,7 @@ def start_test_item( url = uri_join(self.base_url_v2, "item") request_payload = ItemStartRequest( name=name, - start_time=start_time, + start_time=self._convert_time(start_time), type_=item_type, launch_uuid=self.__launch_uuid, attributes=attributes, @@ -857,7 +862,7 @@ def finish_test_item( return None url = uri_join(self.base_url_v2, "item", item_id) request_payload = ItemFinishRequest( - end_time=end_time, + end_time=self._convert_time(end_time), launch_uuid=self.__launch_uuid, status=status, attributes=attributes, @@ -906,7 +911,7 @@ def finish_launch( return None url = uri_join(self.base_url_v2, "launch", self.__launch_uuid, "finish") request_payload = LaunchFinishRequest( - end_time=end_time, + end_time=self._convert_time(end_time), status=status, attributes=attributes, truncate_attributes_enabled=self.truncate_attributes, @@ -1014,7 +1019,7 @@ def log( truncate_fields_enabled=None, replace_binary_characters=None, launch_uuid=self.__launch_uuid, - time=time, + time=self._convert_time(time), file=rp_file, item_uuid=item_id, level=str(level), @@ -1084,7 +1089,7 @@ def get_launch_ui_url(self) -> Optional[str]: if not mode: mode = self.mode - launch_type = "launches" if mode.upper() == "DEFAULT" else "userdebug" + launch_type = "launches" if str(mode).upper() == "DEFAULT" else "userdebug" path = "ui/#{project_name}/{launch_type}/all/{launch_id}".format( project_name=self.__project.lower(), launch_type=launch_type, launch_id=ui_id @@ -1144,6 +1149,14 @@ def use_microseconds(self) -> Optional[bool]: self._use_microseconds = False return self._use_microseconds + def _convert_time(self, time: Union[str, datetime]) -> str: + """Convert time to the format expected by ReportPortal.""" + if isinstance(time, str): + return time + if self.use_microseconds(): + return time.strftime("%Y-%m-%dT%H:%M:%S.%f%z") + return str(int(time.timestamp() * 1000)) + def _add_current_item(self, item: str) -> None: """Add the last item from the self._items queue.""" self._item_stack.put(item) diff --git a/tests/aio/test_async_client.py b/tests/aio/test_async_client.py index 56fd7c4..55f85f1 100644 --- a/tests/aio/test_async_client.py +++ b/tests/aio/test_async_client.py @@ -12,6 +12,7 @@ # limitations under the License import pickle +from datetime import datetime, timezone from unittest import mock # noinspection PyPackageRequirements @@ -220,3 +221,22 @@ async def test_use_microseconds_cached(async_client: AsyncRPClient): assert await async_client.use_microseconds() is True assert await async_client.use_microseconds() is True client.get_api_info.assert_not_called() + + +@pytest.mark.parametrize( + "time_value, microseconds_enabled, expected_result", + [ + ("1712700812345", True, "1712700812345"), + (datetime(2024, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc), True, "2024-01-02T03:04:05.678901+0000"), + ( + datetime(2024, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc), + False, + str(int(datetime(2024, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc).timestamp() * 1000)), + ), + ], +) +@pytest.mark.asyncio +async def test_convert_time(async_client: AsyncRPClient, time_value, microseconds_enabled, expected_result): + async_client.use_microseconds = mock.AsyncMock(return_value=microseconds_enabled) + + assert await async_client._convert_time(time_value) == expected_result diff --git a/tests/aio/test_batched_client.py b/tests/aio/test_batched_client.py index 173363d..48343ff 100644 --- a/tests/aio/test_batched_client.py +++ b/tests/aio/test_batched_client.py @@ -12,6 +12,7 @@ # limitations under the License import pickle +from datetime import datetime, timezone from unittest import mock # noinspection PyPackageRequirements @@ -189,3 +190,25 @@ def test_use_microseconds_cached(): assert client.use_microseconds().blocking_result() is True assert client.use_microseconds().blocking_result() is True aio_client.get_api_info.assert_called_once_with() + + +@pytest.mark.parametrize( + "time_value, microseconds_enabled, expected_result", + [ + ("1712700812345", True, "1712700812345"), + (datetime(2024, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc), True, "2024-01-02T03:04:05.678901+0000"), + ( + datetime(2024, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc), + False, + str(int(datetime(2024, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc).timestamp() * 1000)), + ), + ], +) +def test_convert_time(time_value, microseconds_enabled, expected_result): + aio_client = mock.AsyncMock() + client = BatchedRPClient("http://endpoint", "project", api_key="api_key", client=aio_client) + microseconds_task = mock.Mock() + microseconds_task.blocking_result.return_value = microseconds_enabled + client.use_microseconds = mock.Mock(return_value=microseconds_task) + + assert client._convert_time(time_value) == expected_result diff --git a/tests/aio/test_threaded_client.py b/tests/aio/test_threaded_client.py index e61e2bd..aedeea8 100644 --- a/tests/aio/test_threaded_client.py +++ b/tests/aio/test_threaded_client.py @@ -13,6 +13,7 @@ import pickle import time +from datetime import datetime, timezone from unittest import mock # noinspection PyPackageRequirements @@ -187,3 +188,25 @@ def test_use_microseconds_cached(): assert client.use_microseconds().blocking_result() is True assert client.use_microseconds().blocking_result() is True aio_client.get_api_info.assert_called_once_with() + + +@pytest.mark.parametrize( + "time_value, microseconds_enabled, expected_result", + [ + ("1712700812345", True, "1712700812345"), + (datetime(2024, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc), True, "2024-01-02T03:04:05.678901+0000"), + ( + datetime(2024, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc), + False, + str(int(datetime(2024, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc).timestamp() * 1000)), + ), + ], +) +def test_convert_time(time_value, microseconds_enabled, expected_result): + aio_client = mock.AsyncMock() + client = ThreadedRPClient("http://endpoint", "project", api_key="api_key", client=aio_client) + microseconds_task = mock.Mock() + microseconds_task.blocking_result.return_value = microseconds_enabled + client.use_microseconds = mock.Mock(return_value=microseconds_task) + + assert client._convert_time(time_value) == expected_result diff --git a/tests/test_client.py b/tests/test_client.py index 89625aa..035faaa 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -12,6 +12,7 @@ # limitations under the License import pickle +from datetime import datetime, timezone from io import StringIO from unittest import mock @@ -379,6 +380,24 @@ def test_use_microseconds_default_false(rp_client: RPClient): rp_client.get_api_info.assert_called_once_with() +@pytest.mark.parametrize( + "time_value, microseconds_enabled, expected_result", + [ + ("1712700812345", True, "1712700812345"), + (datetime(2024, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc), True, "2024-01-02T03:04:05.678901+0000"), + ( + datetime(2024, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc), + False, + str(int(datetime(2024, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc).timestamp() * 1000)), + ), + ], +) +def test_convert_time(rp_client: RPClient, time_value, microseconds_enabled, expected_result): + rp_client.use_microseconds = mock.Mock(return_value=microseconds_enabled) + + assert rp_client._convert_time(time_value) == expected_result + + def test_oauth_authentication_parameters(): """Test that OAuth 2.0 authentication parameters work correctly.""" client = RPClient( From a0ceead9d7a7ccd673d57dd4b0c2f8df64c2679c Mon Sep 17 00:00:00 2001 From: Vadzim Hushchanskou Date: Fri, 10 Apr 2026 12:36:26 +0300 Subject: [PATCH 12/12] Some code reverted. Update CHANGELOG.md --- CHANGELOG.md | 2 ++ reportportal_client/helpers/common_helpers.py | 12 ++++-------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dee0aec..4d2cd63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## [Unreleased] +### Added +- Microseconds precision for timestamps, by @HardNorth ## [5.7.2] ### Changed diff --git a/reportportal_client/helpers/common_helpers.py b/reportportal_client/helpers/common_helpers.py index a7bdd29..e5ae0a9 100644 --- a/reportportal_client/helpers/common_helpers.py +++ b/reportportal_client/helpers/common_helpers.py @@ -17,9 +17,9 @@ import inspect import re import threading +import time import unicodedata import uuid -from datetime import datetime, timezone from platform import machine, processor, system from types import MappingProxyType from typing import Any, Callable, Generic, Iterable, Optional, Sized, TypeVar, Union @@ -32,7 +32,6 @@ except ImportError: import json # type: ignore -ISO_MICRO_FORMAT = "%Y-%m-%dT%H:%M:%S.%f%z" _T = TypeVar("_T") ATTRIBUTE_LENGTH_LIMIT: int = 128 ATTRIBUTE_NUMBER_LIMIT: int = 256 @@ -275,12 +274,9 @@ def verify_value_length(attributes: list[dict]) -> Optional[list[dict]]: return result -def timestamp(use_microseconds=False) -> str: - """Return string representation of the current time in milli/microseconds.""" - now = datetime.now(tz=timezone.utc) - if use_microseconds: - return now.strftime(ISO_MICRO_FORMAT) - return str(int(now.timestamp() * 1000)) +def timestamp() -> str: + """Return string representation of the current time in milliseconds.""" + return str(int(time.time() * 1000)) def uri_join(*uri_parts: str) -> str: