diff --git a/CHANGELOG.md b/CHANGELOG.md index b426ba4..4d2cd63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog ## [Unreleased] +### Added +- Microseconds precision for timestamps, by @HardNorth + +## [5.7.2] ### Changed - `aiohttp` version updated to 3.13.4, by @HardNorth ### Removed 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 8061b43..f76c7bf 100644 --- a/reportportal_client/aio/client.py +++ b/reportportal_client/aio/client.py @@ -19,10 +19,11 @@ 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 +from typing import Any, Coroutine, Optional, TypeVar, Union, cast import aiohttp import certifi @@ -57,7 +58,8 @@ from reportportal_client._internal.static.defines import DEFAULT_LOG_LEVEL # 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 ( @@ -79,7 +81,8 @@ LAUNCH_NAME_LENGTH_LIMIT, LifoQueue, agent_name_version, - await_if_necessary, + compare_semantic_versions, + extract_server_version, root_uri_join, uri_join, ) @@ -93,6 +96,7 @@ DEFAULT_TASK_TIMEOUT: float = 60.0 DEFAULT_SHUTDOWN_TIMEOUT: float = 120.0 +MICROSECONDS_MIN_VERSION = "5.13.2" class Client: @@ -682,6 +686,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. @@ -775,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: @@ -878,12 +894,39 @@ 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, - start_time: str, + start_time: Union[str, datetime], description: Optional[str] = None, attributes: Optional[Union[list, dict]] = None, rerun: bool = False, @@ -904,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 @@ -912,7 +961,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, @@ -950,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, @@ -973,7 +1022,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, @@ -1002,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, @@ -1017,7 +1066,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, @@ -1032,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 = "" @@ -1108,9 +1157,38 @@ 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. + """ + 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 _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: str, + time: Union[str, datetime], message: str, level: Optional[Union[int, str]] = None, attachment: Optional[dict] = None, @@ -1135,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, @@ -1188,6 +1266,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: @@ -1235,7 +1316,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, @@ -1291,10 +1372,13 @@ 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 - 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. @@ -1337,10 +1421,53 @@ 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, - start_time: str, + start_time: Union[str, datetime], description: Optional[str] = None, attributes: Optional[Union[list, dict]] = None, rerun: bool = False, @@ -1361,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 @@ -1369,7 +1502,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, @@ -1407,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, @@ -1428,7 +1561,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, @@ -1457,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, @@ -1473,7 +1606,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, @@ -1489,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() @@ -1555,7 +1688,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. @@ -1564,6 +1697,32 @@ 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. + """ + 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()) + + 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) @@ -1572,7 +1731,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, @@ -1597,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, @@ -1659,11 +1818,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() - - async def __return_value(self, value): - return value + thread = threading.Thread(target=self._loop.run_forever, name="RP-Async-Client", daemon=True) + thread.start() + self._thread = thread def __init__( self, @@ -1726,20 +1883,30 @@ 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) - - def create_task(self, coro: Coroutine[Any, Any, _T]) -> Optional[Task[_T]]: + 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. :param coro: Coroutine which will be used for the Task creation. :return: Task instance. """ - if not getattr(self, "_loop", None): - return None + if self._loop is None: + return EmptyTask() result = self._loop.create_task(coro) with self._task_mutex: self._task_list.append(result) @@ -1747,13 +1914,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: @@ -1798,6 +1965,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: @@ -1808,6 +1976,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): @@ -1848,9 +2018,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, @@ -1916,23 +2083,30 @@ 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: + 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]) -> 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) @@ -1989,6 +2163,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: @@ -1999,3 +2174,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/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/aio/util.py b/reportportal_client/aio/util.py new file mode 100644 index 0000000..82d99a7 --- /dev/null +++ b/reportportal_client/aio/util.py @@ -0,0 +1,36 @@ +# 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. + +"""This module contains auxiliary functions for async code.""" + +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/client.py b/reportportal_client/client.py index 3b55fa0..9fc5b92 100644 --- a/reportportal_client/client.py +++ b/reportportal_client/client.py @@ -17,8 +17,10 @@ import logging import queue import sys +import threading 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 @@ -55,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, @@ -68,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.""" @@ -143,11 +153,24 @@ 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 _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, 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 +194,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 +235,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 +266,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, @@ -315,10 +338,18 @@ 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, - time: str, + time: Union[str, datetime], message: str, level: Optional[Union[int, str]] = None, attachment: Optional[dict] = None, @@ -426,6 +457,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]: @@ -578,6 +613,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 @@ -633,11 +672,31 @@ 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, name: str, - start_time: str, + start_time: Union[str, datetime], description: Optional[str] = None, attributes: Optional[Union[list, dict]] = None, rerun: bool = False, @@ -660,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, @@ -693,7 +752,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, @@ -734,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, @@ -772,7 +831,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, @@ -803,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, @@ -834,7 +893,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, @@ -852,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, @@ -936,7 +995,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, @@ -960,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), @@ -1030,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 @@ -1054,6 +1113,50 @@ 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() + 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 _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) @@ -1128,6 +1231,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: @@ -1138,3 +1243,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/core/rp_requests.py b/reportportal_client/core/rp_requests.py index 8b5ada0..8ede72a 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.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 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 92f9a0e..32ccc69 100644 --- a/reportportal_client/helpers/__init__.py +++ b/reportportal_client/helpers/__init__.py @@ -14,11 +14,12 @@ TYPICAL_MULTIPART_FOOTER_LENGTH, LifoQueue, agent_name_version, - await_if_necessary, calculate_file_part_size, calculate_json_part_size, caseless_equal, + compare_semantic_versions, dict_to_payload, + extract_server_version, gen_attributes, generate_uuid, get_function_params, @@ -52,11 +53,12 @@ "TYPICAL_FILE_PART_HEADER", "LifoQueue", "agent_name_version", - "await_if_necessary", "calculate_file_part_size", "calculate_json_part_size", "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 9457b3e..e5ae0a9 100644 --- a/reportportal_client/helpers/common_helpers.py +++ b/reportportal_client/helpers/common_helpers.py @@ -13,10 +13,8 @@ """This module contains common functions-helpers of the client and agents.""" -import asyncio import fnmatch import inspect -import logging import re import threading import time @@ -34,7 +32,6 @@ except ImportError: import json # type: ignore -logger: logging.Logger = logging.getLogger(__name__) _T = TypeVar("_T") ATTRIBUTE_LENGTH_LIMIT: int = 128 ATTRIBUTE_NUMBER_LIMIT: int = 256 @@ -399,20 +396,6 @@ 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]: - """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. @@ -586,3 +569,117 @@ 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 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"): + 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/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"] 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..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 @@ -193,3 +194,49 @@ 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() + + +@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() + + +@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 e080e3f..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 @@ -167,3 +168,47 @@ 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() + + +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() + + +@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 ee5f6b0..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 @@ -165,3 +166,47 @@ 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() + + +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() + + +@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/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 diff --git a/tests/test_client.py b/tests/test_client.py index 19b3b80..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 @@ -54,6 +55,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 +299,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 +347,57 @@ 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_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() + + +@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(