diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1fe16d42..2e7d9d44 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,10 +50,11 @@ jobs: pip install tox tox-gh-actions - name: Test with tox + timeout-minutes: 10 run: tox - name: Upload coverage to Codecov - if: matrix.python-version == 3.7 && success() + if: matrix.python-version == 3.8 && success() uses: codecov/codecov-action@v3 with: files: coverage.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index d8ca9d2d..5ab10cae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ # Changelog ## [Unreleased] +### Added +- `RP` class in `reportportal_client.client` module as common interface for all ReportPortal clients, by @HardNorth +- `reportportal_client.aio` with asynchronous clients and auxiliary classes, by @HardNorth +- Dependency on `aiohttp` and `certifi`, by @HardNorth +### Changed +- RPClient class does not use separate Thread for log processing anymore, by @HardNorth +- Use `importlib.metadata` package for distribution data extraction for Python versions starting 3.8, by @HardNorth +- `helpers.verify_value_length` function updated to truncate attribute keys also and reveal attributes were truncated, by @HardNorth +### Removed +- Dependency on `six`, by @HardNorth + +## [5.4.1] ### Changed - Unified ReportPortal product naming, by @HardNorth - `RPClient` internal item stack implementation changed to `LifoQueue` to maintain concurrency better, by @HardNorth diff --git a/MANIFEST.in b/MANIFEST.in index 14236ba2..4fd17dd1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include MANIFEST.in include README.md CONTRIBUTING.rst requirements.txt +exclude test_res/* diff --git a/reportportal_client/__init__.py b/reportportal_client/__init__.py index bde50a59..d0bb0e03 100644 --- a/reportportal_client/__init__.py +++ b/reportportal_client/__init__.py @@ -1,28 +1,124 @@ -""" -Copyright (c) 2022 https://reportportal.io . +# Copyright (c) 2023 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 -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 +"""This package is the base package for ReportPortal client.""" +import typing +import warnings -https://www.apache.org/licenses/LICENSE-2.0 +import aenum -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. -""" +# noinspection PyProtectedMember +from reportportal_client._internal.local import current, set_current +from reportportal_client.aio.client import AsyncRPClient, BatchedRPClient, ThreadedRPClient +from reportportal_client.client import RP, RPClient, OutputType +from reportportal_client.logs import RPLogger, RPLogHandler +from reportportal_client.steps import step + + +class ClientType(aenum.Enum): + """Enum of possible type of ReportPortal clients.""" + + SYNC = aenum.auto() + ASYNC = aenum.auto() + ASYNC_THREAD = aenum.auto() + ASYNC_BATCHED = aenum.auto() + + +# noinspection PyIncorrectDocstring +def create_client( + client_type: ClientType, + endpoint: str, + project: str, + *, + api_key: str = None, + **kwargs: typing.Any +) -> typing.Optional[RP]: + """Create and ReportPortal Client based on the type and arguments provided. + + :param client_type: Type of the Client to create. + :type client_type: ClientType + :param endpoint: Endpoint of the ReportPortal service. + :type endpoint: str + :param project: Project name to report to. + :type project: str + :param api_key: Authorization API key. + :type api_key: str + :param launch_uuid: A launch UUID to use instead of starting own one. + :type launch_uuid: str + :param is_skipped_an_issue: Option to mark skipped tests as not 'To Investigate' items on the server + side. + :type is_skipped_an_issue: bool + :param verify_ssl: Option to skip ssl verification. + :type verify_ssl: typing.Union[bool, str] + :param retries: Number of retry attempts to make in case of connection / server + errors. + :type retries: int + :param max_pool_size: Option to set the maximum number of connections to save the pool. + :type max_pool_size: int + :param http_timeout : A float in seconds for connect and read timeout. Use a Tuple to + specific connect and read separately. + :type http_timeout: Tuple[float, float] + :param mode: Launch mode, all Launches started by the client will be in that mode. + :type mode: str + :param launch_uuid_print: Print Launch UUID into passed TextIO or by default to stdout. + :type launch_uuid_print: bool + :param print_output: Set output stream for Launch UUID printing. + :type print_output: OutputType + :param log_batch_size: Option to set the maximum number of logs that can be processed in one + batch. + :type log_batch_size: int + :param log_batch_payload_limit: Maximum size in bytes of logs that can be processed in one batch. + :type log_batch_payload_limit: int + :param keepalive_timeout: For Async Clients only. Maximum amount of idle time in seconds before + force connection closing. + :type keepalive_timeout: int + :param task_timeout: For Async Threaded and Batched Clients only. Time limit in seconds for a + Task processing. + :type task_timeout: float + :param shutdown_timeout: For Async Threaded and Batched Clients only. Time limit in seconds for + shutting down internal Tasks. + :type shutdown_timeout: float + :param trigger_num: For Async Batched Client only. Number of tasks which triggers Task batch + execution. + :type trigger_num: int + :param trigger_interval: For Async Batched Client only. Time limit which triggers Task batch + execution. + :type trigger_interval: float + :return: ReportPortal Client instance. + """ + if client_type is ClientType.SYNC: + return RPClient(endpoint, project, api_key=api_key, **kwargs) + if client_type is ClientType.ASYNC: + return AsyncRPClient(endpoint, project, api_key=api_key, **kwargs) + if client_type is ClientType.ASYNC_THREAD: + return ThreadedRPClient(endpoint, project, api_key=api_key, **kwargs) + if client_type is ClientType.ASYNC_BATCHED: + return BatchedRPClient(endpoint, project, api_key=api_key, **kwargs) + warnings.warn(f'Unknown ReportPortal Client type requested: {client_type}', RuntimeWarning, stacklevel=2) -from ._local import current -from .logs import RPLogger, RPLogHandler -from .client import RPClient -from .steps import step __all__ = [ + 'ClientType', + 'create_client', 'current', + 'set_current', + 'RP', + 'RPClient', + 'AsyncRPClient', + 'BatchedRPClient', + 'ThreadedRPClient', + 'OutputType', 'RPLogger', 'RPLogHandler', - 'RPClient', 'step', ] diff --git a/reportportal_client/_internal/__init__.py b/reportportal_client/_internal/__init__.py new file mode 100644 index 00000000..c04681ca --- /dev/null +++ b/reportportal_client/_internal/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2023 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 + +"""ReportPortal client internal API. No warnings before breaking changes. No backward compatibility.""" diff --git a/tests/logs/__init__.py b/reportportal_client/_internal/aio/__init__.py similarity index 86% rename from tests/logs/__init__.py rename to reportportal_client/_internal/aio/__init__.py index c72fbd80..1c768643 100644 --- a/tests/logs/__init__.py +++ b/reportportal_client/_internal/aio/__init__.py @@ -1,6 +1,4 @@ -"""This package contains unit tests for logging.""" - -# Copyright (c) 2022 EPAM Systems +# Copyright (c) 2023 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 diff --git a/reportportal_client/_internal/aio/http.py b/reportportal_client/_internal/aio/http.py new file mode 100644 index 00000000..c9c6d344 --- /dev/null +++ b/reportportal_client/_internal/aio/http.py @@ -0,0 +1,164 @@ +# Copyright (c) 2023 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 +# +# 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 designed to help with asynchronous HTTP request/response handling.""" + +import asyncio +import sys +from types import TracebackType +from typing import Coroutine, Any, Optional, Type, Callable + +from aenum import Enum +from aiohttp import ClientSession, ClientResponse, ServerConnectionError, \ + ClientResponseError + +DEFAULT_RETRY_NUMBER: int = 5 +DEFAULT_RETRY_DELAY: float = 0.005 +THROTTLING_STATUSES: set = {425, 429} +RETRY_STATUSES: set = {408, 500, 502, 503, 504, 507}.union(THROTTLING_STATUSES) + + +class RetryClass(int, Enum): + """Enum contains error types and their retry delay multiply factor as values.""" + + SERVER_ERROR = 1 + CONNECTION_ERROR = 2 + THROTTLING = 3 + + +class RetryingClientSession: + """Class uses aiohttp.ClientSession.request method and adds request retry logic.""" + + _client: ClientSession + __retry_number: int + __retry_delay: float + + def __init__( + self, + *args, + max_retry_number: int = DEFAULT_RETRY_NUMBER, + base_retry_delay: float = DEFAULT_RETRY_DELAY, + **kwargs + ): + """Initialize an instance of the session with arguments. + + To obtain the full list of arguments please see aiohttp.ClientSession.__init__() method. This class + just bypass everything to the base method, except two local arguments 'max_retry_number' and + 'base_retry_delay'. + + :param max_retry_number: the maximum number of the request retries if it was unsuccessful + :param base_retry_delay: base value for retry delay, determine how much time the class will wait after + an error. Real value highly depends on Retry Class and Retry attempt number, + since retries are performed in exponential delay manner + """ + self._client = ClientSession(*args, **kwargs) + self.__retry_number = max_retry_number + self.__retry_delay = base_retry_delay + + async def __nothing(self): + pass + + def __sleep(self, retry_num: int, retry_factor: int) -> Coroutine: + if retry_num > 0: # don't wait at the first retry attempt + delay = (((retry_factor * self.__retry_delay) * 1000) ** retry_num) / 1000 + return asyncio.sleep(delay) + else: + return self.__nothing() + + async def __request( + self, method: Callable, url, **kwargs: Any + ) -> ClientResponse: + """Make a request and retry if necessary. + + The method retries requests depending on error class and retry number. For no-retry errors, such as + 400 Bad Request it just returns result, for cases where it's reasonable to retry it does it in + exponential manner. + """ + result = None + exceptions = [] + for i in range(self.__retry_number + 1): # add one for the first attempt, which is not a retry + retry_factor = None + try: + result = await method(url, **kwargs) + except Exception as exc: + exceptions.append(exc) + if isinstance(exc, ServerConnectionError) or isinstance(exc, ClientResponseError): + retry_factor = RetryClass.CONNECTION_ERROR + + if not retry_factor: + raise exc + + if result: + if result.ok or result.status not in RETRY_STATUSES: + return result + + if result.status in THROTTLING_STATUSES: + retry_factor = RetryClass.THROTTLING + else: + retry_factor = RetryClass.SERVER_ERROR + + if i + 1 < self.__retry_number: + # don't wait at the last attempt + await self.__sleep(i, retry_factor) + + if exceptions: + if len(exceptions) > 1: + if sys.version_info > (3, 10): + # noinspection PyCompatibility + raise ExceptionGroup( # noqa: F821 + 'During retry attempts the following exceptions happened', + exceptions + ) + else: + raise exceptions[-1] + else: + raise exceptions[0] + return result + + def get(self, url: str, *, allow_redirects: bool = True, + **kwargs: Any) -> Coroutine[Any, Any, ClientResponse]: + """Perform HTTP GET request.""" + return self.__request(self._client.get, url, allow_redirects=allow_redirects, **kwargs) + + def post(self, url: str, *, data: Any = None, **kwargs: Any) -> Coroutine[Any, Any, ClientResponse]: + """Perform HTTP POST request.""" + return self.__request(self._client.post, url, data=data, **kwargs) + + def put(self, url: str, *, data: Any = None, **kwargs: Any) -> Coroutine[Any, Any, ClientResponse]: + """Perform HTTP PUT request.""" + return self.__request(self._client.put, url, data=data, **kwargs) + + def close(self) -> Coroutine: + """Gracefully close internal aiohttp.ClientSession class instance.""" + return self._client.close() + + async def __aenter__(self) -> "RetryingClientSession": + """Auxiliary method which controls what `async with` construction does on block enter.""" + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + """Auxiliary method which controls what `async with` construction does on block exit.""" + await self.close() diff --git a/reportportal_client/_internal/aio/tasks.py b/reportportal_client/_internal/aio/tasks.py new file mode 100644 index 00000000..c5e4b237 --- /dev/null +++ b/reportportal_client/_internal/aio/tasks.py @@ -0,0 +1,236 @@ +# Copyright (c) 2023 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 customized asynchronous Tasks and Task Factories for the ReportPortal client.""" + +import asyncio +import sys +import time +from asyncio import Future +from typing import Optional, List, TypeVar, Generic, Union, Generator, Awaitable, Coroutine, Any + +from reportportal_client.aio.tasks import Task, BlockingOperationError + +_T = TypeVar('_T') + + +DEFAULT_TASK_TRIGGER_NUM: int = 10 +DEFAULT_TASK_TRIGGER_INTERVAL: float = 1.0 + + +class BatchedTask(Generic[_T], Task[_T]): + """Represents a Task which uses the current Thread to execute itself.""" + + __loop: asyncio.AbstractEventLoop + + def __init__( + self, + coro: Union[Generator[Future, None, _T], Awaitable[_T]], + *, + loop: asyncio.AbstractEventLoop, + name: Optional[str] = None + ) -> None: + """Initialize an instance of the Task. + + :param coro: Future, Coroutine or a Generator of these objects, which will be executed + :param loop: Event Loop which will be used to execute the Task + :param name: the name of the task + """ + super().__init__(coro, loop=loop, name=name) + self.__loop = loop + + def blocking_result(self) -> _T: + """Use current Thread to execute the Task and return the result if not yet executed. + + :return: execution result or raise an error, or return immediately if already executed + """ + if self.done(): + return self.result() + return self.__loop.run_until_complete(self) + + +class ThreadedTask(Generic[_T], Task[_T]): + """Represents a Task which runs is a separate Thread.""" + + __loop: asyncio.AbstractEventLoop + __wait_timeout: float + + def __init__( + self, + coro: Union[Generator[Future, None, _T], Awaitable[_T]], + wait_timeout: float, + *, + loop: asyncio.AbstractEventLoop, + name: Optional[str] = None + ) -> None: + """Initialize an instance of the Task. + + :param coro: Future, Coroutine or a Generator of these objects, which will be executed + :param loop: Event Loop which will be used to execute the Task + :param name: the name of the task + """ + super().__init__(coro, loop=loop, name=name) + self.__loop = loop + self.__wait_timeout = wait_timeout + + def blocking_result(self) -> _T: + """Pause current Thread until the Task completion and return the result if not yet executed. + + :return: execution result or raise an error, or return immediately if already executed + """ + if self.done(): + return self.result() + if not self.__loop.is_running() or self.__loop.is_closed(): + raise BlockingOperationError('Running loop is not alive') + start_time = time.time() + sleep_time = sys.getswitchinterval() + while not self.done() and time.time() - start_time < self.__wait_timeout: + time.sleep(sleep_time) + if not self.done(): + raise BlockingOperationError('Timed out waiting for the task execution') + return self.result() + + +class BatchedTaskFactory: + """Factory protocol which creates Batched Tasks.""" + + def __call__( + self, + loop: asyncio.AbstractEventLoop, + factory: Union[Coroutine[Any, Any, _T], Generator[Any, None, _T]], + **_ + ) -> Task[_T]: + """Create Batched Task in appropriate Event Loop. + + :param loop: Event Loop which will be used to execute the Task + :param factory: Future, Coroutine or a Generator of these objects, which will be executed + """ + return BatchedTask(factory, loop=loop) + + +class ThreadedTaskFactory: + """Factory protocol which creates Threaded Tasks.""" + + __wait_timeout: float + + def __init__(self, wait_timeout: float): + """Initialize an instance of the Factory. + + :param wait_timeout: Task wait timeout in case of blocking result get + """ + self.__wait_timeout = wait_timeout + + def __call__( + self, + loop: asyncio.AbstractEventLoop, + factory: Union[Coroutine[Any, Any, _T], Generator[Any, None, _T]], + **_ + ) -> Task[_T]: + """Create Threaded Task in appropriate Event Loop. + + :param loop: Event Loop which will be used to execute the Task + :param factory: Future, Coroutine or a Generator of these objects, which will be executed + """ + return ThreadedTask(factory, self.__wait_timeout, loop=loop) + + +class TriggerTaskBatcher(Generic[_T]): + """Batching class which compile its batches by object number or by passed time.""" + + __task_list: List[_T] + __last_run_time: float + __trigger_num: int + __trigger_interval: float + + def __init__(self, + trigger_num: int = DEFAULT_TASK_TRIGGER_NUM, + trigger_interval: float = DEFAULT_TASK_TRIGGER_INTERVAL) -> None: + """Initialize an instance of the Batcher. + + :param trigger_num: object number threshold which triggers batch return and reset + :param trigger_interval: amount of time after which return and reset batch + """ + self.__task_list = [] + self.__last_run_time = time.time() + self.__trigger_num = trigger_num + self.__trigger_interval = trigger_interval + + def __ready_to_run(self) -> bool: + current_time = time.time() + last_time = self.__last_run_time + if len(self.__task_list) <= 0: + return False + if (len(self.__task_list) >= self.__trigger_num + or current_time - last_time >= self.__trigger_interval): + self.__last_run_time = current_time + return True + return False + + def append(self, value: _T) -> Optional[List[_T]]: + """Add an object to internal batch and return the batch if it's triggered. + + :param value: an object to add to the batch + :return: a batch or None + """ + self.__task_list.append(value) + if self.__ready_to_run(): + tasks = self.__task_list + self.__task_list = [] + return tasks + + def flush(self) -> Optional[List[_T]]: + """Immediately return everything what's left in the internal batch. + + :return: a batch or None + """ + if len(self.__task_list) > 0: + tasks = self.__task_list + self.__task_list = [] + return tasks + + +class BackgroundTaskList(Generic[_T]): + """Task list class which collects Tasks into internal batch and removes when they complete.""" + + __task_list: List[_T] + + def __init__(self): + """Initialize an instance of the Batcher.""" + self.__task_list = [] + + def __remove_finished(self): + i = -1 + for task in self.__task_list: + if not task.done(): + break + i += 1 + self.__task_list = self.__task_list[i + 1:] + + def append(self, value: _T) -> None: + """Add an object to internal batch. + + :param value: an object to add to the batch + """ + self.__remove_finished() + self.__task_list.append(value) + + def flush(self) -> Optional[List[_T]]: + """Immediately return everything what's left unfinished in the internal batch. + + :return: a batch or None + """ + self.__remove_finished() + if len(self.__task_list) > 0: + tasks = self.__task_list + self.__task_list = [] + return tasks diff --git a/reportportal_client/_local/__init__.py b/reportportal_client/_internal/local/__init__.py similarity index 100% rename from reportportal_client/_local/__init__.py rename to reportportal_client/_internal/local/__init__.py index 9cef441e..6f3186d5 100644 --- a/reportportal_client/_local/__init__.py +++ b/reportportal_client/_internal/local/__init__.py @@ -1,5 +1,3 @@ -"""ReportPortal client context storing and retrieving module.""" - # Copyright (c) 2022 EPAM Systems # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,6 +11,8 @@ # See the License for the specific language governing permissions and # limitations under the License +"""ReportPortal client context storing and retrieving module.""" + from threading import local __INSTANCES = local() diff --git a/reportportal_client/_local/__init__.pyi b/reportportal_client/_internal/local/__init__.pyi similarity index 80% rename from reportportal_client/_local/__init__.pyi rename to reportportal_client/_internal/local/__init__.pyi index b5f2ab4e..a5d72a7e 100644 --- a/reportportal_client/_local/__init__.pyi +++ b/reportportal_client/_internal/local/__init__.pyi @@ -13,10 +13,10 @@ from typing import Optional -from reportportal_client.client import RPClient +from reportportal_client import RP -def current() -> Optional[RPClient]: ... +def current() -> Optional[RP]: ... -def set_current(client: Optional[RPClient]) -> None: ... +def set_current(client: Optional[RP]) -> None: ... diff --git a/tests/core/__init__.py b/reportportal_client/_internal/logs/__init__.py similarity index 85% rename from tests/core/__init__.py rename to reportportal_client/_internal/logs/__init__.py index 30e3faf9..1c768643 100644 --- a/tests/core/__init__.py +++ b/reportportal_client/_internal/logs/__init__.py @@ -1,6 +1,4 @@ -"""This package contains unit tests for core module.""" - -# Copyright (c) 2022 EPAM Systems +# Copyright (c) 2023 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 diff --git a/reportportal_client/_internal/logs/batcher.py b/reportportal_client/_internal/logs/batcher.py new file mode 100644 index 00000000..bffd5668 --- /dev/null +++ b/reportportal_client/_internal/logs/batcher.py @@ -0,0 +1,114 @@ +# Copyright (c) 2023 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 a helper class to automate packaging separate Log entries into log batches.""" + +import logging +import threading +from typing import List, Optional, TypeVar, Generic, Dict, Any + +from reportportal_client.core.rp_requests import RPRequestLog, AsyncRPRequestLog +from reportportal_client.logs import MAX_LOG_BATCH_SIZE, MAX_LOG_BATCH_PAYLOAD_SIZE + +logger = logging.getLogger(__name__) + +T_co = TypeVar('T_co', bound='RPRequestLog', covariant=True) + + +class LogBatcher(Generic[T_co]): + """Log packaging class to automate compiling separate Log entries into log batches. + + The class accepts the maximum number of log entries in desired batches and maximum batch size to conform + with maximum request size limits, configured on servers. The class implementation is thread-safe. + """ + + entry_num: int + payload_limit: int + _lock: threading.Lock + _batch: List[T_co] + _payload_size: int + + def __init__(self, entry_num=MAX_LOG_BATCH_SIZE, payload_limit=MAX_LOG_BATCH_PAYLOAD_SIZE) -> None: + """Initialize the batcher instance with empty batch and specific limits. + + :param entry_num: maximum numbers of entries in a Log batch + :param payload_limit: maximum batch size in bytes + """ + self.entry_num = entry_num + self.payload_limit = payload_limit + self._lock = threading.Lock() + self._batch = [] + self._payload_size = 0 + + def _append(self, size: int, log_req: T_co) -> Optional[List[T_co]]: + with self._lock: + if self._payload_size + size >= self.payload_limit: + if len(self._batch) > 0: + batch = self._batch + self._batch = [log_req] + self._payload_size = size + return batch + self._batch.append(log_req) + self._payload_size += size + if len(self._batch) >= self.entry_num: + batch = self._batch + self._batch = [] + self._payload_size = 0 + return batch + + def append(self, log_req: RPRequestLog) -> Optional[List[RPRequestLog]]: + """Add a log request object to internal batch and return the batch if it's full. + + :param log_req: log request object + :return: a batch or None + """ + return self._append(log_req.multipart_size, log_req) + + async def append_async(self, log_req: AsyncRPRequestLog) -> Optional[List[AsyncRPRequestLog]]: + """Add a log request object to internal batch and return the batch if it's full. + + :param log_req: log request object + :return: a batch or None + """ + return self._append(await log_req.multipart_size, log_req) + + def flush(self) -> Optional[List[T_co]]: + """Immediately return everything what's left in the internal batch. + + :return: a batch or None + """ + with self._lock: + if len(self._batch) > 0: + batch = self._batch + self._batch = [] + self._payload_size = 0 + return batch + + def __getstate__(self) -> Dict[str, Any]: + """Control object pickling and return object fields as Dictionary. + + :return: object state dictionary + :rtype: dict + """ + state = self.__dict__.copy() + # Don't pickle 'session' field, since it contains unpickling 'socket' + del state['_lock'] + return state + + def __setstate__(self, state: Dict[str, Any]) -> None: + """Control object pickling, receives object state as Dictionary. + + :param dict state: object state dictionary + """ + self.__dict__.update(state) + self._lock = threading.Lock() diff --git a/reportportal_client/services/__init__.py b/reportportal_client/_internal/services/__init__.py similarity index 100% rename from reportportal_client/services/__init__.py rename to reportportal_client/_internal/services/__init__.py index f2e04167..1180670c 100644 --- a/reportportal_client/services/__init__.py +++ b/reportportal_client/_internal/services/__init__.py @@ -1,5 +1,3 @@ -"""This package contains different service interfaces.""" - # Copyright (c) 2023 EPAM Systems # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,3 +10,5 @@ # 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 package contains different service interfaces.""" diff --git a/reportportal_client/services/client_id.py b/reportportal_client/_internal/services/client_id.py similarity index 98% rename from reportportal_client/services/client_id.py rename to reportportal_client/_internal/services/client_id.py index 70974d34..6b547f28 100644 --- a/reportportal_client/services/client_id.py +++ b/reportportal_client/_internal/services/client_id.py @@ -1,5 +1,3 @@ -"""This module generates and store unique client ID of an instance.""" - # Copyright (c) 2023 EPAM Systems # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,14 +11,14 @@ # See the License for the specific language governing permissions and # limitations under the License +"""This module generates and store unique client ID of an instance.""" + import configparser import io import logging import os from uuid import uuid4 -import six - from .constants import CLIENT_ID_PROPERTY, RP_FOLDER_PATH, \ RP_PROPERTIES_FILE_PATH @@ -37,7 +35,7 @@ def __preprocess_file(self, fp): return io.StringIO(content) def read(self, filenames, encoding=None): - if isinstance(filenames, six.string_types): + if isinstance(filenames, str): filenames = [filenames] for filename in filenames: with open(filename, 'r') as fp: diff --git a/reportportal_client/services/client_id.pyi b/reportportal_client/_internal/services/client_id.pyi similarity index 100% rename from reportportal_client/services/client_id.pyi rename to reportportal_client/_internal/services/client_id.pyi diff --git a/reportportal_client/services/constants.py b/reportportal_client/_internal/services/constants.py similarity index 100% rename from reportportal_client/services/constants.py rename to reportportal_client/_internal/services/constants.py index 3b57ad17..8b0d3fe3 100644 --- a/reportportal_client/services/constants.py +++ b/reportportal_client/_internal/services/constants.py @@ -1,5 +1,3 @@ -"""This module contains constants for the external services.""" - # Copyright (c) 2023 EPAM Systems # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,6 +11,8 @@ # See the License for the specific language governing permissions and # limitations under the License +"""This module contains constants for the external services.""" + import base64 import os diff --git a/reportportal_client/services/constants.pyi b/reportportal_client/_internal/services/constants.pyi similarity index 100% rename from reportportal_client/services/constants.pyi rename to reportportal_client/_internal/services/constants.pyi diff --git a/reportportal_client/_internal/services/statistics.py b/reportportal_client/_internal/services/statistics.py new file mode 100644 index 00000000..46fcf28a --- /dev/null +++ b/reportportal_client/_internal/services/statistics.py @@ -0,0 +1,129 @@ +# Copyright (c) 2023 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 sends statistics events to a statistics service.""" + +import logging +import ssl +from platform import python_version +from typing import Optional, Tuple + +import aiohttp +import certifi +import requests + +from reportportal_client.helpers import get_package_parameters +from reportportal_client._internal.services.client_id import get_client_id +from reportportal_client._internal.services.constants import CLIENT_INFO, ENDPOINT + +logger = logging.getLogger(__name__) + +ID, KEY = CLIENT_INFO.split(':') + + +def _get_client_info() -> Tuple[str, str]: + """Get name of the client and its version. + + :return: ('reportportal-client', '5.0.4') + """ + name, version = get_package_parameters('reportportal-client', ['name', 'version']) + return name, version + + +def _get_platform_info() -> str: + """Get current platform basic info, e.g.: 'Python 3.6.1'. + + :return: str represents the current platform, e.g.: 'Python 3.6.1' + """ + return 'Python ' + python_version() + + +def _get_payload(event_name: str, agent_name: Optional[str], agent_version: Optional[str]) -> dict: + """Format Statistics service request as it should be sent. + + :param event_name: name of the event as it will be displayed + :param agent_name: current Agent name + :param agent_version: current Agent version + :return: JSON representation of the request as Dictionary + """ + client_name, client_version = _get_client_info() + request_params = { + 'client_name': client_name, + 'client_version': client_version, + 'interpreter': _get_platform_info(), + 'agent_name': agent_name, + 'agent_version': agent_version, + } + + if agent_name: + request_params['agent_name'] = agent_name + if agent_version: + request_params['agent_version'] = agent_version + + return { + 'client_id': get_client_id(), + 'events': [{ + 'name': event_name, + 'params': request_params + }] + } + + +def send_event(event_name: str, agent_name: Optional[str], agent_version: Optional[str]) -> requests.Response: + """Send an event to statistics service. + + Use client and agent versions with their names. + + :param event_name: Event name to be used + :param agent_name: Name of the agent that uses the client + :param agent_version: Version of the agent + """ + headers = {'User-Agent': 'python-requests'} + query_params = { + 'measurement_id': ID, + 'api_secret': KEY + } + try: + return requests.post(url=ENDPOINT, json=_get_payload(event_name, agent_name, agent_version), + headers=headers, params=query_params) + except requests.exceptions.RequestException as err: + logger.debug('Failed to send data to Statistics service: %s', str(err)) + + +async def async_send_event(event_name: str, agent_name: Optional[str], + agent_version: Optional[str]) -> Optional[aiohttp.ClientResponse]: + """Send an event to statistics service. + + Use client and agent versions with their names. + + :param event_name: Event name to be used + :param agent_name: Name of the agent that uses the client + :param agent_version: Version of the agent + """ + headers = {'User-Agent': 'python-aiohttp'} + query_params = { + 'measurement_id': ID, + 'api_secret': KEY + } + ssl_context = ssl.create_default_context(cafile=certifi.where()) + async with aiohttp.ClientSession() as session: + try: + result = await session.post(url=ENDPOINT, + json=_get_payload(event_name, agent_name, agent_version), + headers=headers, params=query_params, ssl=ssl_context) + except aiohttp.ClientError as exc: + logger.debug('Failed to send data to Statistics service: connection error', exc) + return + if not result.ok: + logger.debug(f'Failed to send data to Statistics service: {result.reason}') + return result diff --git a/reportportal_client/services/statistics.pyi b/reportportal_client/_internal/static/__init__.py similarity index 64% rename from reportportal_client/services/statistics.pyi rename to reportportal_client/_internal/static/__init__.py index f061bf70..4c6c4352 100644 --- a/reportportal_client/services/statistics.pyi +++ b/reportportal_client/_internal/static/__init__.py @@ -11,19 +11,4 @@ # See the License for the specific language governing permissions and # limitations under the License -from logging import Logger -from typing import Text, Optional - -import requests - -logger: Logger - - -def _get_client_info() -> tuple[Text, Text]: ... - - -def _get_platform_info() -> Text: ... - - -def send_event(event_name: Text, agent_name: Optional[Text], - agent_version: Optional[Text]) -> requests.Response: ... +"""This package provides common static objects and variables.""" diff --git a/reportportal_client/static/abstract.py b/reportportal_client/_internal/static/abstract.py similarity index 68% rename from reportportal_client/static/abstract.py rename to reportportal_client/_internal/static/abstract.py index 8bc59d9b..fdfe2aa0 100644 --- a/reportportal_client/static/abstract.py +++ b/reportportal_client/_internal/static/abstract.py @@ -1,19 +1,17 @@ -"""This module provides base abstract class for RP request objects. - -Copyright (c) 2018 http://reportportal.io . - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://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. -""" +# Copyright (c) 2023 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 provides base abstract class for RP request objects.""" from abc import ABCMeta as _ABCMeta, abstractmethod @@ -21,7 +19,7 @@ class AbstractBaseClass(_ABCMeta): - """Metaclass fot pure Interfacing. + """Metaclass for pure Interfacing. Being set as __metaclass__, forbids direct object creation from this class, allowing only inheritance. I.e. diff --git a/reportportal_client/static/defines.py b/reportportal_client/_internal/static/defines.py similarity index 63% rename from reportportal_client/static/defines.py rename to reportportal_client/_internal/static/defines.py index 25ca1c52..4335e0e8 100644 --- a/reportportal_client/static/defines.py +++ b/reportportal_client/_internal/static/defines.py @@ -1,21 +1,20 @@ -"""This module provides RP client static objects and variables. - -Copyright (c) 2018 http://reportportal.io . - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://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. -""" +# Copyright (c) 2023 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 provides RP client static objects and variables.""" import aenum as enum +from reportportal_client.helpers import ATTRIBUTE_LENGTH_LIMIT as ATTRIBUTE_LIMIT RP_LOG_LEVELS = { @@ -29,7 +28,7 @@ } -class _PresenceSentinel(object): +class _PresenceSentinel: """Sentinel object for the None type.""" def __nonzero__(self): @@ -70,7 +69,7 @@ class ItemStartType(str, enum.Enum): class Priority(enum.IntEnum): - """Generic enum for various operations prioritization.""" + """Generic enum for various operations' prioritization.""" PRIORITY_IMMEDIATE = 0x0 PRIORITY_HIGH = 0x1 @@ -78,7 +77,7 @@ class Priority(enum.IntEnum): PRIORITY_LOW = 0x3 -ATTRIBUTE_LENGTH_LIMIT = 128 +ATTRIBUTE_LENGTH_LIMIT = ATTRIBUTE_LIMIT DEFAULT_PRIORITY = Priority.PRIORITY_MEDIUM LOW_PRIORITY = Priority.PRIORITY_LOW NOT_FOUND = _PresenceSentinel() diff --git a/reportportal_client/aio/__init__.py b/reportportal_client/aio/__init__.py new file mode 100644 index 00000000..fd5f103b --- /dev/null +++ b/reportportal_client/aio/__init__.py @@ -0,0 +1,31 @@ +# Copyright (c) 2023 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 + +"""Common package for Asynchronous I/O clients and utilities.""" + +from reportportal_client.aio.client import (ThreadedRPClient, BatchedRPClient, AsyncRPClient, + DEFAULT_TASK_TIMEOUT, DEFAULT_SHUTDOWN_TIMEOUT, + DEFAULT_TASK_TRIGGER_NUM, DEFAULT_TASK_TRIGGER_INTERVAL) +from reportportal_client.aio.tasks import Task, BlockingOperationError + +__all__ = [ + 'Task', + 'BlockingOperationError', + 'DEFAULT_TASK_TIMEOUT', + 'DEFAULT_SHUTDOWN_TIMEOUT', + 'DEFAULT_TASK_TRIGGER_NUM', + 'DEFAULT_TASK_TRIGGER_INTERVAL', + 'AsyncRPClient', + 'BatchedRPClient', + 'ThreadedRPClient' +] diff --git a/reportportal_client/aio/client.py b/reportportal_client/aio/client.py new file mode 100644 index 00000000..2a5dca21 --- /dev/null +++ b/reportportal_client/aio/client.py @@ -0,0 +1,1671 @@ +# Copyright (c) 2023 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 asynchronous implementations of ReportPortal Client.""" + +import asyncio +import logging +import ssl +import threading +import time as datetime +import warnings +from os import getenv +from typing import Union, Tuple, List, Dict, Any, Optional, Coroutine, TypeVar + +import aiohttp +import certifi + +# noinspection PyProtectedMember +from reportportal_client._internal.aio.http import RetryingClientSession +# noinspection PyProtectedMember +from reportportal_client._internal.aio.tasks import (BatchedTaskFactory, ThreadedTaskFactory, + TriggerTaskBatcher, BackgroundTaskList, + DEFAULT_TASK_TRIGGER_NUM, DEFAULT_TASK_TRIGGER_INTERVAL) +# noinspection PyProtectedMember +from reportportal_client._internal.local import set_current +# noinspection PyProtectedMember +from reportportal_client._internal.logs.batcher import LogBatcher +# noinspection PyProtectedMember +from reportportal_client._internal.services.statistics import async_send_event +# noinspection PyProtectedMember +from reportportal_client._internal.static.abstract import ( + AbstractBaseClass, + abstractmethod +) +# noinspection PyProtectedMember +from reportportal_client._internal.static.defines import NOT_FOUND, NOT_SET +from reportportal_client.aio.tasks import Task +from reportportal_client.client import RP, OutputType +from reportportal_client.core.rp_issues import Issue +from reportportal_client.core.rp_requests import (LaunchStartRequest, AsyncHttpRequest, AsyncItemStartRequest, + AsyncItemFinishRequest, LaunchFinishRequest, RPFile, + AsyncRPRequestLog, AsyncRPLogBatch) +from reportportal_client.helpers import (root_uri_join, verify_value_length, await_if_necessary, + agent_name_version, LifoQueue, uri_join) +from reportportal_client.logs import MAX_LOG_BATCH_PAYLOAD_SIZE +from reportportal_client.steps import StepReporter + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + +_T = TypeVar('_T') + +DEFAULT_TASK_TIMEOUT: float = 60.0 +DEFAULT_SHUTDOWN_TIMEOUT: float = 120.0 + + +class Client: + """Stateless asynchronous ReportPortal Client. + + This class intentionally made to not store any data or context from ReportPortal. It provides basic + reporting and data read functions in asynchronous manner. Use it whenever you want to handle item IDs, log + batches, future tasks on your own. + """ + + api_v1: str + api_v2: str + base_url_v1: str + base_url_v2: str + endpoint: str + is_skipped_an_issue: bool + project: str + api_key: str + verify_ssl: Union[bool, str] + retries: Optional[int] + max_pool_size: int + http_timeout: Optional[Union[float, Tuple[float, float]]] + keepalive_timeout: Optional[float] + mode: str + launch_uuid_print: bool + print_output: OutputType + _skip_analytics: str + _session: Optional[RetryingClientSession] + __stat_task: Optional[asyncio.Task] + + def __init__( + self, + endpoint: str, + project: str, + *, + api_key: str = None, + is_skipped_an_issue: bool = True, + verify_ssl: Union[bool, str] = True, + retries: int = NOT_SET, + max_pool_size: int = 50, + http_timeout: Optional[Union[float, Tuple[float, float]]] = (10, 10), + keepalive_timeout: Optional[float] = None, + mode: str = 'DEFAULT', + launch_uuid_print: bool = False, + print_output: OutputType = OutputType.STDOUT, + **kwargs: Any + ) -> None: + """Initialize the class instance with arguments. + + :param endpoint: Endpoint of the ReportPortal service. + :param project: Project name to report to. + :param api_key: Authorization API key. + :param is_skipped_an_issue: Option to mark skipped tests as not 'To Investigate' items on the + server side. + :param verify_ssl: Option to skip ssl verification. + :param retries: Number of retry attempts to make in case of connection / server errors. + :param max_pool_size: Option to set the maximum number of connections to save the pool. + :param http_timeout: A float in seconds for connect and read timeout. Use a Tuple to + specific connect and read separately. + :param keepalive_timeout: Maximum amount of idle time in seconds before force connection closing. + :param mode: Launch mode, all Launches started by the client will be in that mode. + :param launch_uuid_print: Print Launch UUID into passed TextIO or by default to stdout. + :param print_output: Set output stream for Launch UUID printing. + """ + self.api_v1, self.api_v2 = 'v1', 'v2' + self.endpoint = endpoint + self.project = project + self.base_url_v1 = root_uri_join(f'api/{self.api_v1}', self.project) + self.base_url_v2 = root_uri_join(f'api/{self.api_v2}', self.project) + self.is_skipped_an_issue = is_skipped_an_issue + self.verify_ssl = verify_ssl + self.retries = retries + self.max_pool_size = max_pool_size + self.http_timeout = http_timeout + self.keepalive_timeout = keepalive_timeout + self.mode = mode + self._skip_analytics = getenv('AGENT_NO_ANALYTICS') + self.launch_uuid_print = launch_uuid_print + self.print_output = print_output + self._session = None + self.__stat_task = None + self.api_key = api_key + + async def session(self) -> RetryingClientSession: + """Return aiohttp.ClientSession class instance, initialize it if necessary. + + :return: aiohttp.ClientSession instance. + """ + if self._session: + return self._session + + if self.verify_ssl is None or (type(self.verify_ssl) == bool and not self.verify_ssl): + ssl_config = False + else: + if type(self.verify_ssl) == str: + ssl_config = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH, cafile=self.verify_ssl) + else: + ssl_config = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH, cafile=certifi.where()) + + connection_params = { + 'ssl': ssl_config, + 'limit': self.max_pool_size + } + if self.keepalive_timeout: + connection_params['keepalive_timeout'] = self.keepalive_timeout + connector = aiohttp.TCPConnector(**connection_params) + + headers = {} + if self.api_key: + headers['Authorization'] = f'Bearer {self.api_key}' + + session_params = { + 'headers': headers, + 'connector': connector + } + + if self.http_timeout: + if type(self.http_timeout) == tuple: + connect_timeout, read_timeout = self.http_timeout + else: + connect_timeout, read_timeout = self.http_timeout, self.http_timeout + session_params['timeout'] = aiohttp.ClientTimeout(connect=connect_timeout, sock_read=read_timeout) + + retries_set = self.retries is not NOT_SET and self.retries and self.retries > 0 + use_retries = self.retries is NOT_SET or (self.retries and self.retries > 0) + + if retries_set: + session_params['max_retry_number'] = self.retries + + if use_retries: + self._session = RetryingClientSession(self.endpoint, **session_params) + else: + # noinspection PyTypeChecker + self._session = aiohttp.ClientSession(self.endpoint, **session_params) + return self._session + + async def close(self) -> None: + """Gracefully close internal aiohttp.ClientSession class instance and reset it.""" + if self._session: + await self._session.close() + self._session = None + + async def __get_item_url(self, item_id_future: Union[str, Task[str]]) -> Optional[str]: + item_id = await await_if_necessary(item_id_future) + if item_id is NOT_FOUND: + logger.warning('Attempt to make request for non-existent id.') + return + return root_uri_join(self.base_url_v2, 'item', item_id) + + async def __get_launch_url(self, launch_uuid_future: Union[str, Task[str]]) -> Optional[str]: + launch_uuid = await await_if_necessary(launch_uuid_future) + if launch_uuid is NOT_FOUND: + logger.warning('Attempt to make request for non-existent launch.') + return + return root_uri_join(self.base_url_v2, 'launch', launch_uuid, 'finish') + + async def start_launch(self, + name: str, + start_time: str, + *, + description: Optional[str] = None, + attributes: Optional[Union[list, dict]] = None, + rerun: bool = False, + rerun_of: Optional[str] = None, + **kwargs) -> Optional[str]: + """Start a new Launch with the given arguments. + + :param name: Launch name. + :param start_time: Launch start time. + :param description: Launch description. + :param attributes: Launch attributes. + :param rerun: Start launch in rerun mode. + :param rerun_of: For rerun mode specifies which launch will be re-run. Should be used with the + 'rerun' option. + :return: Launch UUID if successfully started or None. + """ + url = root_uri_join(self.base_url_v2, 'launch') + request_payload = LaunchStartRequest( + name=name, + start_time=start_time, + attributes=attributes, + description=description, + mode=self.mode, + rerun=rerun, + rerun_of=rerun_of + ).payload + + response = await AsyncHttpRequest((await self.session()).post, url=url, json=request_payload).make() + if not response: + return + + if not self._skip_analytics: + stat_coro = async_send_event('start_launch', *agent_name_version(attributes)) + self.__stat_task = asyncio.create_task(stat_coro, name='Statistics update') + + launch_uuid = await response.id + logger.debug(f'start_launch - ID: {launch_uuid}') + if self.launch_uuid_print and self.print_output: + print(f'ReportPortal Launch UUID: {launch_uuid}', file=self.print_output.get_output()) + return launch_uuid + + async def start_test_item(self, + launch_uuid: Union[str, Task[str]], + name: str, + start_time: str, + item_type: str, + *, + parent_item_id: Optional[Union[str, Task[str]]] = None, + description: Optional[str] = None, + attributes: Optional[Union[List[dict], dict]] = None, + parameters: Optional[dict] = None, + code_ref: Optional[str] = None, + test_case_id: Optional[str] = None, + has_stats: bool = True, + retry: bool = False, + **_: Any) -> Optional[str]: + """Start Test Case/Suite/Step/Nested Step Item. + + :param launch_uuid: A launch UUID where to start the Test Item. + :param name: Name of the Test Item. + :param start_time: The Item start time. + :param item_type: Type of the Test Item. Allowed values: + "suite", "story", "test", "scenario", "step", "before_class", "before_groups", + "before_method", "before_suite", "before_test", "after_class", "after_groups", + "after_method", "after_suite", "after_test". + :param parent_item_id: A UUID of a parent SUITE / STEP. + :param description: The Item description. + :param attributes: Test Item attributes. + :param parameters: Set of parameters (for parametrized Test Items). + :param code_ref: Physical location of the Test Item. + :param test_case_id: A unique ID of the current Step. + :param has_stats: Set to False if test item is a Nested Step. + :param retry: Used to report retry of the test. Allowed values: "True" or "False". + :return: Test Item UUID if successfully started or None. + """ + if parent_item_id: + url = self.__get_item_url(parent_item_id) + else: + url = root_uri_join(self.base_url_v2, 'item') + request_payload = AsyncItemStartRequest( + name, + start_time, + item_type, + launch_uuid, + attributes=verify_value_length(attributes), + code_ref=code_ref, + description=description, + has_stats=has_stats, + parameters=parameters, + retry=retry, + test_case_id=test_case_id + ).payload + + response = await AsyncHttpRequest((await self.session()).post, url=url, json=request_payload).make() + if not response: + return + item_id = await response.id + if item_id is NOT_FOUND or item_id is None: + logger.warning('start_test_item - invalid response: %s', str(await response.json)) + else: + logger.debug('start_test_item - ID: %s', item_id) + return item_id + + async def finish_test_item(self, + launch_uuid: Union[str, Task[str]], + item_id: Union[str, Task[str]], + end_time: str, + *, + status: str = None, + description: str = None, + attributes: Optional[Union[list, dict]] = None, + issue: Optional[Issue] = None, + retry: bool = False, + **kwargs: Any) -> Optional[str]: + """Finish Test Suite/Case/Step/Nested Step Item. + + :param launch_uuid: A launch UUID where to finish the Test Item. + :param item_id: ID of the Test Item. + :param end_time: The Item end time. + :param status: Test status. Allowed values: + PASSED, FAILED, STOPPED, SKIPPED, INTERRUPTED, CANCELLED, INFO, WARN or None. + :param description: Test Item description. Overrides description from start request. + :param attributes: Test Item attributes(tags). Pairs of key and value. These attributes override + attributes on start Test Item call. + :param issue: Issue which will be attached to the current Item. + :param retry: Used to report retry of the test. Allowed values: "True" or "False". + :return: Response message. + """ + url = self.__get_item_url(item_id) + request_payload = AsyncItemFinishRequest( + end_time, + launch_uuid, + status, + attributes=verify_value_length(attributes), + description=description, + is_skipped_an_issue=self.is_skipped_an_issue, + issue=issue, + retry=retry + ).payload + response = await AsyncHttpRequest((await self.session()).put, url=url, json=request_payload).make() + if not response: + return + message = await response.message + logger.debug('finish_test_item - ID: %s', await await_if_necessary(item_id)) + logger.debug('response message: %s', message) + return message + + async def finish_launch(self, + launch_uuid: Union[str, Task[str]], + end_time: str, + *, + status: str = None, + attributes: Optional[Union[list, dict]] = None, + **kwargs: Any) -> Optional[str]: + """Finish a Launch. + + :param launch_uuid: A Launch UUID to finish. + :param end_time: Launch end time. + :param status: Launch status. Can be one of the followings: + PASSED, FAILED, STOPPED, SKIPPED, INTERRUPTED, CANCELLED. + :param attributes: Launch attributes. These attributes override attributes on Start Launch call. + :return: Response message or None. + """ + url = self.__get_launch_url(launch_uuid) + request_payload = LaunchFinishRequest( + end_time, + status=status, + attributes=verify_value_length(attributes), + description=kwargs.get('description') + ).payload + response = await AsyncHttpRequest((await self.session()).put, url=url, json=request_payload, + name='Finish Launch').make() + if not response: + return + message = await response.message + logger.debug('finish_launch - ID: %s', await await_if_necessary(launch_uuid)) + logger.debug('response message: %s', message) + return message + + async def update_test_item(self, + item_uuid: Union[str, Task[str]], + *, + attributes: Optional[Union[list, dict]] = None, + description: Optional[str] = None) -> Optional[str]: + """Update existing Test Item at the ReportPortal. + + :param item_uuid: Test Item UUID returned on the item start. + :param attributes: Test Item attributes: [{'key': 'k_name', 'value': 'k_value'}, ...]. + :param description: Test Item description. + :return: Response message or None. + """ + data = { + 'description': description, + 'attributes': verify_value_length(attributes), + } + item_id = await self.get_item_id_by_uuid(item_uuid) + url = root_uri_join(self.base_url_v1, 'item', item_id, 'update') + response = await AsyncHttpRequest((await self.session()).put, url=url, json=data).make() + if not response: + return + logger.debug('update_test_item - Item: %s', item_id) + return await response.message + + async def __get_launch_uuid_url(self, launch_uuid_future: Union[str, Task[str]]) -> Optional[str]: + launch_uuid = await await_if_necessary(launch_uuid_future) + if launch_uuid is NOT_FOUND: + logger.warning('Attempt to make request for non-existent Launch UUID.') + return + logger.debug('get_launch_info - ID: %s', launch_uuid) + return root_uri_join(self.base_url_v1, 'launch', 'uuid', launch_uuid) + + async def get_launch_info(self, launch_uuid_future: Union[str, Task[str]]) -> Optional[dict]: + """Get Launch information by Launch UUID. + + :param launch_uuid_future: Str or Task UUID returned on the Launch start. + :return: Launch information in dictionary. + """ + url = self.__get_launch_uuid_url(launch_uuid_future) + response = await AsyncHttpRequest((await self.session()).get, url=url).make() + if not response: + return + launch_info = None + if response.is_success: + launch_info = await response.json + logger.debug('get_launch_info - Launch info: %s', launch_info) + else: + logger.warning('get_launch_info - Launch info: Failed to fetch launch ID from the API.') + return launch_info + + async def __get_item_uuid_url(self, item_uuid_future: Union[str, Task[str]]) -> Optional[str]: + item_uuid = await await_if_necessary(item_uuid_future) + if item_uuid is NOT_FOUND: + logger.warning('Attempt to make request for non-existent UUID.') + return + return root_uri_join(self.base_url_v1, 'item', 'uuid', item_uuid) + + async def get_item_id_by_uuid(self, item_uuid_future: Union[str, Task[str]]) -> Optional[str]: + """Get Test Item ID by the given Item UUID. + + :param item_uuid_future: Str or Task UUID returned on the Item start. + :return: Test Item ID. + """ + url = self.__get_item_uuid_url(item_uuid_future) + response = await AsyncHttpRequest((await self.session()).get, url=url).make() + return await response.id if response else None + + async def get_launch_ui_id(self, launch_uuid_future: Union[str, Task[str]]) -> Optional[int]: + """Get Launch ID of the given Launch. + + :param launch_uuid_future: Str or Task UUID returned on the Launch start. + :return: Launch ID of the Launch. None if not found. + """ + launch_info = await self.get_launch_info(launch_uuid_future) + return launch_info.get('id') if launch_info else None + + async def get_launch_ui_url(self, launch_uuid_future: Union[str, Task[str]]) -> Optional[str]: + """Get full quality URL of the given Launch. + + :param launch_uuid_future: Str or Task UUID returned on the Launch start. + :return: Launch URL string. + """ + launch_uuid = await await_if_necessary(launch_uuid_future) + launch_info = await self.get_launch_info(launch_uuid) + launch_id = launch_info.get('id') if launch_info else None + if not launch_id: + return + mode = launch_info.get('mode') if launch_info else None + if not mode: + mode = self.mode + + launch_type = 'launches' if mode.upper() == 'DEFAULT' else 'userdebug' + + path = f'ui/#{self.project.lower()}/{launch_type}/all/{launch_id}' + url = uri_join(self.endpoint, path) + logger.debug('get_launch_ui_url - ID: %s', launch_uuid) + return url + + async def get_project_settings(self) -> Optional[dict]: + """Get settings of the current Project. + + :return: Settings response in Dictionary. + """ + url = root_uri_join(self.base_url_v1, 'settings') + response = await AsyncHttpRequest((await self.session()).get, url=url).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. + + :param log_batch: A list of log message objects. + :return: Completion message tuple of variable size (depending on request size). + """ + url = root_uri_join(self.base_url_v2, 'log') + if log_batch: + response = await AsyncHttpRequest((await self.session()).post, url=url, + data=AsyncRPLogBatch(log_batch).payload).make() + if not response: + return + return await response.messages + + def clone(self) -> 'Client': + """Clone the client object, set current Item ID as cloned item ID. + + :return: Cloned client object + :rtype: AsyncRPClient + """ + cloned = Client( + endpoint=self.endpoint, + project=self.project, + api_key=self.api_key, + is_skipped_an_issue=self.is_skipped_an_issue, + verify_ssl=self.verify_ssl, + retries=self.retries, + max_pool_size=self.max_pool_size, + http_timeout=self.http_timeout, + keepalive_timeout=self.keepalive_timeout, + mode=self.mode, + launch_uuid_print=self.launch_uuid_print, + print_output=self.print_output + ) + return cloned + + def __getstate__(self) -> Dict[str, Any]: + """Control object pickling and return object fields as Dictionary. + + :return: object state dictionary + :rtype: dict + """ + state = self.__dict__.copy() + # Don't pickle 'session' field, since it contains unpickling 'socket' + del state['_session'] + return state + + def __setstate__(self, state: Dict[str, Any]) -> None: + """Control object pickling, receives object state as Dictionary. + + :param dict state: object state dictionary + """ + self.__dict__.update(state) + + +class AsyncRPClient(RP): + """Asynchronous ReportPortal Client. + + This class implements common RP client interface but all its methods are async, so it capable to use in + asynchronous ReportPortal agents. It handles HTTP request and response bodies generation and + serialization, connection retries and log batching. + """ + + log_batch_size: int + log_batch_payload_limit: int + _item_stack: LifoQueue + _log_batcher: LogBatcher + __client: Client + __launch_uuid: Optional[str] + __step_reporter: StepReporter + use_own_launch: bool + + @property + def client(self) -> Client: + """Return current Client instance. + + :return: Client instance. + """ + return self.__client + + @property + def launch_uuid(self) -> Optional[str]: + """Return current Launch UUID. + + :return: UUID string. + """ + return self.__launch_uuid + + @property + def endpoint(self) -> str: + """Return current base URL. + + :return: base URL string. + """ + return self.__endpoint + + @property + def project(self) -> str: + """Return current Project name. + + :return: Project name string. + """ + return self.__project + + @property + def step_reporter(self) -> StepReporter: + """Return StepReporter object for the current launch. + + :return: StepReporter to report steps. + """ + return self.__step_reporter + + def __init__( + self, + endpoint: str, + project: str, + *, + client: Optional[Client] = None, + launch_uuid: Optional[str] = None, + log_batch_size: int = 20, + log_batch_payload_limit: int = MAX_LOG_BATCH_PAYLOAD_SIZE, + log_batcher: Optional[LogBatcher] = None, + **kwargs: Any + ) -> None: + """Initialize the class instance with arguments. + + :param endpoint: Endpoint of the ReportPortal service. + :param project: Project name to report to. + :param api_key: Authorization API key. + :param is_skipped_an_issue: Option to mark skipped tests as not 'To Investigate' items on the + server side. + :param verify_ssl: Option to skip ssl verification. + :param retries: Number of retry attempts to make in case of connection / server + errors. + :param max_pool_size: Option to set the maximum number of connections to save the pool. + :param http_timeout: A float in seconds for connect and read timeout. Use a Tuple to + specific connect and read separately. + :param keepalive_timeout: Maximum amount of idle time in seconds before force connection + closing. + :param mode: Launch mode, all Launches started by the client will be in that mode. + :param launch_uuid_print: Print Launch UUID into passed TextIO or by default to stdout. + :param print_output: Set output stream for Launch UUID printing. + :param client: ReportPortal async Client instance to use. If set, all above arguments + will be ignored. + :param launch_uuid: A launch UUID to use instead of starting own one. + :param log_batch_size: Option to set the maximum number of logs that can be processed in one + batch. + :param log_batch_payload_limit: maximum size in bytes of logs that can be processed in one batch + :param log_batcher: ReportPortal log batcher instance to use. If set, 'log_batch' + arguments above will be ignored. + """ + self.__endpoint = endpoint + self.__project = project + self.__step_reporter = StepReporter(self) + self._item_stack = LifoQueue() + self.log_batch_size = log_batch_size + self.log_batch_payload_limit = log_batch_payload_limit + self._log_batcher = log_batcher or LogBatcher(log_batch_size, log_batch_payload_limit) + if client: + self.__client = client + else: + self.__client = Client(endpoint, project, **kwargs) + if launch_uuid: + self.__launch_uuid = launch_uuid + self.use_own_launch = False + else: + self.use_own_launch = True + set_current(self) + + async def start_launch(self, + name: str, + start_time: str, + description: Optional[str] = None, + attributes: Optional[Union[list, dict]] = None, + rerun: bool = False, + rerun_of: Optional[str] = None, + **kwargs) -> Optional[str]: + """Start a new Launch with the given arguments. + + :param name: Launch name. + :param start_time: Launch start time. + :param description: Launch description. + :param attributes: Launch attributes. + :param rerun: Start launch in rerun mode. + :param rerun_of: For rerun mode specifies which launch will be re-run. Should be used with the + 'rerun' option. + :return: Launch UUID if successfully started or None. + """ + 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) + self.__launch_uuid = launch_uuid + return launch_uuid + + async def start_test_item(self, + name: str, + start_time: str, + item_type: str, + description: Optional[str] = None, + attributes: Optional[List[dict]] = None, + parameters: Optional[dict] = None, + parent_item_id: Optional[str] = None, + has_stats: bool = True, + code_ref: Optional[str] = None, + retry: bool = False, + test_case_id: Optional[str] = None, + **kwargs: Any) -> Optional[str]: + """Start Test Case/Suite/Step/Nested Step Item. + + :param name: Name of the Test Item. + :param start_time: The Item start time. + :param item_type: Type of the Test Item. Allowed values: + "suite", "story", "test", "scenario", "step", "before_class", "before_groups", + "before_method", "before_suite", "before_test", "after_class", "after_groups", + "after_method", "after_suite", "after_test". + :param description: The Item description. + :param attributes: Test Item attributes. + :param parameters: Set of parameters (for parametrized Test Items). + :param parent_item_id: A UUID of a parent SUITE / STEP. + :param has_stats: Set to False if test item is a Nested Step. + :param code_ref: Physical location of the Test Item. + :param retry: Used to report retry of the test. Allowed values: "True" or "False". + :param test_case_id: A unique ID of the current Step. + :return: Test Item UUID if successfully started or None. + """ + item_id = await self.__client.start_test_item(self.launch_uuid, name, start_time, item_type, + description=description, attributes=attributes, + parameters=parameters, parent_item_id=parent_item_id, + has_stats=has_stats, code_ref=code_ref, retry=retry, + test_case_id=test_case_id, **kwargs) + if item_id and item_id is not NOT_FOUND: + logger.debug('start_test_item - ID: %s', item_id) + self._add_current_item(item_id) + return item_id + + async def finish_test_item(self, + item_id: str, + end_time: str, + status: str = None, + issue: Optional[Issue] = None, + attributes: Optional[Union[list, dict]] = None, + description: str = None, + retry: bool = False, + **kwargs: Any) -> Optional[str]: + """Finish Test Suite/Case/Step/Nested Step Item. + + :param item_id: ID of the Test Item. + :param end_time: The Item end time. + :param status: Test status. Allowed values: + PASSED, FAILED, STOPPED, SKIPPED, INTERRUPTED, CANCELLED, INFO, WARN or None. + :param issue: Issue which will be attached to the current Item. + :param attributes: Test Item attributes(tags). Pairs of key and value. These attributes override + attributes on start Test Item call. + :param description: Test Item description. Overrides description from start request. + :param retry: Used to report retry of the test. Allowed values: "True" or "False". + :return: Response message. + """ + result = await self.__client.finish_test_item(self.launch_uuid, item_id, end_time, status=status, + issue=issue, attributes=attributes, + description=description, + retry=retry, **kwargs) + self._remove_current_item() + return result + + async def finish_launch(self, + end_time: str, + status: str = None, + attributes: Optional[Union[list, dict]] = None, + **kwargs: Any) -> Optional[str]: + """Finish a Launch. + + :param end_time: Launch end time. + :param status: Launch status. Can be one of the followings: + PASSED, FAILED, STOPPED, SKIPPED, INTERRUPTED, CANCELLED. + :param attributes: Launch attributes. These attributes override attributes on Start Launch call. + :return: Response message or None. + """ + if self.use_own_launch: + result = await self.__client.finish_launch(self.launch_uuid, end_time, status=status, + attributes=attributes, + **kwargs) + else: + result = "" + await self.__client.log_batch(self._log_batcher.flush()) + await self.close() + return result + + async def update_test_item( + self, + item_uuid: str, + attributes: Optional[Union[list, dict]] = None, + description: Optional[str] = None + ) -> Optional[str]: + """Update existing Test Item at the ReportPortal. + + :param item_uuid: Test Item UUID returned on the item start. + :param attributes: Test Item attributes: [{'key': 'k_name', 'value': 'k_value'}, ...]. + :param description: Test Item description. + :return: Response message or None. + """ + return await self.__client.update_test_item(item_uuid, attributes=attributes, description=description) + + def _add_current_item(self, item: str) -> None: + """Add the last item from the self._items queue.""" + self._item_stack.put(item) + + def _remove_current_item(self) -> Optional[str]: + """Remove the last item from the self._items queue.""" + return self._item_stack.get() + + def current_item(self) -> Optional[str]: + """Retrieve the last Item reported by the client (based on the internal FILO queue). + + :return: Item UUID string. + """ + return self._item_stack.last() + + async def get_launch_info(self) -> Optional[dict]: + """Get current Launch information. + + :return: Launch information in dictionary. + """ + if not self.launch_uuid: + return {} + return await self.__client.get_launch_info(self.launch_uuid) + + async def get_item_id_by_uuid(self, item_uuid: str) -> Optional[str]: + """Get Test Item ID by the given Item UUID. + + :param item_uuid: String UUID returned on the Item start. + :return: Test Item ID. + """ + return await self.__client.get_item_id_by_uuid(item_uuid) + + async def get_launch_ui_id(self) -> Optional[int]: + """Get Launch ID of the current Launch. + + :return: Launch ID of the Launch. None if not found. + """ + if not self.launch_uuid: + return + return await self.__client.get_launch_ui_id(self.launch_uuid) + + async def get_launch_ui_url(self) -> Optional[str]: + """Get full quality URL of the current Launch. + + :return: Launch URL string. + """ + if not self.launch_uuid: + return + return await self.__client.get_launch_ui_url(self.launch_uuid) + + async def get_project_settings(self) -> Optional[dict]: + """Get settings of the current Project. + + :return: Settings response in Dictionary. + """ + return await self.__client.get_project_settings() + + async def log( + self, + time: str, + message: str, + level: Optional[Union[int, str]] = None, + attachment: Optional[dict] = None, + item_id: Optional[str] = None + ) -> Optional[Tuple[str, ...]]: + """Send Log message to the ReportPortal and attach it to a Test Item or Launch. + + This method stores Log messages in internal batch and sent it when batch is full, so not every method + call will return any response. + + :param time: Time in UTC. + :param message: Log message text. + :param level: Message's Log level. + :param attachment: Message's attachments(images,files,etc.). + :param item_id: UUID of the ReportPortal Item the message belongs to. + :return: Response message Tuple if Log message batch was sent or None. + """ + if item_id is NOT_FOUND: + logger.warning("Attempt to log to non-existent item") + return + rp_file = RPFile(**attachment) if attachment else None + rp_log = AsyncRPRequestLog(self.launch_uuid, time, rp_file, item_id, level, message) + return await self.__client.log_batch(await self._log_batcher.append_async(rp_log)) + + def clone(self) -> 'AsyncRPClient': + """Clone the Client object, set current Item ID as cloned Item ID. + + :return: Cloned client object + :rtype: AsyncRPClient. + """ + cloned_client = self.__client.clone() + # noinspection PyTypeChecker + cloned = AsyncRPClient( + endpoint=self.endpoint, + project=self.project, + client=cloned_client, + launch_uuid=self.launch_uuid, + log_batch_size=self.log_batch_size, + log_batch_payload_limit=self.log_batch_payload_limit, + log_batcher=self._log_batcher + ) + current_item = self.current_item() + if current_item: + cloned._add_current_item(current_item) + return cloned + + async def close(self) -> None: + """Close current client connections.""" + await self.__client.close() + + +class _RPClient(RP, metaclass=AbstractBaseClass): + """Base class for different synchronous to asynchronous client implementations.""" + + __metaclass__ = AbstractBaseClass + + log_batch_size: int + log_batch_payload_limit: int + own_launch: bool + own_client: bool + _item_stack: LifoQueue + _log_batcher: LogBatcher + __client: Client + __launch_uuid: Optional[Task[str]] + __endpoint: str + __project: str + __step_reporter: StepReporter + + @property + def client(self) -> Client: + """Return current Client instance. + + :return: Client instance. + """ + return self.__client + + @property + def launch_uuid(self) -> Optional[Task[str]]: + """Return current Launch UUID. + + :return: UUID string. + """ + return self.__launch_uuid + + @property + def endpoint(self) -> str: + """Return current base URL. + + :return: base URL string. + """ + return self.__endpoint + + @property + def project(self) -> str: + """Return current Project name. + + :return: Project name string. + """ + return self.__project + + @property + def step_reporter(self) -> StepReporter: + """Return StepReporter object for the current launch. + + :return: StepReporter to report steps. + """ + return self.__step_reporter + + def __init__( + self, + endpoint: str, + project: str, + *, + client: Optional[Client] = 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, + **kwargs: Any + ) -> None: + """Initialize the class instance with arguments. + + :param endpoint: Endpoint of the ReportPortal service. + :param project: Project name to report to. + :param api_key: Authorization API key. + :param is_skipped_an_issue: Option to mark skipped tests as not 'To Investigate' items on the + server side. + :param verify_ssl: Option to skip ssl verification. + :param retries: Number of retry attempts to make in case of connection / server + errors. + :param max_pool_size: Option to set the maximum number of connections to save the pool. + :param http_timeout: A float in seconds for connect and read timeout. Use a Tuple to + specific connect and read separately. + :param keepalive_timeout: Maximum amount of idle time in seconds before force connection + closing. + :param mode: Launch mode, all Launches started by the client will be in that mode. + :param launch_uuid_print: Print Launch UUID into passed TextIO or by default to stdout. + :param print_output: Set output stream for Launch UUID printing. + :param client: ReportPortal async Client instance to use. If set, all above arguments + will be ignored. + :param launch_uuid: A launch UUID to use instead of starting own one. + :param log_batch_size: Option to set the maximum number of logs that can be processed in one + batch. + :param log_batch_payload_limit: maximum size in bytes of logs that can be processed in one batch + :param log_batcher: ReportPortal log batcher instance to use. If set, 'log_batch' + arguments above will be ignored. + """ + self.__endpoint = endpoint + self.__project = project + self.__step_reporter = StepReporter(self) + self._item_stack = LifoQueue() + + self.log_batch_size = log_batch_size + self.log_batch_payload_limit = log_batch_payload_limit + self._log_batcher = log_batcher or LogBatcher(log_batch_size, log_batch_payload_limit) + + if client: + self.__client = client + self.own_client = False + else: + self.__client = Client(endpoint, project, **kwargs) + self.own_client = False + + if launch_uuid: + self.__launch_uuid = launch_uuid + self.own_launch = False + else: + self.own_launch = True + + set_current(self) + + @abstractmethod + def create_task(self, coro: Coroutine[Any, Any, _T]) -> Optional[Task[_T]]: + """Create a Task from given Coroutine. + + :param coro: Coroutine which will be used for the Task creation. + :return: Task instance. + """ + raise NotImplementedError('"create_task" method is not implemented!') + + @abstractmethod + def finish_tasks(self) -> None: + """Ensure all pending Tasks are finished, block current Thread if necessary.""" + raise NotImplementedError('"create_task" method is not implemented!') + + def _add_current_item(self, item: Task[_T]) -> None: + """Add the last Item to the internal FILO queue. + + :param item: Future Task of the Item UUID. + """ + self._item_stack.put(item) + + def _remove_current_item(self) -> Task[_T]: + """Remove the last Item from the internal FILO queue. + + :return: Future Task of the Item UUID. + """ + return self._item_stack.get() + + def current_item(self) -> Task[_T]: + """Retrieve the last Item reported by the client (based on the internal FILO queue). + + :return: Future Task of the Item UUID. + """ + return self._item_stack.last() + + async def __empty_str(self): + return "" + + async def __empty_dict(self): + return {} + + async def __int_value(self): + return -1 + + def start_launch(self, + name: str, + start_time: str, + description: Optional[str] = None, + attributes: Optional[Union[list, dict]] = None, + rerun: bool = False, + rerun_of: Optional[str] = None, + **kwargs) -> Task[str]: + """Start a new Launch with the given arguments. + + :param name: Launch name. + :param start_time: Launch start time. + :param description: Launch description. + :param attributes: Launch attributes. + :param rerun: Start launch in rerun mode. + :param rerun_of: For rerun mode specifies which launch will be re-run. Should be used with the + 'rerun' option. + :return: Launch UUID if successfully started or None. + """ + 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) + self.__launch_uuid = self.create_task(launch_uuid_coro) + return self.launch_uuid + + def start_test_item(self, + name: str, + start_time: str, + item_type: str, + description: Optional[str] = None, + attributes: Optional[List[dict]] = None, + parameters: Optional[dict] = None, + parent_item_id: Optional[Task[str]] = None, + has_stats: bool = True, + code_ref: Optional[str] = None, + retry: bool = False, + test_case_id: Optional[str] = None, + **kwargs: Any) -> Task[str]: + """Start Test Case/Suite/Step/Nested Step Item. + + :param name: Name of the Test Item. + :param start_time: The Item start time. + :param item_type: Type of the Test Item. Allowed values: + "suite", "story", "test", "scenario", "step", "before_class", "before_groups", + "before_method", "before_suite", "before_test", "after_class", "after_groups", + "after_method", "after_suite", "after_test". + :param description: The Item description. + :param attributes: Test Item attributes. + :param parameters: Set of parameters (for parametrized Test Items). + :param parent_item_id: A UUID of a parent SUITE / STEP. + :param has_stats: Set to False if test item is a Nested Step. + :param code_ref: Physical location of the Test Item. + :param retry: Used to report retry of the test. Allowed values: "True" or "False". + :param test_case_id: A unique ID of the current Step. + :return: Test Item UUID if successfully started or None. + """ + item_id_coro = self.__client.start_test_item(self.launch_uuid, name, start_time, item_type, + description=description, attributes=attributes, + parameters=parameters, parent_item_id=parent_item_id, + has_stats=has_stats, code_ref=code_ref, retry=retry, + test_case_id=test_case_id, **kwargs) + item_id_task = self.create_task(item_id_coro) + self._add_current_item(item_id_task) + return item_id_task + + def finish_test_item(self, + item_id: Task[str], + end_time: str, + status: str = None, + issue: Optional[Issue] = None, + attributes: Optional[Union[list, dict]] = None, + description: str = None, + retry: bool = False, + **kwargs: Any) -> Task[str]: + """Finish Test Suite/Case/Step/Nested Step Item. + + :param item_id: ID of the Test Item. + :param end_time: The Item end time. + :param status: Test status. Allowed values: + PASSED, FAILED, STOPPED, SKIPPED, INTERRUPTED, CANCELLED, INFO, WARN or None. + :param issue: Issue which will be attached to the current Item. + :param attributes: Test Item attributes(tags). Pairs of key and value. These attributes override + attributes on start Test Item call. + :param description: Test Item description. Overrides description from start request. + :param retry: Used to report retry of the test. Allowed values: "True" or "False". + :return: Response message. + """ + result_coro = self.__client.finish_test_item(self.launch_uuid, item_id, end_time, status=status, + issue=issue, attributes=attributes, + description=description, + retry=retry, **kwargs) + result_task = self.create_task(result_coro) + self._remove_current_item() + return result_task + + def finish_launch(self, + end_time: str, + status: str = None, + attributes: Optional[Union[list, dict]] = None, + **kwargs: Any) -> Task[str]: + """Finish a Launch. + + :param end_time: Launch end time. + :param status: Launch status. Can be one of the followings: + PASSED, FAILED, STOPPED, SKIPPED, INTERRUPTED, CANCELLED. + :param attributes: Launch attributes. These attributes override attributes on Start Launch call. + :return: Response message or None. + """ + 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) + else: + result_coro = self.__empty_str() + + result_task = self.create_task(result_coro) + self.finish_tasks() + return result_task + + def update_test_item(self, + item_uuid: Task[str], + attributes: Optional[Union[list, dict]] = None, + description: Optional[str] = None) -> Task: + """Update existing Test Item at the ReportPortal. + + :param item_uuid: Test Item UUID returned on the item start. + :param attributes: Test Item attributes: [{'key': 'k_name', 'value': 'k_value'}, ...]. + :param description: Test Item description. + :return: Response message or None. + """ + result_coro = self.__client.update_test_item(item_uuid, attributes=attributes, + description=description) + result_task = self.create_task(result_coro) + return result_task + + def get_launch_info(self) -> Task[dict]: + """Get current Launch information. + + :return: Launch information in dictionary. + """ + if not self.launch_uuid: + return self.create_task(self.__empty_dict()) + result_coro = self.__client.get_launch_info(self.launch_uuid) + result_task = self.create_task(result_coro) + return result_task + + def get_item_id_by_uuid(self, item_uuid_future: Task[str]) -> Task[str]: + """Get Test Item ID by the given Item UUID. + + :param item_uuid_future: Str or Task UUID returned on the Item start. + :return: Test Item ID. + """ + result_coro = self.__client.get_item_id_by_uuid(item_uuid_future) + result_task = self.create_task(result_coro) + return result_task + + def get_launch_ui_id(self) -> Task[int]: + """Get Launch ID of the current Launch. + + :return: Launch ID of the Launch. None if not found. + """ + if not self.launch_uuid: + return self.create_task(self.__int_value()) + result_coro = self.__client.get_launch_ui_id(self.launch_uuid) + result_task = self.create_task(result_coro) + return result_task + + def get_launch_ui_url(self) -> Task[str]: + """Get full quality URL of the current Launch. + + :return: Launch URL string. + """ + if not self.launch_uuid: + return self.create_task(self.__empty_str()) + result_coro = self.__client.get_launch_ui_url(self.launch_uuid) + result_task = self.create_task(result_coro) + return result_task + + def get_project_settings(self) -> Task[dict]: + """Get settings of the current Project. + + :return: Settings response in Dictionary. + """ + result_coro = self.__client.get_project_settings() + 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) + + async def _log(self, log_rq: AsyncRPRequestLog) -> Optional[Tuple[str, ...]]: + return await self._log_batch(await self._log_batcher.append_async(log_rq)) + + def log(self, time: str, message: str, level: Optional[Union[int, str]] = None, + attachment: Optional[dict] = None, item_id: Optional[Task[str]] = None) -> Task[Tuple[str, ...]]: + """Send Log message to the ReportPortal and attach it to a Test Item or Launch. + + This method stores Log messages in internal batch and sent it when batch is full, so not every method + call will return any response. + + :param time: Time in UTC. + :param message: Log message text. + :param level: Message's Log level. + :param attachment: Message's attachments(images,files,etc.). + :param item_id: UUID of the ReportPortal Item the message belongs to. + :return: Response message Tuple if Log message batch was sent or None. + """ + rp_file = RPFile(**attachment) if attachment else None + rp_log = AsyncRPRequestLog(self.launch_uuid, time, rp_file, item_id, level, message) + return self.create_task(self._log(rp_log)) + + def close(self) -> None: + """Close current client connections.""" + if self.own_client: + self.create_task(self.__client.close()).blocking_result() + + +class ThreadedRPClient(_RPClient): + """Synchronous-asynchronous ReportPortal Client which uses background Thread to execute async coroutines. + + This class implements common RP client interface, so it capable to use in synchronous ReportPortal Agents + if you want to achieve async performance level with synchronous code. It handles HTTP request and response + bodies generation and serialization, connection retries and log batching. + """ + + task_timeout: float + shutdown_timeout: float + _task_list: BackgroundTaskList[Task[_T]] + _task_mutex: threading.RLock + _loop: Optional[asyncio.AbstractEventLoop] + _thread: Optional[threading.Thread] + + def __init_task_list(self, task_list: Optional[BackgroundTaskList[Task[_T]]] = None, + task_mutex: Optional[threading.RLock] = None): + if task_list: + if not task_mutex: + warnings.warn( + '"task_list" argument is set, but not "task_mutex". This usually indicates ' + 'invalid use, since "task_mutex" is used to synchronize on "task_list".', + RuntimeWarning, + 3 + ) + self._task_list = task_list or BackgroundTaskList() + self._task_mutex = task_mutex or threading.RLock() + + def __heartbeat(self): + # We operate on our own loop with daemon thread, so we will exit in any way when main thread exit, + # so we can iterate forever + self._loop.call_at(self._loop.time() + 0.1, self.__heartbeat) + + def __init_loop(self, loop: Optional[asyncio.AbstractEventLoop] = None): + self._thread = None + if loop: + self._loop = loop + else: + 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 + + def __init__( + self, + endpoint: str, + project: str, + *, + task_timeout: float = DEFAULT_TASK_TIMEOUT, + shutdown_timeout: float = DEFAULT_SHUTDOWN_TIMEOUT, + launch_uuid: Optional[Union[str, Task[str]]] = None, + task_list: Optional[BackgroundTaskList[Task[_T]]] = None, + task_mutex: Optional[threading.RLock] = None, + loop: Optional[asyncio.AbstractEventLoop] = None, + **kwargs: Any + ) -> None: + """Initialize the class instance with arguments. + + :param endpoint: Endpoint of the ReportPortal service. + :param project: Project name to report to. + :param api_key: Authorization API key. + :param is_skipped_an_issue: Option to mark skipped tests as not 'To Investigate' items on the + server side. + :param verify_ssl: Option to skip ssl verification. + :param retries: Number of retry attempts to make in case of connection / server + errors. + :param max_pool_size: Option to set the maximum number of connections to save the pool. + :param http_timeout: A float in seconds for connect and read timeout. Use a Tuple to + specific connect and read separately. + :param keepalive_timeout: Maximum amount of idle time in seconds before force connection + closing. + :param mode: Launch mode, all Launches started by the client will be in that mode. + :param launch_uuid_print: Print Launch UUID into passed TextIO or by default to stdout. + :param print_output: Set output stream for Launch UUID printing. + :param client: ReportPortal async Client instance to use. If set, all above arguments + will be ignored. + :param launch_uuid: A launch UUID to use instead of starting own one. + :param log_batch_size: Option to set the maximum number of logs that can be processed in one + batch. + :param log_batch_payload_limit: maximum size in bytes of logs that can be processed in one batch + :param log_batcher: ReportPortal log batcher instance to use. If set, 'log_batch' + arguments above will be ignored. + :param task_timeout: Time limit in seconds for a Task processing. + :param shutdown_timeout: Time limit in seconds for shutting down internal Tasks. + :param task_list: Thread-safe Task list to have one task storage for multiple Clients + which guarantees their processing on Launch finish. The Client creates + own Task list if this argument is None. + :param task_mutex: Mutex object which is responsible for synchronization of the passed + task_list. The Client creates own one if this argument is None. + :param loop: Event Loop which is used to process Tasks. The Client creates own one + if this argument is None. + """ + self.task_timeout = task_timeout + self.shutdown_timeout = shutdown_timeout + self.__init_task_list(task_list, task_mutex) + self.__init_loop(loop) + if type(launch_uuid) == str: + super().__init__(endpoint, project, + launch_uuid=self.create_task(self.__return_value(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]]: + """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 + result = self._loop.create_task(coro) + with self._task_mutex: + self._task_list.append(result) + return result + + def finish_tasks(self): + """Ensure all pending Tasks are finished, block current Thread if necessary.""" + shutdown_start_time = datetime.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: + break + logs = self._log_batcher.flush() + if logs: + self._loop.create_task(self._log_batch(logs)).blocking_result() + + def clone(self) -> 'ThreadedRPClient': + """Clone the Client object, set current Item ID as cloned Item ID. + + :return: Cloned client object. + :rtype: ThreadedRPClient + """ + # noinspection PyTypeChecker + cloned = ThreadedRPClient( + endpoint=self.endpoint, + project=self.project, + launch_uuid=self.launch_uuid, + client=self.client, + log_batch_size=self.log_batch_size, + log_batch_payload_limit=self.log_batch_payload_limit, + log_batcher=self._log_batcher, + task_timeout=self.task_timeout, + shutdown_timeout=self.shutdown_timeout, + task_mutex=self._task_mutex, + task_list=self._task_list, + loop=self._loop + ) + current_item = self.current_item() + if current_item: + cloned._add_current_item(current_item) + return cloned + + def __getstate__(self) -> Dict[str, Any]: + """Control object pickling and return object fields as Dictionary. + + :return: object state dictionary + :rtype: dict + """ + state = self.__dict__.copy() + # Don't pickle 'session' field, since it contains unpickling 'socket' + del state['_task_mutex'] + del state['_loop'] + del state['_thread'] + return state + + def __setstate__(self, state: Dict[str, Any]) -> None: + """Control object pickling, receives object state as Dictionary. + + :param dict state: object state dictionary + """ + self.__dict__.update(state) + self.__init_task_list(self._task_list, threading.RLock()) + self.__init_loop() + + +class BatchedRPClient(_RPClient): + """Synchronous-asynchronous ReportPortal Client which uses the same Thread to execute async coroutines. + + This class implements common RP client interface, so it capable to use in synchronous ReportPortal Agents + if you want to achieve async performance level with synchronous code. It handles HTTP request and response + bodies generation and serialization, connection retries and log batching. + """ + + task_timeout: float + shutdown_timeout: float + trigger_num: int + trigger_interval: float + _loop: asyncio.AbstractEventLoop + _task_mutex: threading.RLock + _task_list: TriggerTaskBatcher[Task[_T]] + __last_run_time: float + + def __init_task_list(self, task_list: Optional[TriggerTaskBatcher[Task[_T]]] = None, + task_mutex: Optional[threading.RLock] = None): + if task_list: + if not task_mutex: + warnings.warn( + '"task_list" argument is set, but not "task_mutex". This usually indicates ' + 'invalid use, since "task_mutex" is used to synchronize on "task_list".', + RuntimeWarning, + 3 + ) + self._task_list = task_list or TriggerTaskBatcher(self.trigger_num, self.trigger_interval) + self._task_mutex = task_mutex or threading.RLock() + + def __init_loop(self, loop: Optional[asyncio.AbstractEventLoop] = None): + if loop: + self._loop = loop + else: + 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, + project: str, + *, + task_timeout: float = DEFAULT_TASK_TIMEOUT, + shutdown_timeout: float = DEFAULT_SHUTDOWN_TIMEOUT, + launch_uuid: Optional[Union[str, Task[str]]] = None, + task_list: Optional[TriggerTaskBatcher] = None, + task_mutex: Optional[threading.RLock] = None, + loop: Optional[asyncio.AbstractEventLoop] = None, + trigger_num: int = DEFAULT_TASK_TRIGGER_NUM, + trigger_interval: float = DEFAULT_TASK_TRIGGER_INTERVAL, + **kwargs: Any + ) -> None: + """Initialize the class instance with arguments. + + :param endpoint: Endpoint of the ReportPortal service. + :param project: Project name to report to. + :param api_key: Authorization API key. + :param is_skipped_an_issue: Option to mark skipped tests as not 'To Investigate' items on the + server side. + :param verify_ssl: Option to skip ssl verification. + :param retries: Number of retry attempts to make in case of connection / server + errors. + :param max_pool_size: Option to set the maximum number of connections to save the pool. + :param http_timeout: A float in seconds for connect and read timeout. Use a Tuple to + specific connect and read separately. + :param keepalive_timeout: Maximum amount of idle time in seconds before force connection + closing. + :param mode: Launch mode, all Launches started by the client will be in that mode. + :param launch_uuid_print: Print Launch UUID into passed TextIO or by default to stdout. + :param print_output: Set output stream for Launch UUID printing. + :param client: ReportPortal async Client instance to use. If set, all above arguments + will be ignored. + :param launch_uuid: A launch UUID to use instead of starting own one. + :param log_batch_size: Option to set the maximum number of logs that can be processed in one + batch. + :param log_batch_payload_limit: maximum size in bytes of logs that can be processed in one batch + :param log_batcher: ReportPortal log batcher instance to use. If set, 'log_batch' + arguments above will be ignored. + :param task_timeout: Time limit in seconds for a Task processing. + :param shutdown_timeout: Time limit in seconds for shutting down internal Tasks. + :param task_list: Batching Task list to have one task storage for multiple Clients + which guarantees their processing on Launch finish. The Client creates + own Task list if this argument is None. + :param task_mutex: Mutex object which is responsible for synchronization of the passed + task_list. The Client creates own one if this argument is None. + :param loop: Event Loop which is used to process Tasks. The Client creates own one + if this argument is None. + :param trigger_num: Number of tasks which triggers Task batch execution. + :param trigger_interval: Time limit which triggers Task batch execution. + """ + self.task_timeout = task_timeout + self.shutdown_timeout = shutdown_timeout + 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.__init_loop(loop) + if type(launch_uuid) == str: + super().__init__(endpoint, project, + launch_uuid=self.create_task(self.__return_value(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]]: + """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 + result = self._loop.create_task(coro) + with self._task_mutex: + tasks = self._task_list.append(result) + if tasks: + self._loop.run_until_complete(asyncio.wait(tasks, timeout=self.task_timeout)) + return result + + def finish_tasks(self) -> None: + """Ensure all pending Tasks are finished, block current Thread if necessary.""" + with self._task_mutex: + tasks = self._task_list.flush() + if tasks: + self._loop.run_until_complete(asyncio.wait(tasks, timeout=self.shutdown_timeout)) + logs = self._log_batcher.flush() + if logs: + log_task = self._loop.create_task(self._log_batch(logs)) + self._loop.run_until_complete(log_task) + + def clone(self) -> 'BatchedRPClient': + """Clone the Client object, set current Item ID as cloned Item ID. + + :return: Cloned client object. + :rtype: BatchedRPClient + """ + # noinspection PyTypeChecker + cloned = BatchedRPClient( + endpoint=self.endpoint, + project=self.project, + launch_uuid=self.launch_uuid, + client=self.client, + log_batch_size=self.log_batch_size, + log_batch_payload_limit=self.log_batch_payload_limit, + log_batcher=self._log_batcher, + task_timeout=self.task_timeout, + shutdown_timeout=self.shutdown_timeout, + task_list=self._task_list, + task_mutex=self._task_mutex, + loop=self._loop, + trigger_num=self.trigger_num, + trigger_interval=self.trigger_interval + ) + current_item = self.current_item() + if current_item: + cloned._add_current_item(current_item) + return cloned + + def __getstate__(self) -> Dict[str, Any]: + """Control object pickling and return object fields as Dictionary. + + :return: object state dictionary + :rtype: dict + """ + state = self.__dict__.copy() + # Don't pickle 'session' field, since it contains unpickling 'socket' + del state['_task_mutex'] + del state['_loop'] + return state + + def __setstate__(self, state: Dict[str, Any]) -> None: + """Control object pickling, receives object state as Dictionary. + + :param dict state: object state dictionary + """ + self.__dict__.update(state) + self.__init_task_list(self._task_list, threading.RLock()) + self.__init_loop() diff --git a/reportportal_client/aio/tasks.py b/reportportal_client/aio/tasks.py new file mode 100644 index 00000000..0afc77ac --- /dev/null +++ b/reportportal_client/aio/tasks.py @@ -0,0 +1,86 @@ +# Copyright (c) 2023 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 customized asynchronous Tasks and Task Factories for the ReportPortal client.""" + +import asyncio +import sys +from abc import abstractmethod +from asyncio import Future +from typing import TypeVar, Generic, Union, Generator, Awaitable, Optional + +# noinspection PyProtectedMember +from reportportal_client._internal.static.abstract import AbstractBaseClass + +_T = TypeVar('_T') + + +class BlockingOperationError(RuntimeError): + """An issue with task blocking execution.""" + + +class Task(Generic[_T], asyncio.Task, metaclass=AbstractBaseClass): + """Base class for ReportPortal client tasks. + + Its main function to provide interface of 'blocking_result' method which is used to block current Thread + until the result computation. + """ + + __metaclass__ = AbstractBaseClass + + name: Optional[str] + + def __init__( + self, + coro: Union[Generator[Future, None, _T], Awaitable[_T]], + *, + loop: asyncio.AbstractEventLoop, + name: Optional[str] = None + ) -> None: + """Initialize an instance of the Task. + + :param coro: Future, Coroutine or a Generator of these objects, which will be executed + :param loop: Event Loop which will be used to execute the Task + :param name: the name of the task + """ + self.name = name + if sys.version_info < (3, 8): + super().__init__(coro, loop=loop) + else: + super().__init__(coro, loop=loop, name=name) + + @abstractmethod + def blocking_result(self) -> _T: + """Block current Thread and wait for the task result. + + :return: execution result or raise an error + """ + raise NotImplementedError('"blocking_result" method is not implemented!') + + def __repr__(self) -> str: + """Return the result's repr function output if the Task is completed, or the Task's if not. + + :return: canonical string representation of the result or the current Task + """ + if self.done(): + return repr(self.result()) + return super().__repr__() + + def __str__(self): + """Return the result's str function output if the Task is completed, or the Task's if not. + + :return: string object from the result or from the current Task + """ + if self.done(): + return str(self.result()) + return super().__str__() diff --git a/reportportal_client/client.py b/reportportal_client/client.py index 9bc757d1..1b5b73a1 100644 --- a/reportportal_client/client.py +++ b/reportportal_client/client.py @@ -1,5 +1,3 @@ -"""This module contains ReportPortal Client class.""" - # Copyright (c) 2023 EPAM Systems # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,76 +11,410 @@ # See the License for the specific language governing permissions and # limitations under the License +"""This module contains ReportPortal Client interface and synchronous implementation class.""" + import logging +import queue import sys import warnings +from abc import abstractmethod from os import getenv -from queue import LifoQueue -from typing import Union, Tuple, List, Dict, Any, Optional, TextIO +from typing import Union, Tuple, Any, Optional, TextIO, List, Dict +import aenum import requests from requests.adapters import HTTPAdapter, Retry, DEFAULT_RETRIES -from ._local import set_current -from .core.rp_issues import Issue -from .core.rp_requests import ( - HttpRequest, - ItemStartRequest, - ItemFinishRequest, - LaunchStartRequest, - LaunchFinishRequest -) -from .helpers import uri_join, verify_value_length -from .logs.log_manager import LogManager, MAX_LOG_BATCH_PAYLOAD_SIZE -from .services.statistics import send_event -from .static.defines import NOT_FOUND -from .steps import StepReporter +# noinspection PyProtectedMember +from reportportal_client._internal.local import set_current +# noinspection PyProtectedMember +from reportportal_client._internal.logs.batcher import LogBatcher +# noinspection PyProtectedMember +from reportportal_client._internal.services.statistics import send_event +# noinspection PyProtectedMember +from reportportal_client._internal.static.abstract import AbstractBaseClass +# noinspection PyProtectedMember +from reportportal_client._internal.static.defines import NOT_FOUND +from reportportal_client.core.rp_issues import Issue +from reportportal_client.core.rp_requests import (HttpRequest, ItemStartRequest, ItemFinishRequest, RPFile, + LaunchStartRequest, LaunchFinishRequest, RPRequestLog, + RPLogBatch) +from reportportal_client.helpers import uri_join, verify_value_length, agent_name_version, LifoQueue +from reportportal_client.logs import MAX_LOG_BATCH_PAYLOAD_SIZE +from reportportal_client.steps import StepReporter logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -class _LifoQueue(LifoQueue): - def last(self): - with self.mutex: - if self._qsize(): - return self.queue[-1] +class OutputType(aenum.Enum): + """Enum of possible print output types.""" + + STDOUT = aenum.auto() + STDERR = aenum.auto() + + def get_output(self) -> Optional[TextIO]: + """Return TextIO based on the current type.""" + if self == OutputType.STDOUT: + return sys.stdout + if self == OutputType.STDERR: + return sys.stderr + + +class RP(metaclass=AbstractBaseClass): + """Common interface for ReportPortal clients. + + This abstract class serves as common interface for different ReportPortal clients. It's implemented to + ease migration from version to version and to ensure that each particular client has the same methods. + """ + + __metaclass__ = AbstractBaseClass + + @property + @abstractmethod + def launch_uuid(self) -> Optional[str]: + """Return current Launch UUID. + + :return: UUID string. + """ + raise NotImplementedError('"launch_uuid" property is not implemented!') + + @property + def launch_id(self) -> Optional[str]: + """Return current Launch UUID. + + :return: UUID string. + """ + warnings.warn( + message='`launch_id` property is deprecated since 5.5.0 and will be subject for removing in the' + ' next major version. Use `launch_uuid` property instead.', + category=DeprecationWarning, + stacklevel=2 + ) + return self.launch_uuid + + @property + @abstractmethod + def endpoint(self) -> str: + """Return current base URL. + + :return: base URL string. + """ + raise NotImplementedError('"endpoint" property is not implemented!') + + @property + @abstractmethod + def project(self) -> str: + """Return current Project name. + + :return: Project name string. + """ + raise NotImplementedError('"project" property is not implemented!') + + @property + @abstractmethod + def step_reporter(self) -> StepReporter: + """Return StepReporter object for the current launch. + + :return: StepReporter to report steps. + """ + raise NotImplementedError('"step_reporter" property is not implemented!') + + @abstractmethod + def start_launch(self, + name: str, + start_time: str, + description: Optional[str] = None, + attributes: Optional[Union[list, dict]] = None, + rerun: bool = False, + rerun_of: Optional[str] = None, + **kwargs) -> Optional[str]: + """Start a new Launch with the given arguments. + + :param name: Launch name. + :param start_time: Launch start time. + :param description: Launch description. + :param attributes: Launch attributes. + :param rerun: Start launch in rerun mode. + :param rerun_of: For rerun mode specifies which launch will be re-run. Should be used with the + 'rerun' option. + :return: Launch UUID if successfully started or None. + """ + raise NotImplementedError('"start_launch" method is not implemented!') + + @abstractmethod + def start_test_item(self, + name: str, + start_time: str, + item_type: str, + description: Optional[str] = None, + attributes: Optional[Union[List[dict], dict]] = None, + parameters: Optional[dict] = None, + parent_item_id: Optional[str] = None, + has_stats: bool = True, + code_ref: Optional[str] = None, + retry: bool = False, + test_case_id: Optional[str] = None, + **kwargs: Any) -> Optional[str]: + """Start Test Case/Suite/Step/Nested Step Item. + + :param name: Name of the Test Item. + :param start_time: The Item start time. + :param item_type: Type of the Test Item. Allowed values: + "suite", "story", "test", "scenario", "step", "before_class", "before_groups", + "before_method", "before_suite", "before_test", "after_class", "after_groups", + "after_method", "after_suite", "after_test". + :param description: The Item description. + :param attributes: Test Item attributes. + :param parameters: Set of parameters (for parametrized Test Items). + :param parent_item_id: A UUID of a parent SUITE / STEP. + :param has_stats: Set to False if test item is a Nested Step. + :param code_ref: Physical location of the Test Item. + :param retry: Used to report retry of the test. Allowed values: "True" or "False". + :param test_case_id: A unique ID of the current Step. + :return: Test Item UUID if successfully started or None. + """ + raise NotImplementedError('"start_test_item" method is not implemented!') + + @abstractmethod + def finish_test_item(self, + item_id: str, + end_time: str, + status: str = None, + issue: Optional[Issue] = None, + attributes: Optional[Union[list, dict]] = None, + description: str = None, + retry: bool = False, + **kwargs: Any) -> Optional[str]: + """Finish Test Suite/Case/Step/Nested Step Item. + + :param item_id: ID of the Test Item. + :param end_time: The Item end time. + :param status: Test status. Allowed values: + PASSED, FAILED, STOPPED, SKIPPED, INTERRUPTED, CANCELLED, INFO, WARN or None. + :param issue: Issue which will be attached to the current Item. + :param attributes: Test Item attributes(tags). Pairs of key and value. These attributes override + attributes on start Test Item call. + :param description: Test Item description. Overrides description from start request. + :param retry: Used to report retry of the test. Allowed values: "True" or "False". + :return: Response message. + """ + raise NotImplementedError('"finish_test_item" method is not implemented!') + + @abstractmethod + def finish_launch(self, + end_time: str, + status: str = None, + attributes: Optional[Union[list, dict]] = None, + **kwargs: Any) -> Optional[str]: + """Finish a Launch. + + :param end_time: Launch end time. + :param status: Launch status. Can be one of the followings: + PASSED, FAILED, STOPPED, SKIPPED, INTERRUPTED, CANCELLED. + :param attributes: Launch attributes. These attributes override attributes on Start Launch call. + :return: Response message or None. + """ + raise NotImplementedError('"finish_launch" method is not implemented!') + + @abstractmethod + def update_test_item(self, + item_uuid: Optional[str], + attributes: Optional[Union[list, dict]] = None, + description: Optional[str] = None) -> Optional[str]: + """Update existing Test Item at the ReportPortal. + + :param item_uuid: Test Item UUID returned on the item start. + :param attributes: Test Item attributes: [{'key': 'k_name', 'value': 'k_value'}, ...]. + :param description: Test Item description. + :return: Response message or None. + """ + raise NotImplementedError('"update_test_item" method is not implemented!') + + @abstractmethod + def get_launch_info(self) -> Optional[dict]: + """Get current Launch information. + + :return: Launch information in dictionary. + """ + raise NotImplementedError('"get_launch_info" method is not implemented!') + + @abstractmethod + def get_item_id_by_uuid(self, item_uuid: str) -> Optional[str]: + """Get Test Item ID by the given Item UUID. + + :param item_uuid: String UUID returned on the Item start. + :return: Test Item ID. + """ + raise NotImplementedError('"get_item_id_by_uuid" method is not implemented!') + + @abstractmethod + def get_launch_ui_id(self) -> Optional[int]: + """Get Launch ID of the current Launch. + + :return: Launch ID of the Launch. None if not found. + """ + raise NotImplementedError('"get_launch_ui_id" method is not implemented!') + + @abstractmethod + def get_launch_ui_url(self) -> Optional[str]: + """Get full quality URL of the current Launch. + + :return: Launch URL string. + """ + raise NotImplementedError('"get_launch_ui_id" method is not implemented!') + + @abstractmethod + def get_project_settings(self) -> Optional[dict]: + """Get settings of the current Project. + + :return: Settings response in Dictionary. + """ + raise NotImplementedError('"get_project_settings" method is not implemented!') + + @abstractmethod + def log(self, + time: str, message: str, + level: Optional[Union[int, str]] = None, + attachment: Optional[dict] = None, + item_id: Optional[str] = None) -> Optional[Tuple[str, ...]]: + """Send Log message to the ReportPortal and attach it to a Test Item or Launch. + + This method stores Log messages in internal batch and sent it when batch is full, so not every method + call will return any response. + + :param time: Time in UTC. + :param message: Log message text. + :param level: Message's Log level. + :param attachment: Message's attachments(images,files,etc.). + :param item_id: UUID of the ReportPortal Item the message belongs to. + :return: Response message Tuple if Log message batch was sent or None. + """ + raise NotImplementedError('"log" method is not implemented!') + + @abstractmethod + def current_item(self) -> Optional[str]: + """Retrieve the last Item reported by the client (based on the internal FILO queue). + + :return: Item UUID string. + """ + raise NotImplementedError('"current_item" method is not implemented!') + + @abstractmethod + def clone(self) -> 'RP': + """Clone the Client object, set current Item ID as cloned Item ID. + + :return: Cloned client object. + :rtype: RP + """ + raise NotImplementedError('"clone" method is not implemented!') + + @abstractmethod + def close(self) -> None: + """Close current client connections.""" + raise NotImplementedError('"clone" method is not implemented!') + + def start(self) -> None: + """Start the client.""" + warnings.warn( + message='`start` method is deprecated since 5.5.0 and will be subject for removing in the' + ' next major version. There is no any necessity to call this method anymore.', + category=DeprecationWarning, + stacklevel=2 + ) + + def terminate(self, *_: Any, **__: Any) -> None: + """Call this to terminate the client.""" + warnings.warn( + message='`terminate` method is deprecated since 5.5.0 and will be subject for removing in the' + ' next major version. There is no any necessity to call this method anymore.', + category=DeprecationWarning, + stacklevel=2 + ) + self.close() -class RPClient: +class RPClient(RP): """ReportPortal client. - The class is supposed to use by ReportPortal agents: both custom and - official to make calls to RReportPortal. It handles HTTP request and - response bodies generation and serialization, connection retries and log - batching. - NOTICE: the class is not thread-safe, use new class instance for every new - thread to avoid request/response messing and other issues. + The class is supposed to use by ReportPortal agents: both custom and official, to make calls to + ReportPortal. It handles HTTP request and response bodies generation and serialization, connection retries + and log batching. """ - _log_manager: LogManager = ... - api_v1: str = ... - api_v2: str = ... - base_url_v1: str = ... - base_url_v2: str = ... - endpoint: str = ... - is_skipped_an_issue: bool = ... - launch_id: str = ... - log_batch_size: int = ... - log_batch_payload_size: int = ... - project: str = ... - api_key: str = ... - verify_ssl: Union[bool, str] = ... - retries: int = ... - max_pool_size: int = ... - http_timeout: Union[float, Tuple[float, float]] = ... - session: requests.Session = ... - step_reporter: StepReporter = ... - mode: str = ... - launch_uuid_print: Optional[bool] = ... - print_output: Optional[TextIO] = ... - _skip_analytics: str = ... - _item_stack: _LifoQueue = ... + api_v1: str + api_v2: str + base_url_v1: str + base_url_v2: str + __endpoint: str + is_skipped_an_issue: bool + __launch_uuid: str + use_own_launch: bool + log_batch_size: int + log_batch_payload_size: int + __project: str + api_key: str + verify_ssl: Union[bool, str] + retries: int + max_pool_size: int + http_timeout: Union[float, Tuple[float, float]] + session: requests.Session + __step_reporter: StepReporter + mode: str + launch_uuid_print: Optional[bool] + print_output: OutputType + _skip_analytics: str + _item_stack: LifoQueue + _log_batcher: LogBatcher[RPRequestLog] + + @property + def launch_uuid(self) -> Optional[str]: + """Return current launch UUID. + + :return: UUID string + """ + return self.__launch_uuid + + @property + def endpoint(self) -> str: + """Return current base URL. + + :return: base URL string + """ + return self.__endpoint + + @property + def project(self) -> str: + """Return current Project name. + + :return: Project name string + """ + return self.__project + + @property + def step_reporter(self) -> StepReporter: + """Return StepReporter object for the current launch. + + :return: StepReporter to report steps + """ + return self.__step_reporter + + def __init_session(self) -> None: + retry_strategy = Retry( + total=self.retries, + backoff_factor=0.1, + status_forcelist=[429, 500, 502, 503, 504] + ) if self.retries else DEFAULT_RETRIES + session = requests.Session() + session.mount('https://', HTTPAdapter( + max_retries=retry_strategy, pool_maxsize=self.max_pool_size)) + # noinspection HttpUrlsUsage + session.mount('http://', HTTPAdapter( + max_retries=retry_strategy, pool_maxsize=self.max_pool_size)) + if self.api_key: + session.headers['Authorization'] = 'Bearer {0}'.format( + self.api_key) + self.session = session def __init__( self, @@ -91,69 +423,80 @@ def __init__( api_key: str = None, log_batch_size: int = 20, is_skipped_an_issue: bool = True, - verify_ssl: bool = True, + verify_ssl: Union[bool, str] = True, retries: int = None, max_pool_size: int = 50, - launch_id: str = None, + launch_uuid: str = None, http_timeout: Union[float, Tuple[float, float]] = (10, 10), log_batch_payload_size: int = MAX_LOG_BATCH_PAYLOAD_SIZE, mode: str = 'DEFAULT', launch_uuid_print: bool = False, - print_output: Optional[TextIO] = None, + print_output: OutputType = OutputType.STDOUT, + log_batcher: Optional[LogBatcher[RPRequestLog]] = None, **kwargs: Any ) -> None: - """Initialize required attributes. - - :param endpoint: Endpoint of the ReportPortal service - :param project: Project name to report to - :param api_key: Authorization API key - :param log_batch_size: Option to set the maximum number of - logs that can be processed in one batch - :param is_skipped_an_issue: Option to mark skipped tests as not - 'To Investigate' items on the server - side - :param verify_ssl: Option to skip ssl verification - :param max_pool_size: Option to set the maximum number of - connections to save the pool. - :param launch_id: a launch id to use instead of starting - own one - :param http_timeout: a float in seconds for connect and read - timeout. Use a Tuple to specific connect - and read separately. - :param log_batch_payload_size: maximum size in bytes of logs that can - be processed in one batch + """Initialize the class instance with arguments. + + :param endpoint: Endpoint of the ReportPortal service. + :param project: Project name to report to. + :param api_key: Authorization API key. + :param log_batch_size: Option to set the maximum number of logs that can be processed in one + batch. + :param is_skipped_an_issue: Option to mark skipped tests as not 'To Investigate' items on the + server side. + :param verify_ssl: Option to skip ssl verification. + :param retries: Number of retry attempts to make in case of connection / server errors. + :param max_pool_size: Option to set the maximum number of connections to save the pool. + :param launch_uuid: A launch UUID to use instead of starting own one. + :param http_timeout: A float in seconds for connect and read timeout. Use a Tuple to + specific connect and read separately. + :param log_batch_payload_size: Maximum size in bytes of logs that can be processed in one batch. + :param mode: Launch mode, all Launches started by the client will be in that mode. + :param launch_uuid_print: Print Launch UUID into passed TextIO or by default to stdout. + :param print_output: Set output stream for Launch UUID printing. + :param log_batcher: Use existing LogBatcher instance instead of creation of own one. """ set_current(self) - self._batch_logs = [] self.api_v1, self.api_v2 = 'v1', 'v2' - self.endpoint = endpoint - self.project = project + self.__endpoint = endpoint + self.__project = project self.base_url_v1 = uri_join( - self.endpoint, 'api/{}'.format(self.api_v1), self.project) + self.__endpoint, 'api/{}'.format(self.api_v1), self.__project) self.base_url_v2 = uri_join( - self.endpoint, 'api/{}'.format(self.api_v2), self.project) + self.__endpoint, 'api/{}'.format(self.api_v2), self.__project) self.is_skipped_an_issue = is_skipped_an_issue - self.launch_id = launch_id + self.__launch_uuid = launch_uuid + if not self.__launch_uuid: + launch_id = kwargs.get('launch_id') + if launch_id: + warnings.warn( + message='`launch_id` property is deprecated since 5.5.0 and will be subject for removing' + ' in the next major version. Use `launch_uuid` property instead.', + category=DeprecationWarning, + stacklevel=2 + ) + self.__launch_uuid = launch_id + self.use_own_launch = not bool(self.__launch_uuid) self.log_batch_size = log_batch_size self.log_batch_payload_size = log_batch_payload_size + self._log_batcher = log_batcher or LogBatcher(self.log_batch_size, self.log_batch_payload_size) self.verify_ssl = verify_ssl self.retries = retries self.max_pool_size = max_pool_size self.http_timeout = http_timeout - self.step_reporter = StepReporter(self) - self._item_stack = _LifoQueue() + self.__step_reporter = StepReporter(self) + self._item_stack = LifoQueue() self.mode = mode self._skip_analytics = getenv('AGENT_NO_ANALYTICS') self.launch_uuid_print = launch_uuid_print - self.print_output = print_output or sys.stdout + self.print_output = print_output self.api_key = api_key if not self.api_key: if 'token' in kwargs: warnings.warn( - message='Argument `token` is deprecated since 5.3.5 and ' - 'will be subject for removing in the next major ' - 'version. Use `api_key` argument instead.', + message='Argument `token` is deprecated since 5.3.5 and will be subject for removing in ' + 'the next major version. Use `api_key` argument instead.', category=DeprecationWarning, stacklevel=2 ) @@ -161,96 +504,148 @@ def __init__( if not self.api_key: warnings.warn( - message='Argument `api_key` is `None` or empty string, ' - 'that is not supposed to happen because Report ' - 'Portal is usually requires an authorization key. ' - 'Please check your code.', + message='Argument `api_key` is `None` or empty string, that is not supposed to happen ' + 'because Report Portal is usually requires an authorization key. Please check ' + 'your code.', category=RuntimeWarning, stacklevel=2 ) self.__init_session() - self.__init_log_manager() - def __init_session(self) -> None: - retry_strategy = Retry( - total=self.retries, - backoff_factor=0.1, - status_forcelist=[429, 500, 502, 503, 504] - ) if self.retries else DEFAULT_RETRIES - session = requests.Session() - session.mount('https://', HTTPAdapter( - max_retries=retry_strategy, pool_maxsize=self.max_pool_size)) - # noinspection HttpUrlsUsage - session.mount('http://', HTTPAdapter( - max_retries=retry_strategy, pool_maxsize=self.max_pool_size)) - if self.api_key: - session.headers['Authorization'] = 'Bearer {0}'.format( - self.api_key) - self.session = session + def start_launch(self, + name: str, + start_time: str, + description: Optional[str] = None, + attributes: Optional[Union[list, dict]] = None, + rerun: bool = False, + rerun_of: Optional[str] = None, + **kwargs) -> Optional[str]: + """Start a new Launch with the given arguments. + + :param name: Launch name. + :param start_time: Launch start time. + :param description: Launch description. + :param attributes: Launch attributes. + :param rerun: Start launch in rerun mode. + :param rerun_of: For rerun mode specifies which launch will be re-run. Should be used with the + 'rerun' option. + :return: Launch UUID if successfully started or None. + """ + if not self.use_own_launch: + return self.launch_uuid + url = uri_join(self.base_url_v2, 'launch') + request_payload = LaunchStartRequest( + name=name, + start_time=start_time, + attributes=attributes, + description=description, + mode=self.mode, + rerun=rerun, + rerun_of=rerun_of + ).payload + response = HttpRequest(self.session.post, url=url, json=request_payload, + verify_ssl=self.verify_ssl).make() + if not response: + return - def __init_log_manager(self) -> None: - self._log_manager = LogManager( - self.endpoint, self.session, self.api_v2, self.launch_id, - self.project, max_entry_number=self.log_batch_size, - max_payload_size=self.log_batch_payload_size, - verify_ssl=self.verify_ssl) + if not self._skip_analytics: + send_event('start_launch', *agent_name_version(attributes)) - def finish_launch(self, - end_time: str, - status: str = None, - attributes: Optional[Union[List, Dict]] = None, - **kwargs: Any) -> Optional[str]: - """Finish launch. + self.__launch_uuid = response.id + logger.debug('start_launch - ID: %s', self.launch_uuid) + if self.launch_uuid_print and self.print_output: + print(f'ReportPortal Launch UUID: {self.launch_uuid}', file=self.print_output.get_output()) + return self.launch_uuid - :param end_time: Launch end time - :param status: Launch status. Can be one of the followings: - PASSED, FAILED, STOPPED, SKIPPED, RESETED, - CANCELLED - :param attributes: Launch attributes + def start_test_item(self, + name: str, + start_time: str, + item_type: str, + description: Optional[str] = None, + attributes: Optional[Union[List[dict], dict]] = None, + parameters: Optional[dict] = None, + parent_item_id: Optional[str] = None, + has_stats: bool = True, + code_ref: Optional[str] = None, + retry: bool = False, + test_case_id: Optional[str] = None, + **_: Any) -> Optional[str]: + """Start Test Case/Suite/Step/Nested Step Item. + + :param name: Name of the Test Item. + :param start_time: The Item start time. + :param item_type: Type of the Test Item. Allowed values: + "suite", "story", "test", "scenario", "step", "before_class", "before_groups", + "before_method", "before_suite", "before_test", "after_class", "after_groups", + "after_method", "after_suite", "after_test". + :param description: The Item description. + :param attributes: Test Item attributes. + :param parameters: Set of parameters (for parametrized Test Items). + :param parent_item_id: A UUID of a parent SUITE / STEP. + :param has_stats: Set to False if test item is a Nested Step. + :param code_ref: Physical location of the Test Item. + :param retry: Used to report retry of the test. Allowed values: "True" or "False". + :param test_case_id: A unique ID of the current Step. + :return: Test Item UUID if successfully started or None. """ - if self.launch_id is NOT_FOUND or not self.launch_id: - logger.warning('Attempt to finish non-existent launch') + if parent_item_id is NOT_FOUND: + logger.warning('Attempt to start item for non-existent parent item.') return - url = uri_join(self.base_url_v2, 'launch', self.launch_id, 'finish') - request_payload = LaunchFinishRequest( - end_time, - status=status, - attributes=attributes, - description=kwargs.get('description') + if parent_item_id: + url = uri_join(self.base_url_v2, 'item', parent_item_id) + else: + url = uri_join(self.base_url_v2, 'item') + request_payload = ItemStartRequest( + name, + start_time, + item_type, + self.launch_uuid, + attributes=verify_value_length(attributes), + code_ref=code_ref, + description=description, + has_stats=has_stats, + parameters=parameters, + retry=retry, + test_case_id=test_case_id ).payload - response = HttpRequest(self.session.put, url=url, json=request_payload, - verify_ssl=self.verify_ssl, - name='Finish Launch').make() + + response = HttpRequest(self.session.post, + url=url, + json=request_payload, + verify_ssl=self.verify_ssl).make() if not response: return - logger.debug('finish_launch - ID: %s', self.launch_id) - logger.debug('response message: %s', response.message) - return response.message + item_id = response.id + if item_id is not NOT_FOUND: + logger.debug('start_test_item - ID: %s', item_id) + self._add_current_item(item_id) + else: + logger.warning('start_test_item - invalid response: %s', + str(response.json)) + return item_id def finish_test_item(self, item_id: str, end_time: str, status: str = None, issue: Optional[Issue] = None, - attributes: Optional[Union[List, Dict]] = None, + attributes: Optional[Union[list, dict]] = None, description: str = None, retry: bool = False, **kwargs: Any) -> Optional[str]: - """Finish suite/case/step/nested step item. - - :param item_id: ID of the test item - :param end_time: The item end time - :param status: Test status. Allowable values: "passed", - "failed", "stopped", "skipped", "interrupted", - "cancelled" or None - :param attributes: Test item attributes(tags). Pairs of key and value. - Override attributes on start - :param description: Test item description. Overrides description - from start request. - :param issue: Issue of the current test item - :param retry: Used to report retry of the test. Allowable values: - "True" or "False" + """Finish Test Suite/Case/Step/Nested Step Item. + + :param item_id: ID of the Test Item. + :param end_time: The Item end time. + :param status: Test status. Allowed values: + PASSED, FAILED, STOPPED, SKIPPED, INTERRUPTED, CANCELLED, INFO, WARN or None. + :param issue: Issue which will be attached to the current Item. + :param attributes: Test Item attributes(tags). Pairs of key and value. These attributes override + attributes on start Test Item call. + :param description: Test Item description. Overrides description from start request. + :param retry: Used to report retry of the test. Allowed values: "True" or "False". + :return: Response message. """ if item_id is NOT_FOUND or not item_id: logger.warning('Attempt to finish non-existent item') @@ -258,7 +653,7 @@ def finish_test_item(self, url = uri_join(self.base_url_v2, 'item', item_id) request_payload = ItemFinishRequest( end_time, - self.launch_id, + self.launch_uuid, status, attributes=attributes, description=description, @@ -275,30 +670,123 @@ def finish_test_item(self, logger.debug('response message: %s', response.message) return response.message - def get_item_id_by_uuid(self, uuid: str) -> Optional[str]: - """Get test item ID by the given UUID. + def finish_launch(self, + end_time: str, + status: str = None, + attributes: Optional[Union[list, dict]] = None, + **kwargs: Any) -> Optional[str]: + """Finish launch. + + :param end_time: Launch end time + :param status: Launch status. Can be one of the followings: + PASSED, FAILED, STOPPED, SKIPPED, RESETED, + CANCELLED + :param attributes: Launch attributes + """ + if self.use_own_launch: + if self.launch_uuid is NOT_FOUND or not self.launch_uuid: + logger.warning('Attempt to finish non-existent launch') + return + url = uri_join(self.base_url_v2, 'launch', self.launch_uuid, 'finish') + request_payload = LaunchFinishRequest( + end_time, + status=status, + attributes=attributes, + description=kwargs.get('description') + ).payload + response = HttpRequest(self.session.put, url=url, json=request_payload, + verify_ssl=self.verify_ssl, + name='Finish Launch').make() + if not response: + return + logger.debug('finish_launch - ID: %s', self.launch_uuid) + logger.debug('response message: %s', response.message) + message = response.message + else: + message = "" + self._log(self._log_batcher.flush()) + self.close() + return message + + def update_test_item(self, item_uuid: str, attributes: Optional[Union[list, dict]] = None, + description: Optional[str] = None) -> Optional[str]: + """Update existing Test Item at the ReportPortal. + + :param item_uuid: Test Item UUID returned on the item start. + :param attributes: Test Item attributes: [{'key': 'k_name', 'value': 'k_value'}, ...]. + :param description: Test Item description. + :return: Response message or None. + """ + data = { + 'description': description, + 'attributes': verify_value_length(attributes), + } + item_id = self.get_item_id_by_uuid(item_uuid) + url = uri_join(self.base_url_v1, 'item', item_id, 'update') + response = HttpRequest(self.session.put, url=url, json=data, + verify_ssl=self.verify_ssl).make() + if not response: + return + logger.debug('update_test_item - Item: %s', item_id) + return response.message + + def _log(self, batch: Optional[List[RPRequestLog]]) -> Optional[Tuple[str, ...]]: + if batch: + url = uri_join(self.base_url_v2, 'log') + response = HttpRequest(self.session.post, url, files=RPLogBatch(batch).payload, + verify_ssl=self.verify_ssl).make() + return response.messages + + def log(self, + time: str, + message: str, + level: Optional[Union[int, str]] = None, + attachment: Optional[dict] = None, + item_id: Optional[str] = None) -> Optional[Tuple[str, ...]]: + """Send Log message to the ReportPortal and attach it to a Test Item or Launch. + + This method stores Log messages in internal batch and sent it when batch is full, so not every method + call will return any response. + + :param time: Time in UTC. + :param message: Log message text. + :param level: Message's Log level. + :param attachment: Message's attachments(images,files,etc.). + :param item_id: UUID of the ReportPortal Item the message belongs to. + :return: Response message Tuple if Log message batch was sent or None. + """ + if item_id is NOT_FOUND: + logger.warning("Attempt to log to non-existent item") + return + rp_file = RPFile(**attachment) if attachment else None + rp_log = RPRequestLog(self.launch_uuid, time, rp_file, item_id, level, message) + return self._log(self._log_batcher.append(rp_log)) + + def get_item_id_by_uuid(self, item_uuid: str) -> Optional[str]: + """Get Test Item ID by the given Item UUID. - :param uuid: UUID returned on the item start - :return: Test item ID + :param item_uuid: String UUID returned on the Item start. + :return: Test Item ID. """ - url = uri_join(self.base_url_v1, 'item', 'uuid', uuid) + url = uri_join(self.base_url_v1, 'item', 'uuid', item_uuid) response = HttpRequest(self.session.get, url=url, verify_ssl=self.verify_ssl).make() return response.id if response else None - def get_launch_info(self) -> Optional[Dict]: - """Get the current launch information. + def get_launch_info(self) -> Optional[dict]: + """Get current Launch information. - :return dict: Launch information in dictionary + :return: Launch information in dictionary. """ - if self.launch_id is None: + if self.launch_uuid is None: return {} - url = uri_join(self.base_url_v1, 'launch', 'uuid', self.launch_id) - logger.debug('get_launch_info - ID: %s', self.launch_id) + url = uri_join(self.base_url_v1, 'launch', 'uuid', self.launch_uuid) + logger.debug('get_launch_info - ID: %s', self.launch_uuid) response = HttpRequest(self.session.get, url=url, verify_ssl=self.verify_ssl).make() if not response: return + launch_info = None if response.is_success: launch_info = response.json logger.debug( @@ -306,21 +794,20 @@ def get_launch_info(self) -> Optional[Dict]: else: logger.warning('get_launch_info - Launch info: ' 'Failed to fetch launch ID from the API.') - launch_info = {} return launch_info - def get_launch_ui_id(self) -> Optional[Dict]: - """Get UI ID of the current launch. + def get_launch_ui_id(self) -> Optional[int]: + """Get Launch ID of the current Launch. - :return: UI ID of the given launch. None if UI ID has not been found. + :return: Launch ID of the Launch. None if not found. """ launch_info = self.get_launch_info() return launch_info.get('id') if launch_info else None def get_launch_ui_url(self) -> Optional[str]: - """Get UI URL of the current launch. + """Get full quality URL of the current Launch. - :return: launch URL or all launches URL. + :return: Launch URL string. """ launch_info = self.get_launch_info() ui_id = launch_info.get('id') if launch_info else None @@ -333,249 +820,82 @@ def get_launch_ui_url(self) -> Optional[str]: launch_type = 'launches' if mode.upper() == 'DEFAULT' else 'userdebug' path = 'ui/#{project_name}/{launch_type}/all/{launch_id}'.format( - project_name=self.project.lower(), launch_type=launch_type, + project_name=self.__project.lower(), launch_type=launch_type, launch_id=ui_id) - url = uri_join(self.endpoint, path) - logger.debug('get_launch_ui_url - ID: %s', self.launch_id) + url = uri_join(self.__endpoint, path) + logger.debug('get_launch_ui_url - UUID: %s', self.launch_uuid) return url - def get_project_settings(self) -> Optional[Dict]: - """Get project settings. + def get_project_settings(self) -> Optional[dict]: + """Get settings of the current Project. - :return: HTTP response in dictionary + :return: Settings response in Dictionary. """ url = uri_join(self.base_url_v1, 'settings') response = HttpRequest(self.session.get, url=url, verify_ssl=self.verify_ssl).make() return response.json if response else None - def log(self, time: str, message: str, level: Optional[Union[int, str]] = None, - attachment: Optional[Dict] = None, item_id: Optional[str] = None) -> None: - """Send log message to the ReportPortal. - - :param time: Time in UTC - :param message: Log message text - :param level: Message's log level - :param attachment: Message's attachments - :param item_id: ID of the RP item the message belongs to - """ - self._log_manager.log(time, message, level, attachment, item_id) - - def start(self) -> None: - """Start the client.""" - self._log_manager.start() - - def start_launch(self, - name: str, - start_time: str, - description: Optional[str] = None, - attributes: Optional[Union[List, Dict]] = None, - rerun: bool = False, - rerun_of: Optional[str] = None, - **kwargs) -> Optional[str]: - """Start a new launch with the given parameters. - - :param name: Launch name - :param start_time: Launch start time - :param description: Launch description - :param attributes: Launch attributes - :param rerun: Start launch in rerun mode - :param rerun_of: For rerun mode specifies which launch will be - re-run. Should be used with the 'rerun' option. - """ - url = uri_join(self.base_url_v2, 'launch') - - # We are moving 'mode' param to the constructor, next code for the - # transition period only. - my_kwargs = dict(kwargs) - mode = my_kwargs.get('mode') - if 'mode' in my_kwargs: - warnings.warn( - message='Argument `mode` is deprecated since 5.2.5 and will be subject for removing in the ' - 'next major version. Use `mode` argument in the class constructor instead.', - category=DeprecationWarning, - stacklevel=2 - ) - del my_kwargs['mode'] - if not mode: - mode = self.mode - - request_payload = LaunchStartRequest( - name=name, - start_time=start_time, - attributes=attributes, - description=description, - mode=mode, - rerun=rerun, - rerun_of=rerun_of or kwargs.get('rerunOf'), - **my_kwargs - ).payload - response = HttpRequest(self.session.post, - url=url, - json=request_payload, - verify_ssl=self.verify_ssl).make() - if not response: - return - - if not self._skip_analytics: - agent_name, agent_version = None, None - - agent_attribute = [a for a in attributes if - a.get('key') == 'agent'] if attributes else [] - if len(agent_attribute) > 0 and agent_attribute[0].get('value'): - agent_name, agent_version = agent_attribute[0]['value'].split( - '|') - send_event('start_launch', agent_name, agent_version) + def _add_current_item(self, item: str) -> None: + """Add the last item from the self._items queue.""" + self._item_stack.put(item) - self._log_manager.launch_id = self.launch_id = response.id - logger.debug('start_launch - ID: %s', self.launch_id) - if self.launch_uuid_print and self.print_output: - print(f'ReportPortal Launch UUID: {self.launch_id}', file=self.print_output) - return self.launch_id + def _remove_current_item(self) -> Optional[str]: + """Remove the last item from the self._items queue. - def start_test_item(self, - name: str, - start_time: str, - item_type: str, - description: Optional[str] = None, - attributes: Optional[List[Dict]] = None, - parameters: Optional[Dict] = None, - parent_item_id: Optional[str] = None, - has_stats: bool = True, - code_ref: Optional[str] = None, - retry: bool = False, - test_case_id: Optional[str] = None, - **_: Any) -> Optional[str]: - """Start case/step/nested step item. - - :param name: Name of the test item - :param start_time: The item start time - :param item_type: Type of the test item. Allowable values: - "suite", "story", "test", "scenario", "step", - "before_class", "before_groups", - "before_method", "before_suite", - "before_test", "after_class", "after_groups", - "after_method", "after_suite", "after_test" - :param attributes: Test item attributes - :param code_ref: Physical location of the test item - :param description: The item description - :param has_stats: Set to False if test item is nested step - :param parameters: Set of parameters (for parametrized test items) - :param parent_item_id: An ID of a parent SUITE / STEP - :param retry: Used to report retry of the test. Allowable - values: "True" or "False" - :param test_case_id: A unique ID of the current step + :return: Item UUID string """ - if parent_item_id is NOT_FOUND: - logger.warning('Attempt to start item for non-existent parent item.') + try: + return self._item_stack.get() + except queue.Empty: return - if parent_item_id: - url = uri_join(self.base_url_v2, 'item', parent_item_id) - else: - url = uri_join(self.base_url_v2, 'item') - request_payload = ItemStartRequest( - name, - start_time, - item_type, - self.launch_id, - attributes=verify_value_length(attributes), - code_ref=code_ref, - description=description, - has_stats=has_stats, - parameters=parameters, - retry=retry, - test_case_id=test_case_id - ).payload - - response = HttpRequest(self.session.post, - url=url, - json=request_payload, - verify_ssl=self.verify_ssl).make() - if not response: - return - item_id = response.id - if item_id is not NOT_FOUND: - logger.debug('start_test_item - ID: %s', item_id) - self._add_current_item(item_id) - else: - logger.warning('start_test_item - invalid response: %s', - str(response.json)) - return item_id - - def terminate(self, *_: Any, **__: Any) -> None: - """Call this to terminate the client.""" - self._log_manager.stop() - def update_test_item(self, item_uuid: str, attributes: Optional[Union[List, Dict]] = None, - description: Optional[str] = None) -> Optional[str]: - """Update existing test item at the ReportPortal. + def current_item(self) -> Optional[str]: + """Retrieve the last item reported by the client (based on the internal FILO queue). - :param str item_uuid: Test item UUID returned on the item start - :param str description: Test item description - :param list attributes: Test item attributes - [{'key': 'k_name', 'value': 'k_value'}, ...] + :return: Item UUID string. """ - data = { - 'description': description, - 'attributes': verify_value_length(attributes), - } - item_id = self.get_item_id_by_uuid(item_uuid) - url = uri_join(self.base_url_v1, 'item', item_id, 'update') - response = HttpRequest(self.session.put, url=url, json=data, - verify_ssl=self.verify_ssl).make() - if not response: - return - logger.debug('update_test_item - Item: %s', item_id) - return response.message - - def _add_current_item(self, item: str) -> None: - """Add the last item from the self._items queue.""" - self._item_stack.put(item) - - def _remove_current_item(self) -> str: - """Remove the last item from the self._items queue.""" - return self._item_stack.get() - - def current_item(self) -> Optional[str]: - """Retrieve the last item reported by the client.""" return self._item_stack.last() def clone(self) -> 'RPClient': - """Clone the client object, set current Item ID as cloned item ID. + """Clone the Client object, set current Item ID as cloned Item ID. - :returns: Cloned client object + :return: Cloned client object. :rtype: RPClient """ cloned = RPClient( - endpoint=self.endpoint, - project=self.project, + endpoint=self.__endpoint, + project=self.__project, api_key=self.api_key, log_batch_size=self.log_batch_size, is_skipped_an_issue=self.is_skipped_an_issue, verify_ssl=self.verify_ssl, retries=self.retries, max_pool_size=self.max_pool_size, - launch_id=self.launch_id, + launch_uuid=self.launch_uuid, http_timeout=self.http_timeout, log_batch_payload_size=self.log_batch_payload_size, - mode=self.mode + mode=self.mode, + log_batcher=self._log_batcher ) current_item = self.current_item() if current_item: cloned._add_current_item(current_item) return cloned + def close(self) -> None: + """Close current client connections.""" + self.session.close() + def __getstate__(self) -> Dict[str, Any]: """Control object pickling and return object fields as Dictionary. - :returns: object state dictionary + :return: object state dictionary :rtype: dict """ state = self.__dict__.copy() # Don't pickle 'session' field, since it contains unpickling 'socket' del state['session'] - # Don't pickle '_log_manager' field, since it uses 'session' field - del state['_log_manager'] return state def __setstate__(self, state: Dict[str, Any]) -> None: @@ -586,5 +906,3 @@ def __setstate__(self, state: Dict[str, Any]) -> None: self.__dict__.update(state) # Restore 'session' field self.__init_session() - # Restore '_log_manager' field - self.__init_log_manager() diff --git a/reportportal_client/core/rp_file.py b/reportportal_client/core/rp_file.py index 68ca0f22..391071f1 100644 --- a/reportportal_client/core/rp_file.py +++ b/reportportal_client/core/rp_file.py @@ -1,5 +1,3 @@ -"""This module contains classes representing RP file object.""" - # Copyright (c) 2022 EPAM Systems # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,6 +11,8 @@ # See the License for the specific language governing permissions and # limitations under the License +"""This module contains classes representing RP file object.""" + import uuid diff --git a/reportportal_client/core/rp_file.pyi b/reportportal_client/core/rp_file.pyi index 1d08b606..b16db04d 100644 --- a/reportportal_client/core/rp_file.pyi +++ b/reportportal_client/core/rp_file.pyi @@ -13,13 +13,16 @@ from typing import Any, Dict, Optional, Text + class RPFile: content: Any = ... content_type: Text = ... name: Text = ... + def __init__(self, name: Optional[Text], content: Any, content_type: Optional[Text]) -> None: ... + @property def payload(self) -> Dict: ... diff --git a/reportportal_client/core/rp_issues.py b/reportportal_client/core/rp_issues.py index 1449a2bc..d6350fe0 100644 --- a/reportportal_client/core/rp_issues.py +++ b/reportportal_client/core/rp_issues.py @@ -1,3 +1,16 @@ +# Copyright (c) 2022 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 includes classes representing RP issues. Issues are reported within the test item result PUT call. One issue can be @@ -26,21 +39,8 @@ } """ -# Copyright (c) 2022 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 - -class Issue(object): +class Issue: """This class represents an issue that can be attached to test result.""" def __init__(self, @@ -81,7 +81,7 @@ def payload(self): } -class ExternalIssue(object): +class ExternalIssue: """This class represents external(BTS) system issue.""" def __init__(self, diff --git a/reportportal_client/core/rp_issues.pyi b/reportportal_client/core/rp_issues.pyi index de58bae9..a7257ad9 100644 --- a/reportportal_client/core/rp_issues.pyi +++ b/reportportal_client/core/rp_issues.pyi @@ -13,32 +13,39 @@ from typing import Dict, List, Optional, Text + class Issue: _external_issues: List = ... auto_analyzed: bool = ... comment: Text = ... ignore_analyzer: bool = ... issue_type: Text = ... + def __init__(self, issue_type: Text, comment: Optional[Text] = ..., auto_analyzed: Optional[bool] = ..., ignore_analyzer: Optional[bool] = ...) -> None: ... + def external_issue_add(self, issue: ExternalIssue) -> None: ... + @property def payload(self) -> Dict: ... + class ExternalIssue: bts_url: Text = ... bts_project: Text = ... submit_date: Text = ... ticket_id: Text = ... url: Text = ... + def __init__(self, bts_url: Optional[Text] = ..., bts_project: Optional[Text] = ..., submit_date: Optional[Text] = ..., ticket_id: Optional[Text] = ..., url: Optional[Text] = ...) -> None: ... + @property def payload(self) -> Dict: ... diff --git a/reportportal_client/core/rp_requests.py b/reportportal_client/core/rp_requests.py index 7956e755..6bd66912 100644 --- a/reportportal_client/core/rp_requests.py +++ b/reportportal_client/core/rp_requests.py @@ -1,10 +1,3 @@ -"""This module includes classes representing RP API requests. - -Detailed information about requests wrapped up in that module -can be found by the following link: -https://github.com/reportportal/documentation/blob/master/src/md/src/DevGuides/reporting.md -""" - # Copyright (c) 2022 EPAM Systems # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -18,44 +11,75 @@ # See the License for the specific language governing permissions and # limitations under the License -import json +"""This module includes classes representing ReportPortal API requests. + +Detailed information about requests wrapped up in that module +can be found by the following link: +https://github.com/reportportal/documentation/blob/master/src/md/src/DevGuides/reporting.md +""" + +import asyncio +import json as json_converter import logging +from dataclasses import dataclass +from typing import Callable, Optional, Union, List, Tuple, Any, TypeVar + +import aiohttp from reportportal_client import helpers -from reportportal_client.core.rp_file import RPFile -from reportportal_client.core.rp_issues import Issue -from reportportal_client.helpers import dict_to_payload -from reportportal_client.static.abstract import ( +# noinspection PyProtectedMember +from reportportal_client._internal.static.abstract import ( AbstractBaseClass, abstractmethod ) -from reportportal_client.static.defines import ( +# noinspection PyProtectedMember +from reportportal_client._internal.static.defines import ( DEFAULT_PRIORITY, LOW_PRIORITY, - RP_LOG_LEVELS + RP_LOG_LEVELS, Priority ) -from .rp_responses import RPResponse +from reportportal_client.core.rp_file import RPFile +from reportportal_client.core.rp_issues import Issue +from reportportal_client.core.rp_responses import RPResponse, AsyncRPResponse +from reportportal_client.helpers import dict_to_payload, await_if_necessary logger = logging.getLogger(__name__) +T = TypeVar("T") class HttpRequest: - """This model stores attributes related to RP HTTP requests.""" + """This model stores attributes related to ReportPortal HTTP requests.""" + + session_method: Callable + url: Any + files: Optional[Any] + data: Optional[Any] + json: Optional[Any] + verify_ssl: Optional[Union[bool, str]] + http_timeout: Union[float, Tuple[float, float]] + name: Optional[str] + _priority: Priority - def __init__(self, session_method, url, data=None, json=None, - files=None, verify_ssl=True, http_timeout=(10, 10), - name=None): - """Initialize instance attributes. + def __init__(self, + session_method: Callable, + url: Any, + data: Optional[Any] = None, + json: Optional[Any] = None, + files: Optional[Any] = None, + verify_ssl: Optional[Union[bool, str]] = None, + http_timeout: Union[float, Tuple[float, float]] = (10, 10), + name: Optional[str] = None) -> None: + """Initialize an instance of the request with attributes. :param session_method: Method of the requests.Session instance :param url: Request URL - :param data: Dictionary, list of tuples, bytes, or file-like - object to send in the body of the request + :param data: Dictionary, list of tuples, bytes, or file-like object to send in the body of + the request :param json: JSON to be sent in the body of the request + :param files Dictionary for multipart encoding upload :param verify_ssl: Is SSL certificate verification required - :param http_timeout: a float in seconds for connect and read - timeout. Use a Tuple to specific connect and - read separately. + :param http_timeout: a float in seconds for connect and read timeout. Use a Tuple to specific + connect and read separately :param name: request name """ self.data = data @@ -66,116 +90,131 @@ def __init__(self, session_method, url, data=None, json=None, self.verify_ssl = verify_ssl self.http_timeout = http_timeout self.name = name + self._priority = DEFAULT_PRIORITY + + def __lt__(self, other: 'HttpRequest') -> bool: + """Priority protocol for the PriorityQueue. - def make(self): - """Make HTTP request to the ReportPortal API.""" + :param other: another object to compare + :return: if current object is less than other + """ + return self.priority < other.priority + + @property + def priority(self) -> Priority: + """Get the priority of the request. + + :return: the object priority + """ + return self._priority + + @priority.setter + def priority(self, value: Priority) -> None: + """Set the priority of the request. + + :param value: the object priority + """ + self._priority = value + + def make(self) -> Optional[RPResponse]: + """Make HTTP request to the ReportPortal API. + + The method catches any request preparation error to not fail reporting. Since we are reporting tool + and should not fail tests. + + :return: wrapped HTTP response or None in case of failure + """ try: - return RPResponse(self.session_method( - self.url, data=self.data, json=self.json, - files=self.files, verify=self.verify_ssl, - timeout=self.http_timeout) - ) - # https://github.com/reportportal/client-Python/issues/39 + return RPResponse(self.session_method(self.url, data=self.data, json=self.json, files=self.files, + verify=self.verify_ssl, timeout=self.http_timeout)) except (KeyError, IOError, ValueError, TypeError) as exc: - logger.warning( - "ReportPortal %s request failed", - self.name, - exc_info=exc - ) + logger.warning("ReportPortal %s request failed", self.name, exc_info=exc) -class RPRequestBase(object): - """Base class for the rest of the RP request models.""" +class AsyncHttpRequest(HttpRequest): + """This model stores attributes related to asynchronous ReportPortal HTTP requests.""" - __metaclass__ = AbstractBaseClass + def __init__(self, + session_method: Callable, + url: Any, + data: Optional[Any] = None, + json: Optional[Any] = None, + name: Optional[str] = None) -> None: + """Initialize an instance of the request with attributes. - def __init__(self): - """Initialize instance attributes.""" - self._http_request = None - self._priority = DEFAULT_PRIORITY - self._response = None + :param session_method: Method of the requests.Session instance + :param url: Request URL or async coroutine returning the URL + :param data: Dictionary, list of tuples, bytes, or file-like object, or async coroutine to + send in the body of the request + :param json: JSON to be sent in the body of the request as Dictionary or async coroutine + :param name: request name + """ + super().__init__(session_method=session_method, url=url, data=data, json=json, name=name) - def __lt__(self, other): - """Priority protocol for the PriorityQueue.""" - return self.priority < other.priority + async def make(self) -> Optional[AsyncRPResponse]: + """Asynchronously make HTTP request to the ReportPortal API. - @property - def http_request(self): - """Get the HttpRequest object of the request.""" - return self._http_request + The method catches any request preparation error to not fail reporting. Since we are reporting tool + and should not fail tests. - @http_request.setter - def http_request(self, value): - """Set the HttpRequest object of the request.""" - self._http_request = value + :return: wrapped HTTP response or None in case of failure + """ + url = await await_if_necessary(self.url) + if not url: + return + data = await await_if_necessary(self.data) + json = await await_if_necessary(self.json) + try: + return AsyncRPResponse(await self.session_method(url, data=data, json=json)) + except (KeyError, IOError, ValueError, TypeError) as exc: + logger.warning("ReportPortal %s request failed", self.name, exc_info=exc) - @property - def priority(self): - """Get the priority of the request.""" - return self._priority - @priority.setter - def priority(self, value): - """Set the priority of the request.""" - self._priority = value +class RPRequestBase(metaclass=AbstractBaseClass): + """Base class for specific ReportPortal request models. - @property - def response(self): - """Get the response object for the request.""" - return self._response + Its main function to provide interface of 'payload' method which is used to generate HTTP request payload. + """ - @response.setter - def response(self, value): - """Set the response object for the request.""" - self._response = value + __metaclass__ = AbstractBaseClass + @property @abstractmethod - def payload(self): - """Abstract interface for getting HTTP request payload.""" + def payload(self) -> dict: + """Abstract interface for getting HTTP request payload. + + :return: JSON representation in the form of a Dictionary + """ raise NotImplementedError('Payload interface is not implemented!') +@dataclass(frozen=True) class LaunchStartRequest(RPRequestBase): - """RP start launch request model. + """ReportPortal start launch request model. https://github.com/reportportal/documentation/blob/master/src/md/src/DevGuides/reporting.md#start-launch """ - def __init__(self, - name, - start_time, - attributes=None, - description=None, - mode='default', - rerun=False, - rerun_of=None): - """Initialize instance attributes. - - :param name: Name of the launch - :param start_time: Launch start time - :param attributes: Launch attributes - :param description: Description of the launch - :param mode: Launch mode. Allowable values 'default' or 'debug' - :param rerun: Rerun mode. Allowable values 'True' of 'False' - :param rerun_of: Rerun mode. Specifies launch to be re-runned. Uses - with the 'rerun' attribute. - """ - super(LaunchStartRequest, self).__init__() - self.attributes = attributes - self.description = description - self.mode = mode - self.name = name - self.rerun = rerun - self.rerun_of = rerun_of - self.start_time = start_time + name: str + start_time: str + attributes: Optional[Union[list, dict]] = None + description: Optional[str] = None + mode: str = 'default' + rerun: bool = False + rerun_of: str = None + uuid: str = None @property - def payload(self): - """Get HTTP payload for the request.""" - if self.attributes and isinstance(self.attributes, dict): - self.attributes = dict_to_payload(self.attributes) - return { - 'attributes': self.attributes, + def payload(self) -> dict: + """Get HTTP payload for the request. + + :return: JSON representation in the form of a Dictionary + """ + my_attributes = self.attributes + if my_attributes and isinstance(self.attributes, dict): + my_attributes = dict_to_payload(self.attributes) + result = { + 'attributes': my_attributes, 'description': self.description, 'mode': self.mode, 'name': self.name, @@ -183,274 +222,296 @@ def payload(self): 'rerunOf': self.rerun_of, 'startTime': self.start_time } + if self.uuid: + result['uuid'] = self.uuid + return result +@dataclass(frozen=True) class LaunchFinishRequest(RPRequestBase): - """RP finish launch request model. + """ReportPortal finish launch request model. https://github.com/reportportal/documentation/blob/master/src/md/src/DevGuides/reporting.md#finish-launch """ - def __init__(self, - end_time, - status=None, - attributes=None, - description=None): - """Initialize instance attributes. - - :param end_time: Launch end time - :param status: Launch status. Allowable values: "passed", - "failed", "stopped", "skipped", "interrupted", - "cancelled" - :param attributes: Launch attributes(tags). Pairs of key and value. - Overrides attributes on start - :param description: Launch description. Overrides description on start - """ - super(LaunchFinishRequest, self).__init__() - self.attributes = attributes - self.description = description - self.end_time = end_time - self.status = status + end_time: str + status: Optional[str] = None + attributes: Optional[Union[list, dict]] = None + description: Optional[str] = None @property - def payload(self): - """Get HTTP payload for the request.""" - if self.attributes and isinstance(self.attributes, dict): - self.attributes = dict_to_payload(self.attributes) + def payload(self) -> dict: + """Get HTTP payload for the request. + + :return: JSON representation in the form of a Dictionary + """ + my_attributes = self.attributes + if my_attributes and isinstance(self.attributes, dict): + my_attributes = dict_to_payload(self.attributes) return { - 'attributes': self.attributes, + 'attributes': my_attributes, 'description': self.description, 'endTime': self.end_time, 'status': self.status } +@dataclass(frozen=True) class ItemStartRequest(RPRequestBase): - """RP start test item request model. + """ReportPortal start test item request model. https://github.com/reportportal/documentation/blob/master/src/md/src/DevGuides/reporting.md#start-rootsuite-item """ - def __init__(self, - name, - start_time, - type_, - launch_uuid, - attributes=None, - code_ref=None, - description=None, - has_stats=True, - parameters=None, - retry=False, - test_case_id=None, - uuid=None, - unique_id=None): - """Initialize instance attributes. + name: str + start_time: str + type_: str + launch_uuid: Any + attributes: Optional[Union[list, dict]] + code_ref: Optional[str] + description: Optional[str] + has_stats: bool + parameters: Optional[Union[list, dict]] + retry: bool + test_case_id: Optional[str] + + @staticmethod + def _create_request(**kwargs) -> dict: + request = { + 'codeRef': kwargs.get('code_ref'), + 'description': kwargs.get('description'), + 'hasStats': kwargs.get('has_stats'), + 'name': kwargs['name'], + 'retry': kwargs.get('retry'), + 'startTime': kwargs['start_time'], + 'testCaseId': kwargs.get('test_case_id'), + 'type': kwargs['type'], + 'launchUuid': kwargs['launch_uuid'] + } + attributes = kwargs.get('attributes') + if attributes and isinstance(attributes, dict): + attributes = dict_to_payload(kwargs['attributes']) + request['attributes'] = attributes + parameters = kwargs.get('parameters') + if parameters and isinstance(parameters, dict): + parameters = dict_to_payload(kwargs['parameters']) + request['parameters'] = parameters + return request - :param name: Name of the test item - :param start_time: Test item start time - :param type_: Type of the test item. Allowable values: "suite", - "story", "test", "scenario", "step", - "before_class", "before_groups", "before_method", - "before_suite", "before_test", "after_class", - "after_groups", "after_method", "after_suite", - "after_test" - :param launch_uuid: Parent launch UUID - :param attributes: Test item attributes - :param code_ref: Physical location of the test item - :param description: Test item description - :param has_stats: Set to False if test item is nested step - :param parameters: Set of parameters (for parametrized test items) - :param retry: Used to report retry of the test. Allowable values: - "True" or "False" - :param test_case_id:Test case ID from integrated TMS - :param uuid: Test item UUID (auto generated) - :param unique_id: Test item ID (auto generated) + @property + def payload(self) -> dict: + """Get HTTP payload for the request. + + :return: JSON representation in the form of a Dictionary """ - super(ItemStartRequest, self).__init__() - self.attributes = attributes - self.code_ref = code_ref - self.description = description - self.has_stats = has_stats - self.launch_uuid = launch_uuid - self.name = name - self.parameters = parameters - self.retry = retry - self.start_time = start_time - self.test_case_id = test_case_id - self.type_ = type_ - self.uuid = uuid - self.unique_id = unique_id + data = self.__dict__.copy() + data['type'] = data.pop('type_') + return ItemStartRequest._create_request(**data) + + +class AsyncItemStartRequest(ItemStartRequest): + """ReportPortal start test item request asynchronous model. + + https://github.com/reportportal/documentation/blob/master/src/md/src/DevGuides/reporting.md#start-rootsuite-item + """ + + def __int__(self, *args, **kwargs) -> None: + """Initialize an instance of the request with attributes.""" + super.__init__(*args, **kwargs) @property - def payload(self): - """Get HTTP payload for the request.""" - if self.attributes and isinstance(self.attributes, dict): - self.attributes = dict_to_payload(self.attributes) - if self.parameters: - self.parameters = dict_to_payload(self.parameters) - return { - 'attributes': self.attributes, - 'codeRef': self.code_ref, - 'description': self.description, - 'hasStats': self.has_stats, - 'launchUuid': self.launch_uuid, - 'name': self.name, - 'parameters': self.parameters, - 'retry': self.retry, - 'startTime': self.start_time, - 'testCaseId': self.test_case_id, - 'type': self.type_ - } + async def payload(self) -> dict: + """Get HTTP payload for the request. + :return: JSON representation in the form of a Dictionary + """ + data = self.__dict__.copy() + data['type'] = data.pop('type_') + data['launch_uuid'] = await await_if_necessary(data.pop('launch_uuid')) + return ItemStartRequest._create_request(**data) + +@dataclass(frozen=True) class ItemFinishRequest(RPRequestBase): - """RP finish test item request model. + """ReportPortal finish test item request model. https://github.com/reportportal/documentation/blob/master/src/md/src/DevGuides/reporting.md#finish-child-item """ - def __init__(self, - end_time, - launch_uuid, - status, - attributes=None, - description=None, - is_skipped_an_issue=True, - issue=None, - retry=False): - """Initialize instance attributes. + end_time: str + launch_uuid: Any + status: str + attributes: Optional[Union[list, dict]] + description: str + is_skipped_an_issue: bool + issue: Issue + retry: bool + + @staticmethod + def _create_request(**kwargs) -> dict: + request = { + 'description': kwargs.get('description'), + 'endTime': kwargs['end_time'], + 'launchUuid': kwargs['launch_uuid'], + 'status': kwargs.get('status'), + 'retry': kwargs.get('retry') + } + attributes = kwargs.get('attributes') + if attributes and isinstance(attributes, dict): + attributes = dict_to_payload(kwargs['attributes']) + request['attributes'] = attributes + + if kwargs.get('issue') is None and ( + kwargs.get('status') is not None and kwargs.get('status').lower() == 'skipped' + ) and not kwargs.get('is_skipped_an_issue'): + issue_payload = {'issue_type': 'NOT_ISSUE'} + elif kwargs.get('issue') is not None: + issue_payload = kwargs.get('issue').payload + else: + issue_payload = None + request['issue'] = issue_payload + return request - :param end_time: Test item end time - :param launch_uuid: Parent launch UUID - :param status: Test status. Allowable values: "passed", - "failed", "stopped", "skipped", - "interrupted", "cancelled". - :param attributes: Test item attributes(tags). Pairs of key - and value. Overrides attributes on start - :param description: Test item description. Overrides - description from start request. - :param is_skipped_an_issue: Option to mark skipped tests as not - 'To Investigate' items in UI - :param issue: Issue of the current test item - :param retry: Used to report retry of the test. - Allowable values: "True" or "False" + @property + def payload(self) -> dict: + """Get HTTP payload for the request. + + :return: JSON representation in the form of a Dictionary """ - super(ItemFinishRequest, self).__init__() - self.attributes = attributes - self.description = description - self.end_time = end_time - self.is_skipped_an_issue = is_skipped_an_issue - self.issue = issue # type: Issue - self.launch_uuid = launch_uuid - self.status = status - self.retry = retry + return ItemFinishRequest._create_request(**self.__dict__) + + +class AsyncItemFinishRequest(ItemFinishRequest): + """ReportPortal finish test item request asynchronous model. + + https://github.com/reportportal/documentation/blob/master/src/md/src/DevGuides/reporting.md#finish-child-item + """ + + def __int__(self, *args, **kwargs) -> None: + """Initialize an instance of the request with attributes.""" + super.__init__(*args, **kwargs) @property - def payload(self): - """Get HTTP payload for the request.""" - if self.attributes and isinstance(self.attributes, dict): - self.attributes = dict_to_payload(self.attributes) - if self.issue is None and ( - self.status is not None and self.status.lower() == 'skipped' - ) and not self.is_skipped_an_issue: - issue_payload = {'issue_type': 'NOT_ISSUE'} - else: - issue_payload = None - return { - 'attributes': self.attributes, - 'description': self.description, - 'endTime': self.end_time, - 'issue': getattr(self.issue, 'payload', issue_payload), - 'launchUuid': self.launch_uuid, - 'status': self.status, - 'retry': self.retry - } + async def payload(self) -> dict: + """Get HTTP payload for the request. + + :return: JSON representation in the form of a Dictionary + """ + data = self.__dict__.copy() + data['launch_uuid'] = await await_if_necessary(data.pop('launch_uuid')) + return ItemFinishRequest._create_request(**data) +@dataclass(frozen=True) class RPRequestLog(RPRequestBase): - """RP log save request model. + """ReportPortal log save request model. https://github.com/reportportal/documentation/blob/master/src/md/src/DevGuides/reporting.md#save-single-log-without-attachment """ - def __init__(self, - launch_uuid, - time, - file=None, - item_uuid=None, - level=RP_LOG_LEVELS[40000], - message=None): - """Initialize instance attributes. + launch_uuid: Any + time: str + file: Optional[RPFile] = None + item_uuid: Optional[Any] = None + level: str = RP_LOG_LEVELS[40000] + message: Optional[str] = None + + @staticmethod + def _create_request(**kwargs) -> dict: + request = { + 'launchUuid': kwargs['launch_uuid'], + 'level': kwargs['level'], + 'message': kwargs.get('message'), + 'time': kwargs['time'], + 'itemUuid': kwargs.get('item_uuid'), + 'file': kwargs.get('file') + } + if 'file' in kwargs and kwargs['file']: + request['file'] = {'name': kwargs['file'].name} + return request - :param launch_uuid: Launch UUID - :param time: Log time - :param file: Object of the RPFile - :param item_uuid: Test item UUID - :param level: Log level. Allowable values: error(40000), - warn(30000), info(20000), debug(10000), - trace(5000), fatal(50000), unknown(60000) - :param message: Log message + @property + def payload(self) -> dict: + """Get HTTP payload for the request. + + :return: JSON representation in the form of a Dictionary """ - super(RPRequestLog, self).__init__() - self.file = file # type: RPFile - self.launch_uuid = launch_uuid - self.level = level - self.message = message - self.time = time - self.item_uuid = item_uuid - self.priority = LOW_PRIORITY + return RPRequestLog._create_request(**self.__dict__) - def __file(self): - """Form file payload part of the payload.""" - if not self.file: - return {} - return {'file': {'name': self.file.name}} + @staticmethod + def _multipart_size(payload: dict, file: Optional[RPFile]): + size = helpers.calculate_json_part_size(payload) + size += helpers.calculate_file_part_size(file) + return size @property - def payload(self): - """Get HTTP payload for the request.""" - payload = { - 'launchUuid': self.launch_uuid, - 'level': self.level, - 'message': self.message, - 'time': self.time, - 'itemUuid': self.item_uuid - } - payload.update(self.__file()) - return payload + def multipart_size(self) -> int: + """Calculate request size how it would be transfer in Multipart HTTP. + + :return: estimate request size + """ + return RPRequestLog._multipart_size(self.payload, self.file) + + +class AsyncRPRequestLog(RPRequestLog): + """ReportPortal log save request asynchronous model. + + https://github.com/reportportal/documentation/blob/master/src/md/src/DevGuides/reporting.md#save-single-log-without-attachment + """ + + def __int__(self, *args, **kwargs) -> None: + """Initialize an instance of the request with attributes.""" + super.__init__(*args, **kwargs) @property - def multipart_size(self): - """Calculate request size how it would transfer in Multipart HTTP.""" - size = helpers.calculate_json_part_size(self.payload) - size += helpers.calculate_file_part_size(self.file) - return size + async def payload(self) -> dict: + """Get HTTP payload for the request. + + :return: JSON representation in the form of a Dictionary + """ + data = self.__dict__.copy() + uuids = await asyncio.gather(await_if_necessary(data.pop('launch_uuid')), + await_if_necessary(data.pop('item_uuid'))) + data['launch_uuid'] = uuids[0] + data['item_uuid'] = uuids[1] + return RPRequestLog._create_request(**data) + + @property + async def multipart_size(self) -> int: + """Calculate request size how it would be transfer in Multipart HTTP. + + :return: estimate request size + """ + return RPRequestLog._multipart_size(await self.payload, self.file) class RPLogBatch(RPRequestBase): - """RP log save batches with attachments request model. + """ReportPortal log save batches with attachments request model. https://github.com/reportportal/documentation/blob/master/src/md/src/DevGuides/reporting.md#batch-save-logs """ - def __init__(self, log_reqs): + default_content: str + log_reqs: List[Union[RPRequestLog, AsyncRPRequestLog]] + priority: Priority + + def __init__(self, log_reqs: List[Union[RPRequestLog, AsyncRPRequestLog]]) -> None: """Initialize instance attributes. :param log_reqs: """ - super(RPLogBatch, self).__init__() + super().__init__() self.default_content = 'application/octet-stream' self.log_reqs = log_reqs self.priority = LOW_PRIORITY - def __get_file(self, rp_file): + def __get_file(self, rp_file) -> Tuple[str, tuple]: """Form a tuple for the single file.""" return ('file', (rp_file.name, rp_file.content, rp_file.content_type or self.default_content)) - def __get_files(self): + def _get_files(self) -> List[Tuple[str, tuple]]: """Get list of files for the JSON body.""" files = [] for req in self.log_reqs: @@ -458,8 +519,19 @@ def __get_files(self): files.append(self.__get_file(req.file)) return files - def __get_request_part(self): - r"""Form JSON body for the request. + def __get_request_part(self) -> List[Tuple[str, tuple]]: + body = [( + 'json_request_part', ( + None, + json_converter.dumps([log.payload for log in self.log_reqs]), + 'application/json' + ) + )] + return body + + @property + def payload(self) -> List[Tuple[str, tuple]]: + r"""Get HTTP payload for the request. Example: [('json_request_part', @@ -476,17 +548,36 @@ def __get_request_part(self): '\n

Paragraph

', 'text/html'))] """ - body = [( - 'json_request_part', ( - None, - json.dumps([log.payload for log in self.log_reqs]), - 'application/json' - ) - )] - body.extend(self.__get_files()) + body = self.__get_request_part() + body.extend(self._get_files()) return body + +class AsyncRPLogBatch(RPLogBatch): + """ReportPortal log save batches with attachments request asynchronous model. + + https://github.com/reportportal/documentation/blob/master/src/md/src/DevGuides/reporting.md#batch-save-logs + """ + + def __int__(self, *args, **kwargs) -> None: + """Initialize an instance of the request with attributes.""" + super.__init__(*args, **kwargs) + + async def __get_request_part(self) -> List[dict]: + coroutines = [log.payload for log in self.log_reqs] + return list(await asyncio.gather(*coroutines)) + @property - def payload(self): - """Get HTTP payload for the request.""" - return self.__get_request_part() + async def payload(self) -> aiohttp.MultipartWriter: + """Get HTTP payload for the request. + + :return: Multipart request object capable to send with AIOHTTP + """ + json_payload = aiohttp.JsonPayload(await self.__get_request_part()) + json_payload.set_content_disposition('form-data', name='json_request_part') + mp_writer = aiohttp.MultipartWriter('form-data') + mp_writer.append_payload(json_payload) + for _, file in self._get_files(): + file_payload = aiohttp.Payload(file[1], content_type=file[2], filename=file[0]) + mp_writer.append_payload(file_payload) + return mp_writer diff --git a/reportportal_client/core/rp_requests.pyi b/reportportal_client/core/rp_requests.pyi deleted file mode 100644 index 332cccf3..00000000 --- a/reportportal_client/core/rp_requests.pyi +++ /dev/null @@ -1,214 +0,0 @@ -# Copyright (c) 2022 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 - -from typing import Any, Callable, ByteString, Dict, IO, List, Optional, Text, \ - Union, Tuple - -from reportportal_client.core.rp_file import RPFile as RPFile -from reportportal_client.core.rp_issues import Issue as Issue -from reportportal_client.core.rp_responses import RPResponse as RPResponse -from reportportal_client.static.abstract import AbstractBaseClass -from reportportal_client.static.defines import Priority as Priority - - -class HttpRequest: - session_method: Callable = ... - url: Text = ... - files: Optional[Dict] = ... - data: Optional[Union[Dict, List[Union[tuple, ByteString]], IO]] = ... - json: Optional[Dict] = ... - verify_ssl: Optional[bool] = ... - http_timeout: Union[float, Tuple[float, float]] = ... - name: Optional[Text] = ... - - def __init__(self, - session_method: Callable, - url: Text, - data=Optional[ - Union[Dict, List[Union[tuple, ByteString, IO]]]], - json=Optional[Dict], - files=Optional[Dict], - verify_ssl=Optional[bool], - name=Optional[Text]) -> None: ... - - def make(self) -> Optional[RPResponse]: ... - - -class RPRequestBase(metaclass=AbstractBaseClass): - __metaclass__: AbstractBaseClass = ... - _http_request: Optional[HttpRequest] = ... - _priority: Priority = ... - _response: Optional[RPResponse] = ... - - def __init__(self) -> None: ... - - def __lt__(self, other: RPRequestBase) -> bool: ... - - @property - def http_request(self) -> HttpRequest: ... - - @http_request.setter - def http_request(self, value: HttpRequest) -> None: ... - - @property - def priority(self) -> Priority: ... - - @priority.setter - def priority(self, value: Priority) -> None: ... - - @property - def response(self) -> Optional[RPResponse]: ... - - @response.setter - def response(self, value: RPResponse) -> None: ... - - def payload(self) -> Dict: ... - - -class LaunchStartRequest(RPRequestBase): - attributes: Optional[Union[List, Dict]] = ... - description: Text = ... - mode: Text = ... - name: Text = ... - rerun: bool = ... - rerun_of: Text = ... - start_time: Text = ... - uuid: Text = ... - - def __init__(self, - name: Text, - start_time: Text, - attributes: Optional[Union[List, Dict]] = ..., - description: Optional[Text] = ..., - mode: Text = ..., - rerun: bool = ..., - rerun_of: Optional[Text] = ..., - uuid: Optional[Text] = ...) -> None: ... - - @property - def payload(self) -> Dict: ... - - -class LaunchFinishRequest(RPRequestBase): - attributes: Optional[Union[List, Dict]] = ... - description: Text = ... - end_time: Text = ... - status: Text = ... - - def __init__(self, - end_time: Text, - status: Optional[Text] = ..., - attributes: Optional[Union[List, Dict]] = ..., - description: Optional[Text] = ...) -> None: ... - - @property - def payload(self) -> Dict: ... - - -class ItemStartRequest(RPRequestBase): - attributes: Optional[Union[List, Dict]] = ... - code_ref: Text = ... - description: Text = ... - has_stats: bool = ... - launch_uuid: Text = ... - name: Text = ... - parameters: Optional[Union[List, Dict]] = ... - retry: bool = ... - start_time: Text = ... - test_case_id: Optional[Text] = ... - type_: Text = ... - uuid: Text = ... - unique_id: Text = ... - - def __init__(self, - name: Text, - start_time: Text, - type_: Text, - launch_uuid: Text, - attributes: Optional[Union[List, Dict]] = ..., - code_ref: Optional[Text] = ..., - description: Optional[Text] = ..., - has_stats: bool = ..., - parameters: Optional[Union[List, Dict]] = ..., - retry: bool = ..., - test_case_id: Optional[Text] = ..., - uuid: Optional[Any] = ..., - unique_id: Optional[Any] = ...) -> None: ... - - @property - def payload(self) -> Dict: ... - - -class ItemFinishRequest(RPRequestBase): - attributes: Optional[Union[List, Dict]] = ... - description: Text = ... - end_time: Text = ... - is_skipped_an_issue: bool = ... - issue: Issue = ... - launch_uuid: Text = ... - status: Text = ... - retry: bool = ... - - def __init__(self, - end_time: Text, - launch_uuid: Text, - status: Text, - attributes: Optional[Union[List, Dict]] = ..., - description: Optional[Any] = ..., - is_skipped_an_issue: bool = ..., - issue: Optional[Issue] = ..., - retry: bool = ...) -> None: ... - - @property - def payload(self) -> Dict: ... - - -class RPRequestLog(RPRequestBase): - file: RPFile = ... - launch_uuid: Text = ... - level: Text = ... - message: Text = ... - time: Text = ... - item_uuid: Text = ... - - def __init__(self, - launch_uuid: Text, - time: Text, - file: Optional[RPFile] = ..., - item_uuid: Optional[Text] = ..., - level: Text = ..., - message: Optional[Text] = ...) -> None: ... - - def __file(self) -> Dict: ... - - @property - def payload(self) -> Dict: ... - - @property - def multipart_size(self) -> int: ... - - -class RPLogBatch(RPRequestBase): - default_content: Text = ... - log_reqs: List[RPRequestLog] = ... - - def __init__(self, log_reqs: List[RPRequestLog]) -> None: ... - - def __get_file(self, rp_file: RPFile) -> tuple: ... - - def __get_files(self) -> List: ... - - def __get_request_part(self) -> Dict: ... - - @property - def payload(self) -> Dict: ... diff --git a/reportportal_client/core/rp_responses.py b/reportportal_client/core/rp_responses.py index 910e3b11..891336b3 100644 --- a/reportportal_client/core/rp_responses.py +++ b/reportportal_client/core/rp_responses.py @@ -1,10 +1,3 @@ -"""This module contains models for the RP response objects. - -Detailed information about responses wrapped up in that module -can be found by the following link: -https://github.com/reportportal/documentation/blob/master/src/md/src/DevGuides/reporting.md -""" - # Copyright (c) 2022 EPAM Systems # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -18,90 +11,166 @@ # See the License for the specific language governing permissions and # limitations under the License +"""This module contains models for the ReportPortal response objects. + +Detailed information about responses wrapped up in that module +can be found by the following link: +https://github.com/reportportal/documentation/blob/master/src/md/src/DevGuides/reporting.md +""" + import logging +from json import JSONDecodeError +from typing import Any, Optional, Generator, Mapping, Tuple + +from aiohttp import ClientResponse +from requests import Response -from reportportal_client.static.defines import NOT_FOUND +# noinspection PyProtectedMember +from reportportal_client._internal.static.defines import NOT_FOUND, NOT_SET logger = logging.getLogger(__name__) -class RPMessage(object): - """Model for the message returned by RP API.""" +def _iter_json_messages(json: Any) -> Generator[str, None, None]: + if not isinstance(json, Mapping): + return + data = json.get('responses', [json]) + for chunk in data: + message = chunk.get('message', chunk.get('error_code', NOT_FOUND)) + if message: + yield message - __slots__ = ['message', 'error_code'] - def __init__(self, data): - """Initialize instance attributes. +class RPResponse: + """Class representing ReportPortal API response.""" - :param data: Dictionary representation of the API response + _resp: Response + __json: Any + + def __init__(self, data: Response) -> None: + """Initialize an instance with attributes. + + :param data: requests.Response object """ - self.error_code = data.get('error_code', NOT_FOUND) - self.message = data.get('message', NOT_FOUND) + self._resp = data + self.__json = NOT_SET + + @property + def id(self) -> Optional[str]: + """Get value of the 'id' key in the response. - def __str__(self): - """Change string representation of the class.""" - if self.error_code is NOT_FOUND: - return self.message - return '{error_code}: {message}'.format(error_code=self.error_code, - message=self.message) + :return: ID as string or NOT_FOUND, or None if the response is not JSON + """ + if self.json is None: + return + return self.json.get('id', NOT_FOUND) @property - def is_empty(self): - """Check if returned message is empty.""" - return self.message is NOT_FOUND + def is_success(self) -> bool: + """Check if response to API has been successful. + :return: is response successful + """ + return self._resp.ok -class RPResponse(object): - """Class representing RP API response.""" + @property + def json(self) -> Any: + """Get the response in Dictionary or List. - __slots__ = ['_data', '_resp'] + :return: JSON represented as Dictionary or List, or None if the response is not JSON + """ + if self.__json is NOT_SET: + try: + self.__json = self._resp.json() + except (JSONDecodeError, TypeError): + self.__json = None + return self.__json - def __init__(self, data): - """Initialize instance attributes. + @property + def message(self) -> Optional[str]: + """Get value of the 'message' key in the response. - :param data: requests.Response object + :return: message as string or NOT_FOUND, or None if the response is not JSON """ - self._data = self._get_json(data) - self._resp = data + if self.json is None: + return + return self.json.get('message') - @staticmethod - def _get_json(data): - """Get response in dictionary. + @property + def messages(self) -> Optional[Tuple[str, ...]]: + """Get list of messages received in the response. - :param data: requests.Response object - :return: dict + :return: a variable size tuple of strings or NOT_FOUND, or None if the response is not JSON + """ + if self.json is None: + return + return tuple(_iter_json_messages(self.json)) + + +class AsyncRPResponse: + """Class representing ReportPortal API asynchronous response.""" + + _resp: ClientResponse + __json: Any + + def __init__(self, data: ClientResponse) -> None: + """Initialize an instance with attributes. + + :param data: aiohttp.ClientResponse object """ - return data.json() + self._resp = data + self.__json = NOT_SET @property - def id(self): - """Get value of the 'id' key.""" - return self.json.get('id', NOT_FOUND) + async def id(self) -> Optional[str]: + """Get value of the 'id' key in the response. + + :return: ID as string or NOT_FOUND, or None if the response is not JSON + """ + json = await self.json + if json is None: + return + return json.get('id', NOT_FOUND) @property - def is_success(self): - """Check if response to API has been successful.""" - return self._resp.ok + def is_success(self) -> bool: + """Check if response to API has been successful. - def _iter_messages(self): - """Generate RPMessage for each response.""" - data = self.json.get('responses', [self.json]) - for chunk in data: - message = RPMessage(chunk) - if not message.is_empty: - yield message + :return: is response successful + """ + return self._resp.ok @property - def json(self): - """Get the response in dictionary.""" - return self._data + async def json(self) -> Any: + """Get the response in Dictionary or List. + + :return: JSON represented as Dictionary or List, or None if the response is not JSON + """ + if self.__json is NOT_SET: + try: + self.__json = await self._resp.json() + except (JSONDecodeError, TypeError): + self.__json = None + return self.__json @property - def message(self): - """Get value of the 'message' key.""" - return self.json.get('message', NOT_FOUND) + async def message(self) -> Optional[str]: + """Get value of the 'message' key in the response. + + :return: message as string or NOT_FOUND, or None if the response is not JSON + """ + json = await self.json + if json is None: + return + return json.get('message', NOT_FOUND) @property - def messages(self): - """Get list of messages received.""" - return tuple(self._iter_messages()) + async def messages(self) -> Optional[Tuple[str, ...]]: + """Get list of messages received in the response. + + :return: a variable size tuple of strings or NOT_FOUND, or None if the response is not JSON + """ + json = await self.json + if json is None: + return + return tuple(_iter_json_messages(json)) diff --git a/reportportal_client/core/rp_responses.pyi b/reportportal_client/core/rp_responses.pyi deleted file mode 100644 index 92d95c05..00000000 --- a/reportportal_client/core/rp_responses.pyi +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright (c) 2022 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 - -from requests import Response -from typing import Dict, Iterable, Optional, Text, Tuple - -class RPMessage: - error_code: Text = ... - message: Text = ... - def __init__(self, data: Dict) -> None: ... - @property - def is_empty(self) -> bool: ... - -class RPResponse: - _data: Dict = ... - _resp: Response = ... - def __init__(self, data: Response) -> None: ... - def _get_json(self, data: Response) -> Dict: ... - def _iter_messages(self) -> Iterable[RPMessage]: ... - @property - def id(self) -> Optional[Text]: ... - @property - def is_success(self) -> bool: ... - @property - def json(self) -> Dict: ... - @property - def message(self) -> Text: ... - @property - def messages(self) -> Tuple[RPMessage]: ... diff --git a/reportportal_client/core/test_manager.py b/reportportal_client/core/test_manager.py deleted file mode 100644 index 0f8e2d19..00000000 --- a/reportportal_client/core/test_manager.py +++ /dev/null @@ -1,230 +0,0 @@ -"""This module contains functional for test items management.""" - -# Copyright (c) 2022 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 - -from reportportal_client.helpers import generate_uuid, dict_to_payload -from reportportal_client.items.rp_log_items.rp_log_item import RPLogItem -from reportportal_client.items.rp_test_items.rp_child_test_item import \ - RPChildTestItem -from reportportal_client.items.rp_test_items.rp_root_test_item import \ - RPRootTestItem - - -class TestManager(object): - """Manage test items during single launch. - - Test item types (item_type) can be: - (SUITE, STORY, TEST, SCENARIO, STEP, BEFORE_CLASS, - BEFORE_GROUPS, BEFORE_METHOD, BEFORE_SUITE, BEFORE_TEST, AFTER_CLASS, - AFTER_GROUPS, AFTER_METHOD, AFTER_SUITE, AFTER_TEST). - - 'attributes' and 'parameters' should be a dictionary - with the following format: - { - "": "", - "": "", - ... - } - """ - - def __init__(self, - session, - endpoint, - project_name, - launch_id=None - ): - """Initialize instance attributes. - - :param session: Session object - :param endpoint: Endpoint url - :param launch_id: ReportPortal launch UUID - :param project_name: RP project name - """ - self.session = session - self.endpoint = endpoint - self.project_name = project_name - self.launch_id = launch_id - self.__storage = [] - - def start_test_item(self, - api_version, - name, - start_time, - item_type, - description=None, - attributes=None, - parameters=None, - parent_uuid=None, - has_stats=True, - **kwargs): - """Start new test item. - - :param api_version: RP API version - :param name: test item name - :param start_time: test item execution start time - :param item_type: test item type (see class doc string) - :param description: test item description - :param attributes: test item attributes(tags) - Pairs of key and value (see class doc string) - :param parameters: test item set of parameters - (for parametrized tests) (see class doc string) - :param parent_uuid: UUID of parent test item - :param has_stats: True - regular test item, False - test item - without statistics (nested step) - :param kwargs: other parameters - :return: test item UUID - """ - if attributes and isinstance(attributes, dict): - attributes = dict_to_payload(attributes) - if parameters: - parameters = dict_to_payload(parameters) - - item_data = { - "description": description, - "attributes": attributes, - "parameters": parameters, - "has_stats": has_stats - } - kwargs and item_data.update(kwargs) - uuid = generate_uuid() - if not parent_uuid: - test_item = RPRootTestItem(self.endpoint, - self.session, - self.project_name, - name, - item_type, - self.launch_id, - uuid, - **item_data) - self.__storage.append(test_item) - else: - parent_item = self.get_test_item(parent_uuid) - test_item = RPChildTestItem(self.endpoint, - self.session, - self.project_name, - parent_item, - name, - item_type, - self.launch_id, - uuid, - **item_data) - test_item.start(api_version, start_time) - return uuid - - def update_test_item(self, api_version, item_uuid, attributes=None, - description=None, **kwargs): - """Update existing test item at the ReportPortal. - - :param api_version: RP API version - :param str item_uuid: test item UUID returned on the item start - :param str description: test item description - :param dict attributes: test item attributes(tags) - Pairs of key and value (see class doc string) - """ - self.get_test_item(item_uuid) - raise NotImplementedError() - - def finish_test_item(self, - api_version, - item_uuid, - end_time, - status, - issue=None, - attributes=None, - **kwargs): - """Finish test item. - - :param api_version:RP API version - :param item_uuid: id of the test item - :param end_time: time in UTC format - :param status: status of the test - :param issue: description of an issue - :param attributes: dict with attributes - :param kwargs: other parameters - """ - # check if the test is skipped, if not - do not mark as TO INVESTIGATE - if issue is None and status == "SKIPPED": - issue = {"issue_type": "NOT_ISSUE"} - if attributes and isinstance(attributes, dict): - attributes = dict_to_payload(attributes) - self.get_test_item(item_uuid).finish(api_version, - end_time, - status, - issue=issue, - attributes=attributes, - **kwargs) - - def remove_test_item(self, api_version, item_uuid): - """Remove test item by uuid. - - :param api_version: RP API version - :param item_uuid: test item uuid - """ - self.get_test_item(item_uuid) - raise NotImplementedError() - - def log(self, api_version, time, message=None, level=None, attachment=None, - item_id=None): - """Log message. Can be added to test item in any state. - - :param api_version: RP API version - :param time: log time - :param message: log message - :param level: log level - :param attachment: attachments to log (images,files,etc.) - :param item_id: parent item UUID - :return: log item UUID - """ - uuid = generate_uuid() - log_item = RPLogItem(self.endpoint, - self.session, - self.project_name, - self.launch_id, - uuid) - log_item.create(api_version, time, attachment, item_id, level, message) - return uuid - - def get_test_item(self, item_uuid): - """Get test item by its uuid in the storage. - - :param item_uuid: test item uuid - :return: test item object if found else None - """ - # Todo: add 'force' parameter to get item from ReportPortal server - # instead of cache and update cache data according to this request - return self._find_item(item_uuid, self.__storage) - - def _find_item(self, item_uuid, storage): - """Find test item by its uuid in given storage. - - :param item_uuid: test item uuid - :param storage: list with test item objects - :return: test item object if found else None - """ - for test_item in reversed(storage): - if item_uuid == test_item.generated_id: - return test_item - else: - if hasattr(test_item, "child_items") and test_item.child_items: - found_item = self._find_item(item_uuid, - test_item.child_items) - if found_item: - return found_item - - def get_storage(self): - """Get storage. - - :return: storage with test items - """ - return self.__storage diff --git a/reportportal_client/core/worker.py b/reportportal_client/core/worker.py index 64127c55..c9af496f 100644 --- a/reportportal_client/core/worker.py +++ b/reportportal_client/core/worker.py @@ -1,5 +1,3 @@ -"""This module contains worker that makes non-blocking HTTP requests.""" - # Copyright (c) 2022 EPAM Systems # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,13 +11,18 @@ # See the License for the specific language governing permissions and # limitations under the License +"""This module contains worker that makes non-blocking HTTP requests.""" + import logging +import queue import threading +import warnings from threading import current_thread, Thread from aenum import auto, Enum, unique -from reportportal_client.static.defines import Priority -from six.moves import queue + +# noinspection PyProtectedMember +from reportportal_client._internal.static.defines import Priority logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) @@ -58,6 +61,12 @@ class APIWorker(object): def __init__(self, task_queue): """Initialize instance attributes.""" + warnings.warn( + message='`APIWorker` class is deprecated since 5.5.0 and will be subject for removing in the' + ' next major version.', + category=DeprecationWarning, + stacklevel=2 + ) self._queue = task_queue self._thread = None self._stop_lock = threading.Condition() @@ -91,11 +100,10 @@ def _request_process(self, request): """Send request to RP and update response attribute of the request.""" logger.debug('[%s] Processing {%s} request', self.name, request) try: - request.response = request.http_request.make() + request.make() except Exception as err: logger.exception('[%s] Unknown exception has occurred. ' 'Skipping it.', err) - self._queue.task_done() def _monitor(self): """Monitor worker queues and process them. diff --git a/reportportal_client/core/worker.pyi b/reportportal_client/core/worker.pyi index ad0c9a3e..8bf5d897 100644 --- a/reportportal_client/core/worker.pyi +++ b/reportportal_client/core/worker.pyi @@ -19,8 +19,9 @@ from typing import Any, Optional, Text, Union from aenum import Enum -from reportportal_client.core.rp_requests import RPRequestBase as RPRequest -from static.defines import Priority +# noinspection PyProtectedMember +from reportportal_client._internal.static.defines import Priority +from reportportal_client.core.rp_requests import RPRequestBase as RPRequest, HttpRequest logger: Logger THREAD_TIMEOUT: int @@ -53,7 +54,7 @@ class APIWorker: def _command_process(self, cmd: Optional[ControlCommand]) -> None: ... - def _request_process(self, request: Optional[RPRequest]) -> None: ... + def _request_process(self, request: Optional[HttpRequest]) -> None: ... def _monitor(self) -> None: ... @@ -63,7 +64,7 @@ class APIWorker: def is_alive(self) -> bool: ... - def send(self, cmd: Union[ControlCommand, RPRequest]) -> Any: ... + def send(self, cmd: Union[ControlCommand, HttpRequest]) -> Any: ... def start(self) -> None: ... diff --git a/reportportal_client/errors.py b/reportportal_client/errors.py index 5f1d993b..3c95870d 100644 --- a/reportportal_client/errors.py +++ b/reportportal_client/errors.py @@ -1,37 +1,26 @@ -""" -Copyright (c) 2018 http://reportportal.io . +# Copyright (c) 2023 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 -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 +"""This module includes exceptions used by the client.""" -http://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. -""" +class RPExceptionBase(Exception): + """General exception for the package.""" -class Error(Exception): - """General exception for package.""" +class RPError(RPExceptionBase): + """General error exception for package.""" -class ResponseError(Error): +class ResponseError(RPError): """Error in response returned by RP.""" - - -class EntryCreatedError(ResponseError): - """Represents error in case no entry is created. - - No 'id' in the json response. - """ - - -class OperationCompletionError(ResponseError): - """Represents error in case of operation failure. - - No 'message' in the json response. - """ diff --git a/reportportal_client/helpers.py b/reportportal_client/helpers.py index b52e5078..b7a902e7 100644 --- a/reportportal_client/helpers.py +++ b/reportportal_client/helpers.py @@ -1,49 +1,105 @@ -"""This module contains common functions-helpers of the client and agents. - -Copyright (c) 2022 https://reportportal.io . -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at -https://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" +# Copyright (c) 2023 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 common functions-helpers of the client and agents.""" + +import asyncio import inspect import json import logging +import sys +import threading import time import uuid from platform import machine, processor, system +from typing import Optional, Any, List, Dict, Callable, Tuple, Union, TypeVar, Generic -import six -from pkg_resources import DistributionNotFound, get_distribution +from reportportal_client.core.rp_file import RPFile -from .static.defines import ATTRIBUTE_LENGTH_LIMIT +logger: logging.Logger = logging.getLogger(__name__) +_T = TypeVar('_T') +ATTRIBUTE_LENGTH_LIMIT: int = 128 +TRUNCATE_REPLACEMENT: str = '...' -logger = logging.getLogger(__name__) +class LifoQueue(Generic[_T]): + """Primitive thread-safe Last-in-first-out queue implementation.""" -def generate_uuid(): - """Generate UUID.""" - return str(uuid.uuid4()) + _lock: threading.Lock() + __items: List[_T] + def __init__(self): + """Initialize the queue instance.""" + self._lock = threading.Lock() + self.__items = [] -def convert_string(value): - """Support and convert strings in py2 and py3. + def put(self, element: _T) -> None: + """Add an element to the queue.""" + with self._lock: + self.__items.append(element) - :param value: input string - :return value: converted string - """ - if isinstance(value, six.text_type): - # Don't try to encode 'unicode' in Python 2. - return value - return str(value) + def get(self) -> Optional[_T]: + """Return and remove the last element from the queue. + + :return: The last element in the queue. + """ + result = None + with self._lock: + if len(self.__items) > 0: + result = self.__items[-1] + self.__items = self.__items[:-1] + return result + + def last(self) -> _T: + """Return the last element from the queue, but does not remove it. + + :return: The last element in the queue. + """ + with self._lock: + if len(self.__items) > 0: + return self.__items[-1] + + def qsize(self): + """Return the queue size.""" + with self._lock: + return len(self.__items) + + def __getstate__(self) -> Dict[str, Any]: + """Control object pickling and return object fields as Dictionary. + + :return: object state dictionary + :rtype: dict + """ + state = self.__dict__.copy() + # Don't pickle 'session' field, since it contains unpickling 'socket' + del state['_lock'] + return state + + def __setstate__(self, state: Dict[str, Any]) -> None: + """Control object pickling, receives object state as Dictionary. + + :param dict state: object state dictionary + """ + self.__dict__.update(state) + self._lock = threading.Lock() + + +def generate_uuid() -> str: + """Generate UUID.""" + return str(uuid.uuid4()) -def dict_to_payload(dictionary): +def dict_to_payload(dictionary: Optional[dict]) -> Optional[List[dict]]: """Convert incoming dictionary to the list of dictionaries. This function transforms the given dictionary of tags/attributes into @@ -53,14 +109,21 @@ def dict_to_payload(dictionary): :param dictionary: Dictionary containing tags/attributes :return list: List of tags/attributes in the required format """ - hidden = dictionary.pop('system', False) - return [ - {'key': key, 'value': convert_string(value), 'system': hidden} - for key, value in sorted(dictionary.items()) - ] + if not dictionary: + return dictionary + my_dictionary = dict(dictionary) + + hidden = my_dictionary.pop('system', None) + result = [] + for key, value in sorted(my_dictionary.items()): + attribute = {'key': str(key), 'value': str(value)} + if hidden is not None: + attribute['system'] = hidden + result.append(attribute) + return result -def gen_attributes(rp_attributes): +def gen_attributes(rp_attributes: List[str]) -> List[Dict[str, str]]: """Generate list of attributes for the API request. Example of input list: @@ -89,7 +152,7 @@ def gen_attributes(rp_attributes): return attrs -def get_launch_sys_attrs(): +def get_launch_sys_attrs() -> Dict[str, str]: """Generate attributes for the launch containing system information. :return: dict {'os': 'Windows', @@ -104,49 +167,101 @@ def get_launch_sys_attrs(): } -def get_package_version(package_name): +def get_package_parameters(package_name: str, parameters: List[str] = None) -> List[Optional[str]]: + """Get parameters of the given package. + + :param package_name: Name of the package. + :param parameters: Wanted parameters. + :return: Parameter List. + """ + result = [] + if not parameters: + return result + + if sys.version_info < (3, 8): + from pkg_resources import get_distribution, DistributionNotFound + try: + package_info = get_distribution(package_name) + except DistributionNotFound: + return [None] * len(parameters) + for param in parameters: + if param.lower() == 'name': + param = 'project_name' + result.append(getattr(package_info, param, None)) + else: + # noinspection PyCompatibility + from importlib.metadata import distribution, PackageNotFoundError + try: + package_info = distribution(package_name) + except PackageNotFoundError: + return [None] * len(parameters) + for param in parameters: + result.append(package_info.metadata[param.lower()[:1].upper() + param.lower()[1:]]) + return result + + +def get_package_version(package_name: str) -> Optional[str]: """Get version of the given package. - :param package_name: Name of the package - :return: Version of the package + :param package_name: Name of the package. + :return: Version of the package. + """ + return get_package_parameters(package_name, ['version'])[0] + + +def truncate_attribute_string(text: str) -> str: + """Truncate a text if it's longer than allowed. + + :param text: Text to truncate. + :return: Truncated text. """ - try: - package_version = get_distribution(package_name).version - except DistributionNotFound: - package_version = 'not found' - return package_version + truncation_length = len(TRUNCATE_REPLACEMENT) + if len(text) > ATTRIBUTE_LENGTH_LIMIT and len(text) > truncation_length: + return text[:ATTRIBUTE_LENGTH_LIMIT - truncation_length] + TRUNCATE_REPLACEMENT + return text -def verify_value_length(attributes): +def verify_value_length(attributes: Optional[Union[List[dict], dict]]) -> Optional[List[dict]]: """Verify length of the attribute value. The length of the attribute value should have size from '1' to '128'. - Otherwise HTTP response will return an error. + Otherwise, HTTP response will return an error. Example of the input list: [{'key': 'tag_name', 'value': 'tag_value1'}, {'value': 'tag_value2'}] + :param attributes: List of attributes(tags) :return: List of attributes with corrected value length """ - if attributes is not None: - for pair in attributes: - if not isinstance(pair, dict): - continue - attr_value = pair.get('value') - if attr_value is None: - continue - try: - pair['value'] = attr_value[:ATTRIBUTE_LENGTH_LIMIT] - except TypeError: - continue - return attributes - - -def timestamp(): + if attributes is None: + return + + my_attributes = attributes + if isinstance(my_attributes, dict): + my_attributes = dict_to_payload(my_attributes) + + result = [] + for pair in my_attributes: + if not isinstance(pair, dict): + continue + attr_value = pair.get('value') + if attr_value is None: + continue + truncated = {} + truncated.update(pair) + result.append(truncated) + attr_key = pair.get('key') + if attr_key: + truncated['key'] = truncate_attribute_string(str(attr_key)) + truncated['value'] = truncate_attribute_string(str(attr_value)) + return result + + +def timestamp() -> str: """Return string representation of the current time in milliseconds.""" return str(int(time.time() * 1000)) -def uri_join(*uri_parts): +def uri_join(*uri_parts: str) -> str: """Join uri parts. Avoiding usage of urlparse.urljoin and os.path.join @@ -160,7 +275,21 @@ def uri_join(*uri_parts): return '/'.join(str(s).strip('/').strip('\\') for s in uri_parts) -def get_function_params(func, args, kwargs): +def root_uri_join(*uri_parts: str) -> str: + """Join uri parts. Format it as path from server root. + + Avoiding usage of urlparse.urljoin and os.path.join + as it does not clearly join parts. + Args: + *uri_parts: tuple of values for join, can contain back and forward + slashes (will be stripped up). + Returns: + An uri string. + """ + return '/' + uri_join(*uri_parts) + + +def get_function_params(func: Callable, args: tuple, kwargs: Dict[str, Any]) -> Dict[str, Any]: """Extract argument names from the function and combine them with values. :param func: the function to get arg names @@ -168,11 +297,7 @@ def get_function_params(func, args, kwargs): :param kwargs: function's kwargs :return: a dictionary of values """ - if six.PY2: - # Use deprecated method for python 2.7 compatibility - arg_spec = inspect.getargspec(func) - else: - arg_spec = inspect.getfullargspec(func) + arg_spec = inspect.getfullargspec(func) result = dict() for i, arg_name in enumerate(arg_spec.args): if i >= len(args): @@ -183,36 +308,36 @@ def get_function_params(func, args, kwargs): return result if len(result.items()) > 0 else None -TYPICAL_MULTIPART_BOUNDARY = '--972dbca3abacfd01fb4aea0571532b52' +TYPICAL_MULTIPART_BOUNDARY: str = '--972dbca3abacfd01fb4aea0571532b52' -TYPICAL_JSON_PART_HEADER = TYPICAL_MULTIPART_BOUNDARY + '''\r +TYPICAL_JSON_PART_HEADER: str = TYPICAL_MULTIPART_BOUNDARY + '''\r Content-Disposition: form-data; name="json_request_part"\r Content-Type: application/json\r \r ''' -TYPICAL_FILE_PART_HEADER = TYPICAL_MULTIPART_BOUNDARY + '''\r +TYPICAL_FILE_PART_HEADER: str = TYPICAL_MULTIPART_BOUNDARY + '''\r Content-Disposition: form-data; name="file"; filename="{0}"\r Content-Type: {1}\r \r ''' -TYPICAL_JSON_PART_HEADER_LENGTH = len(TYPICAL_JSON_PART_HEADER) +TYPICAL_JSON_PART_HEADER_LENGTH: int = len(TYPICAL_JSON_PART_HEADER) -TYPICAL_MULTIPART_FOOTER = '\r\n' + TYPICAL_MULTIPART_BOUNDARY + '--' +TYPICAL_MULTIPART_FOOTER: str = '\r\n' + TYPICAL_MULTIPART_BOUNDARY + '--' -TYPICAL_MULTIPART_FOOTER_LENGTH = len(TYPICAL_MULTIPART_FOOTER) +TYPICAL_MULTIPART_FOOTER_LENGTH: int = len(TYPICAL_MULTIPART_FOOTER) -TYPICAL_JSON_ARRAY = '[]' +TYPICAL_JSON_ARRAY: str = '[]' -TYPICAL_JSON_ARRAY_LENGTH = len(TYPICAL_JSON_ARRAY) +TYPICAL_JSON_ARRAY_LENGTH: int = len(TYPICAL_JSON_ARRAY) -TYPICAL_JSON_ARRAY_ELEMENT = ',' +TYPICAL_JSON_ARRAY_ELEMENT: str = ',' -TYPICAL_JSON_ARRAY_ELEMENT_LENGTH = len(TYPICAL_JSON_ARRAY_ELEMENT) +TYPICAL_JSON_ARRAY_ELEMENT_LENGTH: int = len(TYPICAL_JSON_ARRAY_ELEMENT) -def calculate_json_part_size(json_dict): +def calculate_json_part_size(json_dict: dict) -> int: """Predict a JSON part size of Multipart request. :param json_dict: a dictionary representing the JSON @@ -225,7 +350,7 @@ def calculate_json_part_size(json_dict): return size -def calculate_file_part_size(file): +def calculate_file_part_size(file: Optional[RPFile]) -> int: """Predict a file part size of Multipart request. :param file: RPFile class instance @@ -236,3 +361,33 @@ def calculate_file_part_size(file): size = len(TYPICAL_FILE_PART_HEADER.format(file.name, file.content_type)) size += len(file.content) return size + + +def agent_name_version(attributes: Optional[Union[list, dict]] = None) -> Tuple[Optional[str], Optional[str]]: + """Extract Agent name and version from given Launch attributes. + + :param attributes: Launch attributes as they provided to Start Launch call + :return: Tuple of (agent name, version) + """ + my_attributes = attributes + if isinstance(my_attributes, dict): + my_attributes = dict_to_payload(my_attributes) + agent_name, agent_version = None, None + agent_attribute = [a for a in my_attributes if a.get('key') == 'agent'] if my_attributes else [] + if len(agent_attribute) > 0 and agent_attribute[0].get('value'): + agent_name, agent_version = agent_attribute[0]['value'].split('|') + 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 diff --git a/reportportal_client/helpers.pyi b/reportportal_client/helpers.pyi deleted file mode 100644 index 8d52d805..00000000 --- a/reportportal_client/helpers.pyi +++ /dev/null @@ -1,32 +0,0 @@ -from core.rp_file import RPFile -from .errors import EntryCreatedError as EntryCreatedError, \ - OperationCompletionError as OperationCompletionError, \ - ResponseError as ResponseError -from logging import Logger - -from typing import Text, Tuple, List, Callable, Any, Dict, Optional -from requests import Response - -logger: Logger - -TYPICAL_FILE_PART_HEADER: Text -TYPICAL_MULTIPART_FOOTER_LENGTH: int - -def generate_uuid() -> Text: ... -def convert_string(value: Text) -> Text: ... -def dict_to_payload(dictionary: dict) -> list[dict]: ... -def gen_attributes(rp_attributes: List[Text]) -> List[Dict[Text, Text]]: ... -def get_launch_sys_attrs()-> dict[Text]: ... -def get_package_version(package_name:Text) -> Text: ... -def uri_join(*uri_parts: Text) -> Text: ... -def get_id(response: Response) -> Text: ... -def get_msg(response: Response) -> dict: ... -def get_data(response: Response) -> dict: ... -def get_json(response: Response) -> dict: ... -def get_error_messages(data: dict) -> list: ... -def verify_value_length(attributes: list[dict]) -> list[dict]: ... -def timestamp() -> Text: ... -def get_function_params(func: Callable, args: Tuple[Any, ...], - kwargs: Dict[Text, Any]) -> Dict[Text, Any]: ... -def calculate_json_part_size(json_dict: dict) -> int: ... -def calculate_file_part_size(file: RPFile) -> int: ... diff --git a/reportportal_client/items/__init__.py b/reportportal_client/items/__init__.py deleted file mode 100644 index a4f66a81..00000000 --- a/reportportal_client/items/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""This package provides common static objects and variables. - -Copyright (c) 2018 http://reportportal.io . - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://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. -""" diff --git a/reportportal_client/items/item_weight.py b/reportportal_client/items/item_weight.py deleted file mode 100644 index f1b40b90..00000000 --- a/reportportal_client/items/item_weight.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -This module contains common classes-helpers for the items. - -Copyright (c) 2018 http://reportportal.io . -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at -http://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. -""" - -from aenum import IntEnum - - -class ItemWeight(IntEnum): - """Enum with weights for different item types.""" - - ROOT_ITEM_WEIGHT = 1 - LOG_ITEM_WEIGHT = 10 diff --git a/reportportal_client/items/rp_base_item.py b/reportportal_client/items/rp_base_item.py deleted file mode 100644 index e5c63da5..00000000 --- a/reportportal_client/items/rp_base_item.py +++ /dev/null @@ -1,64 +0,0 @@ -"""This module contains functional for Base RP items management.""" - -# Copyright (c) 2023 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 - -from reportportal_client.core.rp_requests import HttpRequest - - -class BaseRPItem(object): - """This model stores attributes related to RP item.""" - - def __init__(self, rp_url, session, project_name, - launch_uuid, generated_id): - """Initialize instance attributes. - - :param rp_url: ReportPortal url - :param session: Session object - :param project_name: RP project name - :param launch_uuid: Parent launch UUID - :param generated_id: Id generated to speed up client - """ - self.uuid = None - self.weight = None - self.generated_id = generated_id - self.http_requests = [] - self.responses = [] - self.rp_url = rp_url - self.session = session - self.project_name = project_name - self.launch_uuid = launch_uuid - - @property - def http_request(self): - """Get last http request. - - :return: request object - """ - return self.http_requests[-1] if self.http_requests else None - - def add_request(self, endpoint, method, request_class, *args, **kwargs): - """Add new request object. - - :param endpoint: request endpoint - :param method: Session object method. Allowable values: get, - post, put, delete - :param request_class: request class object - :param args: request object attributes - :param kwargs: request object named attributes - :return: None - """ - rp_request = request_class(*args, **kwargs) - rp_request.http_request = HttpRequest(method, endpoint) - rp_request.priority = self.weight - self.http_requests.append(rp_request) diff --git a/reportportal_client/items/rp_log_items/__init__.py b/reportportal_client/items/rp_log_items/__init__.py deleted file mode 100644 index a4f66a81..00000000 --- a/reportportal_client/items/rp_log_items/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""This package provides common static objects and variables. - -Copyright (c) 2018 http://reportportal.io . - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://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. -""" diff --git a/reportportal_client/items/rp_log_items/rp_log_item.py b/reportportal_client/items/rp_log_items/rp_log_item.py deleted file mode 100644 index 409eab82..00000000 --- a/reportportal_client/items/rp_log_items/rp_log_item.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -This module contains functional for RP log items management. - -Copyright (c) 2018 http://reportportal.io . - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://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. -""" - -from reportportal_client.core.rp_requests import RPRequestLog -from reportportal_client.items.item_weight import ItemWeight -from reportportal_client.items.rp_base_item import BaseRPItem -from reportportal_client.static.defines import RP_LOG_LEVELS - - -class RPLogItem(BaseRPItem): - """This model stores attributes for RP log items.""" - - def __init__(self, rp_url, session, project_name, - launch_uuid, generated_id): - """Initialize instance attributes. - - :param rp_url: ReportPortal URL - :param session: Session object - :param project_name: RP project name - :param launch_uuid: Parent launch UUID - :param generated_id: Id generated to speed up client - """ - super(RPLogItem, self).__init__(rp_url, session, - project_name, launch_uuid, - generated_id) - self.weight = ItemWeight.LOG_ITEM_WEIGHT - - @property - def response(self): - """Get the response object for RP log item.""" - return self.responses[0] - - @response.setter - def response(self, value): - """Set the response object for RP log item.""" - raise NotImplementedError - - def create(self, api_version, time, file_obj=None, item_uuid=None, - level=RP_LOG_LEVELS[40000], message=None): - """Add request for log item creation. - - :param api_version: RP API version - :param time: Log item time - :param file_obj: Object of the RPFile - :param item_uuid: Parent test item UUID - :param level: Log level. Allowable values: error(40000), - warn(30000), info(20000), debug(10000), - trace(5000), fatal(50000), unknown(60000) - :param message: Log message - """ - endpoint = "/api/{version}/{projectName}/log".format( - version=api_version, projectName=self.project_name) - self.add_request(endpoint, self.session.post, RPRequestLog, - self.launch_uuid, time, file=file_obj, - item_uuid=item_uuid, level=level, message=message) diff --git a/reportportal_client/items/rp_test_items/__init__.py b/reportportal_client/items/rp_test_items/__init__.py deleted file mode 100644 index a4f66a81..00000000 --- a/reportportal_client/items/rp_test_items/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""This package provides common static objects and variables. - -Copyright (c) 2018 http://reportportal.io . - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://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. -""" diff --git a/reportportal_client/items/rp_test_items/rp_base_test_item.py b/reportportal_client/items/rp_test_items/rp_base_test_item.py deleted file mode 100644 index d7b18df6..00000000 --- a/reportportal_client/items/rp_test_items/rp_base_test_item.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -This module contains functional for Base RP test items management. - -Copyright (c) 2018 http://reportportal.io . - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://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. -""" - -from reportportal_client.items.rp_base_item import BaseRPItem -from reportportal_client.core.rp_requests import ItemFinishRequest - - -class RPBaseTestItem(BaseRPItem): - """This model stores common attributes for RP test items.""" - - def __init__(self, rp_url, session, project_name, item_name, - item_type, launch_uuid, generated_id, **kwargs): - """Initialize instance attributes. - - :param rp_url: ReportPortal url - :param session: Session object - :param project_name: RP project name - :param item_name: RP item name - :param item_type: Type of the test item. Allowable values: "suite", - "story", "test", "scenario", "step", - "before_class", "before_groups", "before_method", - "before_suite", "before_test", "after_class", - "after_groups", "after_method", "after_suite", - "after_test" - :param launch_uuid: Parent launch UUID - :param generated_id: Id generated to speed up client - :param has_stats: If item has stats - :param kwargs: Dict of additional named parameters - """ - super(RPBaseTestItem, self).__init__(rp_url, session, - project_name, launch_uuid, - generated_id) - self.item_name = item_name - self.item_type = item_type - self.description = kwargs.get("description") - self.attributes = kwargs.get("attributes") - self.uuid = kwargs.get("uuid") - self.code_ref = kwargs.get("code_ref") - self.parameters = kwargs.get("parameters") - self.unique_id = kwargs.get("unique_id") - self.retry = kwargs.get("retry", False) - self.has_stats = kwargs.get("has_stats", True) - self.child_items = [] - - def add_child_item(self, item): - """Add new child item to the list. - - :param item: test item object - :return: None - """ - self.child_items.append(item) - - def finish(self, api_version, end_time, status=None, description=None, - attributes=None, issue=None): - """Form finish request for RP test item. - - :param api_version: RP API version - :param end_time: Test item end time - :param status: Test status. Allowable values: "passed", - "failed", "stopped", "skipped", "interrupted", - "cancelled" - :param description: Test item description. - :param attributes: List with attributes - :param issue: Issue of the current test item - """ - attributes = attributes or self.attributes - endpoint = "{url}/api/{version}/{projectName}/item/{itemUuid}". \ - format(url=self.rp_url, version=api_version, - projectName=self.project_name, itemUuid=self.uuid) - - self.add_request(endpoint, self.session.post, ItemFinishRequest, - end_time, self.launch_uuid, status, - attributes=attributes, description=description, - issue=issue, retry=self.retry) diff --git a/reportportal_client/items/rp_test_items/rp_child_test_item.py b/reportportal_client/items/rp_test_items/rp_child_test_item.py deleted file mode 100644 index 5a5495d6..00000000 --- a/reportportal_client/items/rp_test_items/rp_child_test_item.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -This module contains functional for Child RP test items management. - -Copyright (c) 2018 http://reportportal.io . - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://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. -""" - -from weakref import proxy - -from reportportal_client.core.rp_requests import ItemStartRequest -from reportportal_client.items.rp_test_items.rp_base_test_item import \ - RPBaseTestItem - - -class RPChildTestItem(RPBaseTestItem): - """This model stores attributes for RP child test items.""" - - def __init__(self, rp_url, session, project_name, parent_item, - item_name, item_type, launch_uuid, generated_id, - **kwargs): - """Initialize instance attributes. - - :param rp_url: ReportPortal url - :param session: Session object - :param project_name: RP project name - :param item_name: RP item name - :param item_type: Type of the test item. Allowable values: "suite", - "story", "test", "scenario", "step", - "before_class", "before_groups", "before_method", - "before_suite", "before_test", "after_class", - "after_groups", "after_method", "after_suite", - "after_test" - :param launch_uuid: Parent launch UUID - :param generated_id: Id generated to speed up client - :param kwargs: Dict of additional named parameters - """ - super(RPChildTestItem, self).__init__(rp_url, session, - project_name, item_name, - item_type, launch_uuid, - generated_id, **kwargs) - self.parent_item = proxy(parent_item) - self.parent_item.add_child_item(self) - self.weight = self.parent_item.weight + 1 - - def start(self, api_version, start_time): - """Create request object to start child test item. - - :param api_version: RP API version - :param start_time: Test item start time - """ - endpoint = "{url}/{api_version}/{project_name}/item/" \ - "{parentItemUuid}". \ - format(url=self.rp_url, api_version=api_version, - project_name=self.project_name, - parentItemUuid=self.parent_item.uuid) - - self.add_request(endpoint, self.session.post, ItemStartRequest, - self.item_name, start_time, - self.item_type, self.launch_uuid, - attributes=self.attributes, code_ref=self.code_ref, - description=self.description, - has_stats=self.has_stats, - parameters=self.parameters, - retry=self.retry, uuid=self.uuid, - unique_id=self.unique_id) diff --git a/reportportal_client/items/rp_test_items/rp_root_test_item.py b/reportportal_client/items/rp_test_items/rp_root_test_item.py deleted file mode 100644 index 5b11b72e..00000000 --- a/reportportal_client/items/rp_test_items/rp_root_test_item.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -This module contains functional for Root RP test items management. - -Copyright (c) 2018 http://reportportal.io . - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://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. -""" - -from reportportal_client.core.rp_requests import ItemStartRequest -from reportportal_client.items.item_weight import ItemWeight -from reportportal_client.items.rp_test_items.rp_base_test_item import \ - RPBaseTestItem - - -class RPRootTestItem(RPBaseTestItem): - """This model stores attributes for RP Root test items.""" - - def __init__(self, rp_url, session, project_name, item_name, - item_type, launch_uuid, generated_id, **kwargs): - """Initialize instance attributes. - - :param rp_url: ReportPortal url - :param session: Session object - :param project_name: RP project name - :param item_name: RP item name - :param item_type: Type of the test item. Allowable values: - "suite", "story", "test", "scenario", "step", - "before_class", "before_groups", "before_method", - "before_suite", "before_test", "after_class", - "after_groups", "after_method", "after_suite", - "after_test" - :param launch_uuid: Parent launch UUID - :param generated_id: Id generated to speed up client - :param kwargs: Dict of additional named parameters - """ - super(RPRootTestItem, self).__init__(rp_url, session, - project_name, item_name, - item_type, launch_uuid, - generated_id, **kwargs) - self.weight = ItemWeight.ROOT_ITEM_WEIGHT - - def start(self, api_version, start_time): - """Create request object to start root test item. - - :param api_version: RP API version - :param start_time: Test item start time - """ - endpoint = "{rp_url}/api/{api_version}/{project_name}/item".format( - rp_url=self.rp_url, - api_version=api_version, - project_name=self.project_name) - - self.add_request(endpoint, self.session.post, ItemStartRequest, - self.item_name, start_time, - self.item_type, self.launch_uuid, - attributes=self.attributes, code_ref=self.code_ref, - description=self.description, - has_stats=self.has_stats, parameters=self.parameters, - retry=self.retry, uuid=self.uuid, - unique_id=self.unique_id) diff --git a/reportportal_client/logs/__init__.py b/reportportal_client/logs/__init__.py index 0d71eef2..c5c7a700 100644 --- a/reportportal_client/logs/__init__.py +++ b/reportportal_client/logs/__init__.py @@ -10,18 +10,20 @@ # 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 + """ReportPortal logging handling module.""" import logging import sys import threading - -from six import PY2 -from six.moves.urllib.parse import urlparse +from urllib.parse import urlparse # noinspection PyProtectedMember -from reportportal_client._local import current, set_current -from reportportal_client.helpers import timestamp +from reportportal_client._internal.local import current, set_current +from reportportal_client.helpers import timestamp, TYPICAL_MULTIPART_FOOTER_LENGTH + +MAX_LOG_BATCH_SIZE: int = 20 +MAX_LOG_BATCH_PAYLOAD_SIZE: int = int((64 * 1024 * 1024) * 0.98) - TYPICAL_MULTIPART_FOOTER_LENGTH class RPLogger(logging.getLoggerClass()): @@ -56,16 +58,11 @@ def _log(self, level, msg, args, exc_info=None, extra=None, # exception on some versions of IronPython. We trap it here so that # IronPython can use logging. try: - if PY2: - # In python2.7 findCaller() don't accept any parameters - # and returns 3 elements - fn, lno, func = self.findCaller() + if 'stacklevel' in kwargs: + fn, lno, func, sinfo = \ + self.findCaller(stack_info, kwargs['stacklevel']) else: - if 'stacklevel' in kwargs: - fn, lno, func, sinfo = \ - self.findCaller(stack_info, kwargs['stacklevel']) - else: - fn, lno, func, sinfo = self.findCaller(stack_info) + fn, lno, func, sinfo = self.findCaller(stack_info) except ValueError: # pragma: no cover fn, lno, func = '(unknown file)', 0, '(unknown function)' @@ -75,14 +72,8 @@ def _log(self, level, msg, args, exc_info=None, extra=None, if exc_info and not isinstance(exc_info, tuple): exc_info = sys.exc_info() - if PY2: - # In python2.7 makeRecord() accepts everything but sinfo - record = self.makeRecord(self.name, level, fn, lno, msg, args, - exc_info, func, extra) - else: - record = self.makeRecord(self.name, level, fn, lno, msg, args, - exc_info, func, extra, sinfo) - + record = self.makeRecord(self.name, level, fn, lno, msg, args, + exc_info, func, extra, sinfo) if not getattr(record, 'attachment', None): record.attachment = attachment self.handle(record) diff --git a/reportportal_client/logs/log_manager.py b/reportportal_client/logs/log_manager.py index 3533bce5..0d12bb77 100644 --- a/reportportal_client/logs/log_manager.py +++ b/reportportal_client/logs/log_manager.py @@ -1,5 +1,3 @@ -"""This module contains management functionality for processing logs.""" - # Copyright (c) 2022 EPAM Systems # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,12 +11,16 @@ # See the License for the specific language governing permissions and # limitations under the License +"""This module contains management functionality for processing logs.""" + import logging +import queue +import warnings from threading import Lock -from six.moves import queue - from reportportal_client import helpers +# noinspection PyProtectedMember +from reportportal_client._internal.static.defines import NOT_FOUND from reportportal_client.core.rp_requests import ( HttpRequest, RPFile, @@ -26,15 +28,12 @@ RPRequestLog ) from reportportal_client.core.worker import APIWorker -from reportportal_client.static.defines import NOT_FOUND +from reportportal_client.logs import MAX_LOG_BATCH_SIZE, MAX_LOG_BATCH_PAYLOAD_SIZE logger = logging.getLogger(__name__) -MAX_LOG_BATCH_SIZE = 20 -MAX_LOG_BATCH_PAYLOAD_SIZE = 65000000 - -class LogManager(object): +class LogManager: """Manager of the log items.""" def __init__(self, rp_url, session, api_version, launch_id, project_name, @@ -54,6 +53,12 @@ def __init__(self, rp_url, session, api_version, launch_id, project_name, :param max_payload_size: maximum size in bytes of logs that can be processed in one batch """ + warnings.warn( + message='`LogManager` class is deprecated since 5.5.0 and will be subject for removing in the' + ' next major version.', + category=DeprecationWarning, + stacklevel=2 + ) self._lock = Lock() self._batch = [] self._payload_size = helpers.TYPICAL_MULTIPART_FOOTER_LENGTH @@ -79,8 +84,7 @@ def _send_batch(self): http_request = HttpRequest( self.session.post, self._log_endpoint, files=batch.payload, verify_ssl=self.verify_ssl) - batch.http_request = http_request - self._worker.send(batch) + self._worker.send(http_request) self._batch = [] self._payload_size = helpers.TYPICAL_MULTIPART_FOOTER_LENGTH diff --git a/reportportal_client/logs/log_manager.pyi b/reportportal_client/logs/log_manager.pyi index cd59fc29..5feb42ab 100644 --- a/reportportal_client/logs/log_manager.pyi +++ b/reportportal_client/logs/log_manager.pyi @@ -11,23 +11,18 @@ # See the License for the specific language governing permissions and # limitations under the License +import queue from logging import Logger from threading import Lock from typing import Dict, List, Optional, Text from requests import Session -from six.moves import queue -from reportportal_client.core.rp_requests import ( - RPRequestLog as RPRequestLog -) +from reportportal_client.core.rp_requests import RPRequestLog from reportportal_client.core.worker import APIWorker as APIWorker logger: Logger -MAX_LOG_BATCH_SIZE: int -MAX_LOG_BATCH_PAYLOAD_SIZE: int - class LogManager: _lock: Lock = ... diff --git a/reportportal_client/services/statistics.py b/reportportal_client/services/statistics.py deleted file mode 100644 index 94e003eb..00000000 --- a/reportportal_client/services/statistics.py +++ /dev/null @@ -1,86 +0,0 @@ -"""This module sends statistics events to a statistics service.""" - -# Copyright (c) 2023 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 logging -from platform import python_version - -import requests -from pkg_resources import get_distribution - -from .client_id import get_client_id -from .constants import CLIENT_INFO, ENDPOINT - -logger = logging.getLogger(__name__) - -ID, KEY = CLIENT_INFO.split(':') - - -def _get_client_info(): - """Get name of the client and its version. - - :return: ('reportportal-client', '5.0.4') - """ - client = get_distribution('reportportal-client') - return client.project_name, client.version - - -def _get_platform_info(): - """Get current platform basic info, e.g.: 'Python 3.6.1'. - - :return: str represents the current platform, e.g.: 'Python 3.6.1' - """ - return 'Python ' + python_version() - - -def send_event(event_name, agent_name, agent_version): - """Send an event to statistics service. - - Use client and agent versions with their names. - - :param event_name: Event name to be used - :param agent_name: Name of the agent that uses the client - :param agent_version: Version of the agent - """ - client_name, client_version = _get_client_info() - request_params = { - 'client_name': client_name, - 'client_version': client_version, - 'interpreter': _get_platform_info(), - 'agent_name': agent_name, - 'agent_version': agent_version, - } - - if agent_name: - request_params['agent_name'] = agent_name - if agent_version: - request_params['agent_version'] = agent_version - - payload = { - 'client_id': get_client_id(), - 'events': [{ - 'name': event_name, - 'params': request_params - }] - } - headers = {'User-Agent': 'python-requests'} - query_params = { - 'measurement_id': ID, - 'api_secret': KEY - } - try: - return requests.post(url=ENDPOINT, json=payload, headers=headers, - params=query_params) - except requests.exceptions.RequestException as err: - logger.debug('Failed to send data to Statistics service: %s', str(err)) diff --git a/reportportal_client/static/__init__.py b/reportportal_client/static/__init__.py deleted file mode 100644 index a4f66a81..00000000 --- a/reportportal_client/static/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""This package provides common static objects and variables. - -Copyright (c) 2018 http://reportportal.io . - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://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. -""" diff --git a/reportportal_client/static/errors.py b/reportportal_client/static/errors.py deleted file mode 100644 index dbbdee9f..00000000 --- a/reportportal_client/static/errors.py +++ /dev/null @@ -1,28 +0,0 @@ -"""This modules includes exceptions used by the client. - -Copyright (c) 2018 http://reportportal.io . - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://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. -""" - - -class RPExceptionBase(Exception): - """General exception for the package.""" - - -class RPError(RPExceptionBase): - """General error exception for package.""" - - -class ResponseError(RPError): - """Error in response returned by RP.""" diff --git a/reportportal_client/steps/__init__.py b/reportportal_client/steps/__init__.py index 6b36e22a..b482395e 100644 --- a/reportportal_client/steps/__init__.py +++ b/reportportal_client/steps/__init__.py @@ -10,6 +10,7 @@ # 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 + """ReportPortal Nested Step handling module. The module for handling and reporting ReportPortal Nested Steps inside python @@ -43,7 +44,8 @@ def test_my_nested_step(): """ from functools import wraps -from reportportal_client._local import current +# noinspection PyProtectedMember +from reportportal_client._internal.local import current from reportportal_client.helpers import get_function_params, timestamp NESTED_STEP_ITEMS = ('step', 'scenario', 'before_class', 'before_groups', @@ -136,8 +138,7 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): """Exit the runtime context related to this object.""" - # Cannot call _local.current() early since it will be initialized - # before client put something in there + # Cannot call local.current() early since it will be initialized before client put something in there rp_client = self.client or current() if not rp_client: return @@ -155,6 +156,7 @@ def __call__(self, func): :param func: function reference """ + @wraps(func) def wrapper(*args, **kwargs): __tracebackhide__ = True diff --git a/reportportal_client/steps/__init__.pyi b/reportportal_client/steps/__init__.pyi index 748d8c79..9c63b3a3 100644 --- a/reportportal_client/steps/__init__.pyi +++ b/reportportal_client/steps/__init__.pyi @@ -11,41 +11,42 @@ # See the License for the specific language governing permissions and # limitations under the License -from typing import Text, Optional, Dict, Any, Callable, Union +from typing import Optional, Dict, Any, Callable, Union -from reportportal_client.client import RPClient +from reportportal_client.client import RP +from reportportal_client.aio import Task class StepReporter: - client: RPClient = ... + client: RP = ... - def __init__(self, rp_client: RPClient) -> None: ... + def __init__(self, rp_client: RP) -> None: ... def start_nested_step(self, - name: Text, - start_time: Text, + name: str, + start_time: str, parameters: Dict = ..., - **kwargs: Any) -> Text: ... + **kwargs: Any) -> Union[Optional[str], Task[str]]: ... def finish_nested_step(self, - item_id: Text, - end_time: Text, - status: Text, + item_id: str, + end_time: str, + status: str, **kwargs: Any) -> None: ... class Step: - name: Text = ... + name: str = ... params: Dict = ... - status: Text = ... - client: Optional[RPClient] = ... - __item_id: Optional[Text] = ... + status: str = ... + client: Optional[RP] = ... + __item_id: Union[Optional[str], Task[str]] = ... def __init__(self, - name: Text, + name: str, params: Dict, - status: Text, - rp_client: Optional[RPClient]) -> None: ... + status: str, + rp_client: Optional[RP]) -> None: ... def __enter__(self) -> None: ... @@ -54,7 +55,7 @@ class Step: def __call__(self, func: Callable) -> Callable: ... -def step(name_source: Union[Callable, Text], +def step(name_source: Union[Callable, str], params: Dict = ..., - status: Text = ..., - rp_client: RPClient = ...) -> None: ... + status: str = ..., + rp_client: RP = ...) -> None: ... diff --git a/requirements-dev.txt b/requirements-dev.txt index 9955decc..e01a8206 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,3 @@ pytest pytest-cov +pytest-asyncio diff --git a/requirements.txt b/requirements.txt index 549e8374..6c8a44c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ aenum -requests>=2.27.1 -six>=1.16.0 +requests>=2.28.0 +aiohttp>=3.8.3 +certifi>=2023.7.22 diff --git a/setup.py b/setup.py index 890d811e..272b6ac5 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages -__version__ = '5.4.1' +__version__ = '5.5.0' TYPE_STUBS = ['*.pyi'] @@ -21,10 +21,12 @@ def read_file(fname): setup( name='reportportal-client', - packages=find_packages(exclude=('tests', 'tests.*')), + packages=find_packages(exclude=['tests', 'test_res']), package_data={ - 'reportportal_client': TYPE_STUBS, - 'reportportal_client.steps': TYPE_STUBS + 'reportportal_client.steps': TYPE_STUBS, + 'reportportal_client.core': TYPE_STUBS, + 'reportportal_client.logs': TYPE_STUBS, + 'reportportal_client.services': TYPE_STUBS, }, version=__version__, description='Python client for ReportPortal v5.', @@ -38,12 +40,11 @@ def read_file(fname): license='Apache 2.0.', keywords=['testing', 'reporting', 'reportportal', 'client'], classifiers=[ - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', ], install_requires=read_file('requirements.txt').splitlines(), ) diff --git a/test_res/root.pem b/test_res/root.pem new file mode 100644 index 00000000..b85c8037 --- /dev/null +++ b/test_res/root.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE----- diff --git a/tests/__init__.py b/tests/__init__.py index 72af15ef..758c5345 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,4 +1,15 @@ -"""This package contains unit tests for the project.""" +# Copyright (c) 2023 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 -from six import add_move, MovedModule -add_move(MovedModule('mock', 'mock', 'unittest.mock')) +"""Package which contains all ReportPortal client tests.""" +# This file is here because otherwise code coverage is not working. But pytest does not require package files. diff --git a/tests/_internal/aio/test_http.py b/tests/_internal/aio/test_http.py new file mode 100644 index 00000000..eda0fab9 --- /dev/null +++ b/tests/_internal/aio/test_http.py @@ -0,0 +1,159 @@ +# Copyright (c) 2023 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 +# +# 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 http.server +import socketserver +import threading +import time +from unittest import mock + +import aiohttp +# noinspection PyPackageRequirements +import pytest + +# noinspection PyProtectedMember +from reportportal_client._internal.aio.http import RetryingClientSession + +HTTP_TIMEOUT_TIME = 1.2 + + +class OkHttpHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write('{}\n\n'.encode("utf-8")) + self.wfile.flush() + + +class TimeoutHttpHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + time.sleep(HTTP_TIMEOUT_TIME) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write('{}\n\n'.encode("utf-8")) + self.wfile.flush() + + +class ResetHttpHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.wfile.close() + + +class ErrorHttpHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(500, 'Internal Server Error') + self.end_headers() + self.wfile.write('Internal Server Error\n\n'.encode("utf-8")) + self.wfile.flush() + + +class ThrottlingHttpHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(429, 'Throttling') + self.end_headers() + self.wfile.write('Throttling\n\n'.encode("utf-8")) + self.wfile.flush() + + +SERVER_PORT = 8000 +SERVER_ADDRESS = ('', SERVER_PORT) +SERVER_CLASS = socketserver.TCPServer +SERVER_HANDLER_CLASS = http.server.BaseHTTPRequestHandler + + +def get_http_server(server_class=SERVER_CLASS, server_address=SERVER_ADDRESS, + server_handler=SERVER_HANDLER_CLASS): + httpd = server_class(server_address, server_handler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + return httpd + + +async def execute_http_request(port, retry_number, server_class, timeout_seconds, protocol='http'): + timeout = aiohttp.ClientTimeout(connect=timeout_seconds, sock_read=timeout_seconds) + connector = aiohttp.TCPConnector(force_close=True) + session = RetryingClientSession(f'{protocol}://localhost:{port}', timeout=timeout, + max_retry_number=retry_number, base_retry_delay=0.01, connector=connector) + # noinspection PyProtectedMember + parent_request = session._client.get + async_mock = mock.Mock() + async_mock.side_effect = parent_request + exception = None + result = None + with get_http_server(server_handler=server_class, server_address=('', port)): + with mock.patch('reportportal_client._internal.aio.http.ClientSession.get', async_mock): + async with session: + start_time = time.time() + try: + result = await session.get('/') + except Exception as exc: + exception = exc + total_time = time.time() - start_time + return async_mock, exception, result, total_time + + +@pytest.mark.parametrize( + 'server_class, port, expected_delay, timeout_seconds, is_exception', + [ + (TimeoutHttpHandler, 8001, 14.4, 1.0, True), + (ResetHttpHandler, 8002, 8.4, 1.0, True), + (ErrorHttpHandler, 8003, 1.1, 1.0, False), + (ThrottlingHttpHandler, 8004, 27.93, 1.0, False), + ] +) +@pytest.mark.asyncio +async def test_retry_on_request_error(server_class, port, expected_delay, timeout_seconds, is_exception): + retry_number = 5 + async_mock, exception, result, total_time = await execute_http_request(port, retry_number, server_class, + timeout_seconds) + if is_exception: + assert exception is not None + else: + assert exception is None + assert not result.ok + assert async_mock.call_count == 1 + retry_number + assert total_time > expected_delay + assert total_time < expected_delay * 1.5 + + +@pytest.mark.asyncio +async def test_no_retry_on_ok_request(): + retry_number = 5 + port = 8000 + async_mock, exception, result, total_time = await execute_http_request(port, retry_number, OkHttpHandler, + 1) + assert exception is None + assert result.ok + assert async_mock.call_count == 1 + assert total_time < 1 + + +@pytest.mark.asyncio +async def test_no_retry_on_not_retryable_error(): + retry_number = 5 + port = 8005 + async_mock, exception, result, total_time = await execute_http_request(port, retry_number, OkHttpHandler, + 1, protocol='https') + assert exception is not None + assert result is None + assert async_mock.call_count == 1 + assert total_time < 1 diff --git a/tests/_internal/logs/test_log_batcher.py b/tests/_internal/logs/test_log_batcher.py new file mode 100644 index 00000000..625554d2 --- /dev/null +++ b/tests/_internal/logs/test_log_batcher.py @@ -0,0 +1,104 @@ +# Copyright (c) 2022 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 os + +from reportportal_client import helpers +# noinspection PyProtectedMember +from reportportal_client._internal.logs.batcher import LogBatcher +from reportportal_client.core.rp_file import RPFile +from reportportal_client.core.rp_requests import RPRequestLog +from reportportal_client.logs import MAX_LOG_BATCH_PAYLOAD_SIZE + +TEST_LAUNCH_ID = 'test_launch_uuid' +TEST_ITEM_ID = 'test_item_id' +PROJECT_NAME = 'test_project' +TEST_MASSAGE = 'test_message' +TEST_LEVEL = 'DEBUG' +TEST_BATCH_SIZE = 5 +TEST_ATTACHMENT_NAME = 'test_file.bin' +TEST_ATTACHMENT_TYPE = 'application/zip' + + +def test_log_batch_send_by_length(): + log_batcher = LogBatcher(entry_num=TEST_BATCH_SIZE) + + for i in range(TEST_BATCH_SIZE): + result = log_batcher.append( + RPRequestLog(launch_uuid=TEST_LAUNCH_ID, time=helpers.timestamp(), message=TEST_MASSAGE, + level=TEST_LEVEL, item_uuid=TEST_ITEM_ID)) + if i < TEST_BATCH_SIZE - 1: + assert result is None + assert len(log_batcher._batch) == i + 1 + assert log_batcher._payload_size > 0 + else: + assert len(result) == TEST_BATCH_SIZE + assert len(log_batcher._batch) == 0 + assert log_batcher._payload_size == 0 + + +def test_log_batch_send_by_flush(): + log_batcher = LogBatcher(entry_num=TEST_BATCH_SIZE) + + for _ in range(TEST_BATCH_SIZE - 1): + log_batcher.append( + RPRequestLog(launch_uuid=TEST_LAUNCH_ID, time=helpers.timestamp(), message=TEST_MASSAGE, + level=TEST_LEVEL, item_uuid=TEST_ITEM_ID)) + result = log_batcher.flush() + + assert len(result) == TEST_BATCH_SIZE - 1 + assert len(log_batcher._batch) == 0 + assert log_batcher._payload_size == 0 + + +def test_log_batch_send_by_size(): + log_batcher = LogBatcher(entry_num=TEST_BATCH_SIZE) + + random_byte_array = bytearray(os.urandom(MAX_LOG_BATCH_PAYLOAD_SIZE)) + binary_result = log_batcher.append( + RPRequestLog(launch_uuid=TEST_LAUNCH_ID, time=helpers.timestamp(), message=TEST_MASSAGE, + level=TEST_LEVEL, item_uuid=TEST_ITEM_ID, + file=RPFile(name=TEST_ATTACHMENT_NAME, content=random_byte_array, + content_type=TEST_ATTACHMENT_TYPE))) + message_result = log_batcher.append( + RPRequestLog(launch_uuid=TEST_LAUNCH_ID, time=helpers.timestamp(), message=TEST_MASSAGE, + level=TEST_LEVEL, item_uuid=TEST_ITEM_ID)) + + assert binary_result is None + assert message_result is not None + assert len(message_result) == 1 + assert message_result[0].file is not None + assert log_batcher._payload_size > 0 + assert len(log_batcher._batch) == 1 + + +def test_log_batch_triggers_previous_request_to_send(): + log_batcher = LogBatcher(entry_num=TEST_BATCH_SIZE) + + random_byte_array = bytearray(os.urandom(MAX_LOG_BATCH_PAYLOAD_SIZE)) + + message_result = log_batcher.append( + RPRequestLog(launch_uuid=TEST_LAUNCH_ID, time=helpers.timestamp(), message=TEST_MASSAGE, + level=TEST_LEVEL, item_uuid=TEST_ITEM_ID)) + + binary_result = log_batcher.append( + RPRequestLog(launch_uuid=TEST_LAUNCH_ID, time=helpers.timestamp(), message=TEST_MASSAGE, + level=TEST_LEVEL, item_uuid=TEST_ITEM_ID, + file=RPFile(name=TEST_ATTACHMENT_NAME, content=random_byte_array, + content_type=TEST_ATTACHMENT_TYPE))) + + assert binary_result is not None + assert message_result is None + assert len(binary_result) == 1 + assert binary_result[0].file is None + assert MAX_LOG_BATCH_PAYLOAD_SIZE < log_batcher._payload_size < MAX_LOG_BATCH_PAYLOAD_SIZE * 1.1 diff --git a/tests/test_client_id.py b/tests/_internal/services/test_client_id.py similarity index 83% rename from tests/test_client_id.py rename to tests/_internal/services/test_client_id.py index 291437ed..915d381c 100644 --- a/tests/test_client_id.py +++ b/tests/_internal/services/test_client_id.py @@ -10,13 +10,23 @@ # 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 +# +# 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 os import re from uuid import UUID, uuid4 -from reportportal_client.services.client_id import get_client_id -from reportportal_client.services.constants import RP_PROPERTIES_FILE_PATH +# noinspection PyProtectedMember +from reportportal_client._internal.services.client_id import get_client_id +# noinspection PyProtectedMember +from reportportal_client._internal.services.constants import RP_PROPERTIES_FILE_PATH def test_get_client_id_should_return_the_id_for_two_calls(): diff --git a/tests/_internal/services/test_statistics.py b/tests/_internal/services/test_statistics.py new file mode 100644 index 00000000..abda1f59 --- /dev/null +++ b/tests/_internal/services/test_statistics.py @@ -0,0 +1,137 @@ +"""This module contains unit tests for statistics used in the project.""" + +# Copyright (c) 2023 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 +# +# 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 re +import sys +from unittest import mock + +# noinspection PyPackageRequirements +import pytest +from requests.exceptions import RequestException + +# noinspection PyProtectedMember +from reportportal_client._internal.services.constants import ENDPOINT, CLIENT_INFO +# noinspection PyProtectedMember +from reportportal_client._internal.services.statistics import send_event, async_send_event + +VERSION_VAR = '__version__' +EVENT_NAME = 'start_launch' +with open('setup.py') as f: + EXPECTED_CL_VERSION = next( + map(lambda l: re.sub(f'^\\s*{VERSION_VAR}\\s*=\\s*[\'"]([^\'"]+)[\'"]', '\\g<1>', l), + filter(lambda x: VERSION_VAR in x, f.read().splitlines()))) +EXPECTED_CL_NAME = 'reportportal-client' +AGENT_VERSION, AGENT_NAME = '5.0.5', 'pytest-reportportal' +EXPECTED_HEADERS = {'User-Agent': 'python-requests'} +EXPECTED_AIO_HEADERS = {'User-Agent': 'python-aiohttp'} +EXPECTED_DATA = { + 'client_id': '555', + 'events': [{ + 'name': EVENT_NAME, + 'params': { + 'client_name': EXPECTED_CL_NAME, + 'client_version': EXPECTED_CL_VERSION, + 'interpreter': 'Python 3.6.6', + 'agent_name': AGENT_NAME, + 'agent_version': AGENT_VERSION, + } + }] +} +MID, KEY = CLIENT_INFO.split(':') +EXPECTED_PARAMS = {'measurement_id': MID, 'api_secret': KEY} + + +@mock.patch('reportportal_client._internal.services.statistics.get_client_id', + mock.Mock(return_value='555')) +@mock.patch('reportportal_client._internal.services.statistics.requests.post') +@mock.patch('reportportal_client._internal.services.statistics.python_version', + mock.Mock(return_value='3.6.6')) +def test_send_event(mocked_requests): + """Test functionality of the send_event() function. + + :param mocked_requests: Mocked requests module + """ + + send_event(EVENT_NAME, AGENT_NAME, AGENT_VERSION) + mocked_requests.assert_called_with( + url=ENDPOINT, json=EXPECTED_DATA, headers=EXPECTED_HEADERS, + params=EXPECTED_PARAMS) + + +@mock.patch('reportportal_client._internal.services.statistics.get_client_id', + mock.Mock(return_value='555')) +@mock.patch('reportportal_client._internal.services.statistics.requests.post', + mock.Mock(side_effect=RequestException)) +def test_send_event_raises(): + """Test that the send_event() does not raise exceptions.""" + send_event(EVENT_NAME, 'pytest-reportportal', '5.0.5') + + +@mock.patch('reportportal_client._internal.services.statistics.requests.post') +@mock.patch('reportportal_client._internal.services.statistics.python_version', + mock.Mock(return_value='3.6.6')) +def test_same_client_id(mocked_requests): + """Test functionality of the send_event() function. + + :param mocked_requests: Mocked requests module + """ + agent_version, agent_name = '5.0.5', 'pytest-reportportal' + + send_event(EVENT_NAME, agent_name, agent_version) + send_event(EVENT_NAME, agent_name, agent_version) + args_list = mocked_requests.call_args_list + + result1 = args_list[0][1]['json']['client_id'] + result2 = args_list[1][1]['json']['client_id'] + + assert result1 == result2 + + +MOCKED_AIOHTTP = None +if not sys.version_info < (3, 8): + MOCKED_AIOHTTP = mock.AsyncMock() + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason="the test requires AsyncMock which was introduced in Python 3.8") +@mock.patch('reportportal_client._internal.services.statistics.get_client_id', + mock.Mock(return_value='555')) +@mock.patch('reportportal_client._internal.services.statistics.aiohttp.ClientSession.post', MOCKED_AIOHTTP) +@mock.patch('reportportal_client._internal.services.statistics.python_version', + mock.Mock(return_value='3.6.6')) +@pytest.mark.asyncio +async def test_async_send_event(): + """Test functionality of the send_event() function.""" + await async_send_event(EVENT_NAME, AGENT_NAME, AGENT_VERSION) + assert len(MOCKED_AIOHTTP.call_args_list) == 1 + args, kwargs = MOCKED_AIOHTTP.call_args_list[0] + assert len(args) == 0 + expected_kwargs_keys = ['headers', 'url', 'json', 'params', 'ssl'] + for key in expected_kwargs_keys: + assert key in kwargs + assert len(expected_kwargs_keys) == len(kwargs) + assert kwargs['headers'] == EXPECTED_AIO_HEADERS + assert kwargs['url'] == ENDPOINT + assert kwargs['json'] == EXPECTED_DATA + assert kwargs['params'] == EXPECTED_PARAMS + assert kwargs['ssl'] is not None diff --git a/tests/aio/test_aio_client.py b/tests/aio/test_aio_client.py new file mode 100644 index 00000000..bcc264ac --- /dev/null +++ b/tests/aio/test_aio_client.py @@ -0,0 +1,774 @@ +# Copyright (c) 2023 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 os +import pickle +import sys +from io import StringIO +from json import JSONDecodeError +from ssl import SSLContext +from typing import List, Optional +from unittest import mock + +import aiohttp +# noinspection PyPackageRequirements +import pytest +from aiohttp import ServerConnectionError + +from reportportal_client import OutputType +# noinspection PyProtectedMember +from reportportal_client._internal.aio.http import RetryingClientSession, DEFAULT_RETRY_NUMBER +# noinspection PyProtectedMember +from reportportal_client._internal.static.defines import NOT_SET +from reportportal_client.aio.client import Client +from reportportal_client.core.rp_issues import Issue +from reportportal_client.core.rp_requests import AsyncRPRequestLog +from reportportal_client.helpers import timestamp + +ENDPOINT = 'http://localhost:8080' +PROJECT = 'default_personal' +API_KEY = 'test_key' +POST_RESPONSE_ID = 'test_launch_uuid' +RETURN_POST_JSON = {'id': POST_RESPONSE_ID} +RESPONSE_MESSAGE = 'Item finished successfully' +RETURN_PUT_JSON = {'message': RESPONSE_MESSAGE} +GET_RESPONSE_ID = 'test_item_id' +RETURN_GET_JSON = {'id': GET_RESPONSE_ID} + + +def test_client_pickling(): + client = Client(ENDPOINT, PROJECT, api_key=API_KEY) + pickled_client = pickle.dumps(client) + unpickled_client = pickle.loads(pickled_client) + assert unpickled_client is not None + + +@pytest.mark.parametrize( + 'retry_num, expected_class, expected_param', + [ + (1, RetryingClientSession, 1), + (0, aiohttp.ClientSession, NOT_SET), + (-1, aiohttp.ClientSession, NOT_SET), + (None, aiohttp.ClientSession, NOT_SET), + (NOT_SET, RetryingClientSession, DEFAULT_RETRY_NUMBER) + ] +) +@pytest.mark.asyncio +async def test_retries_param(retry_num, expected_class, expected_param): + client = Client(ENDPOINT, PROJECT, api_key=API_KEY, retries=retry_num) + session = await client.session() + assert isinstance(session, expected_class) + if expected_param is not NOT_SET: + assert getattr(session, '_RetryingClientSession__retry_number') == expected_param + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason="For some reasons this does not work on Python 3.7 on Ubuntu, " + "but works on my Mac. Unfortunately GHA use Python 3.7 on Ubuntu.") +@pytest.mark.parametrize( + 'timeout_param, expected_connect_param, expected_sock_read_param', + [ + ((15, 17), 15, 17), + (21, 21, 21), + (None, None, None) + ] +) +@mock.patch('reportportal_client.aio.client.RetryingClientSession') +@pytest.mark.asyncio +async def test_timeout_param(mocked_session, timeout_param, expected_connect_param, expected_sock_read_param): + client = Client(ENDPOINT, PROJECT, api_key=API_KEY, http_timeout=timeout_param) + session = await client.session() + assert session is not None + assert len(mocked_session.call_args_list) == 1 + args, kwargs = mocked_session.call_args_list[0] + assert len(args) == 1 and args[0] == ENDPOINT + expected_kwargs_keys = ['headers', 'connector'] + if timeout_param: + expected_kwargs_keys.append('timeout') + for key in expected_kwargs_keys: + assert key in kwargs + assert len(expected_kwargs_keys) == len(kwargs) + assert kwargs['headers'] == {'Authorization': f'Bearer {API_KEY}'} + assert kwargs['connector'] is not None + if timeout_param: + assert kwargs['timeout'] is not None + assert isinstance(kwargs['timeout'], aiohttp.ClientTimeout) + assert kwargs['timeout'].connect == expected_connect_param + assert kwargs['timeout'].sock_read == expected_sock_read_param + + +def test_clone(): + args = ['http://endpoint', 'project'] + kwargs = {'api_key': 'api_key', 'is_skipped_an_issue': False, 'verify_ssl': False, 'retries': 5, + 'max_pool_size': 30, 'http_timeout': (30, 30), 'keepalive_timeout': 25, 'mode': 'DEBUG', + 'launch_uuid_print': True, 'print_output': OutputType.STDERR} + client = Client(*args, **kwargs) + cloned = client.clone() + assert cloned is not None and client is not cloned + assert cloned.endpoint == args[0] and cloned.project == args[1] + assert ( + cloned.api_key == kwargs['api_key'] + and cloned.is_skipped_an_issue == kwargs['is_skipped_an_issue'] + and cloned.verify_ssl == kwargs['verify_ssl'] + and cloned.retries == kwargs['retries'] + and cloned.max_pool_size == kwargs['max_pool_size'] + and cloned.http_timeout == kwargs['http_timeout'] + and cloned.keepalive_timeout == kwargs['keepalive_timeout'] + and cloned.mode == kwargs['mode'] + and cloned.launch_uuid_print == kwargs['launch_uuid_print'] + and cloned.print_output == kwargs['print_output'] + ) + + +LAUNCH_ID = 333 +EXPECTED_DEFAULT_URL = f'http://endpoint/ui/#project/launches/all/{LAUNCH_ID}' +EXPECTED_DEBUG_URL = f'http://endpoint/ui/#project/userdebug/all/{LAUNCH_ID}' + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason="the test requires AsyncMock which was introduced in Python 3.8") +@pytest.mark.parametrize( + 'launch_mode, project_name, expected_url', + [ + ('DEFAULT', "project", EXPECTED_DEFAULT_URL), + ('DEBUG', "project", EXPECTED_DEBUG_URL), + ('DEFAULT', "PROJECT", EXPECTED_DEFAULT_URL), + ('debug', "PROJECT", EXPECTED_DEBUG_URL) + ] +) +@pytest.mark.asyncio +async def test_launch_url_get(aio_client, launch_mode: str, project_name: str, expected_url: str): + aio_client.project = project_name + response = mock.AsyncMock() + response.is_success = True + response.json.side_effect = lambda: {'mode': launch_mode, 'id': LAUNCH_ID} + + async def get_call(*args, **kwargs): + return response + + (await aio_client.session()).get.side_effect = get_call + + assert await (aio_client.get_launch_ui_url('test_launch_uuid')) == expected_url + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason="For some reasons this does not work on Python 3.7 on Ubuntu, " + "but works on my Mac. Unfortunately GHA use Python 3.7 on Ubuntu.") +@pytest.mark.parametrize('default', [True, False]) +@mock.patch('reportportal_client.aio.client.aiohttp.TCPConnector') +@pytest.mark.asyncio +async def test_verify_ssl_default(connector_mock: mock.Mock, default: bool): + if default: + client = Client('http://endpoint', 'project', api_key='api_key') + else: + client = Client('http://endpoint', 'project', api_key='api_key', verify_ssl=True) + await client.session() + connector_mock.assert_called_once() + _, kwargs = connector_mock.call_args_list[0] + ssl_context: SSLContext = kwargs.get('ssl', None) + assert ssl_context is not None and isinstance(ssl_context, SSLContext) + assert len(ssl_context.get_ca_certs()) > 0 + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason="For some reasons this does not work on Python 3.7 on Ubuntu, " + "but works on my Mac. Unfortunately GHA use Python 3.7 on Ubuntu.") +@pytest.mark.parametrize('param_value', [False, None]) +@mock.patch('reportportal_client.aio.client.aiohttp.TCPConnector') +@pytest.mark.asyncio +async def test_verify_ssl_off(connector_mock: mock.Mock, param_value): + client = Client('http://endpoint', 'project', api_key='api_key', verify_ssl=param_value) + await client.session() + connector_mock.assert_called_once() + _, kwargs = connector_mock.call_args_list[0] + ssl_context: SSLContext = kwargs.get('ssl', None) + assert ssl_context is not None and isinstance(ssl_context, bool) and not ssl_context + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason="For some reasons this does not work on Python 3.7 on Ubuntu, " + "but works on my Mac. Unfortunately GHA use Python 3.7 on Ubuntu.") +@mock.patch('reportportal_client.aio.client.aiohttp.TCPConnector') +@pytest.mark.asyncio +async def test_verify_ssl_str(connector_mock: mock.Mock): + client = Client('http://endpoint', 'project', api_key='api_key', + verify_ssl=os.path.join(os.getcwd(), 'test_res/root.pem')) + await client.session() + connector_mock.assert_called_once() + _, kwargs = connector_mock.call_args_list[0] + ssl_context: SSLContext = kwargs.get('ssl', None) + assert ssl_context is not None and isinstance(ssl_context, SSLContext) + assert len(ssl_context.get_ca_certs()) == 1 + certificate = ssl_context.get_ca_certs()[0] + assert certificate['subject'][1] == (('organizationName', 'Internet Security Research Group'),) + assert certificate['notAfter'] == 'Jun 4 11:04:38 2035 GMT' + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason="For some reasons this does not work on Python 3.7 on Ubuntu, " + "but works on my Mac. Unfortunately GHA use Python 3.7 on Ubuntu.") +@mock.patch('reportportal_client.aio.client.aiohttp.TCPConnector') +@pytest.mark.asyncio +async def test_keepalive_timeout(connector_mock: mock.Mock): + keepalive_timeout = 33 + client = Client('http://endpoint', 'project', api_key='api_key', + keepalive_timeout=keepalive_timeout) + await client.session() + connector_mock.assert_called_once() + _, kwargs = connector_mock.call_args_list[0] + timeout = kwargs.get('keepalive_timeout', None) + assert timeout is not None and timeout == keepalive_timeout + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason="the test requires AsyncMock which was introduced in Python 3.8") +@pytest.mark.asyncio +async def test_close(aio_client: Client): + # noinspection PyTypeChecker + session: mock.AsyncMock = await aio_client.session() + await (aio_client.close()) + assert aio_client._session is None + session.close.assert_awaited_once() + + +def mock_basic_post_response(session): + return_object = mock.AsyncMock() + return_object.json.return_value = RETURN_POST_JSON + session.post.return_value = return_object + + +def verify_attributes(expected_attributes: Optional[dict], actual_attributes: Optional[List[dict]]): + if expected_attributes is None: + assert actual_attributes is None + return + else: + assert actual_attributes is not None + hidden = expected_attributes.pop('system', None) + assert len(actual_attributes) == len(expected_attributes) + for attribute in actual_attributes: + if 'key' in attribute: + assert attribute.get('value') == expected_attributes.get(attribute.get('key')) + assert attribute.get('system') == hidden + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='the test requires AsyncMock which was introduced in Python 3.8') +@pytest.mark.asyncio +async def test_start_launch(aio_client: Client): + # noinspection PyTypeChecker + session: mock.AsyncMock = await aio_client.session() + mock_basic_post_response(session) + + launch_name = 'Test Launch' + start_time = str(1696921416000) + description = 'Test Launch description' + attributes = {'attribute_key': 'attribute_value'} + rerun_of = 'test_prent_launch_uuid' + result = await aio_client.start_launch(launch_name, start_time, description=description, + attributes=attributes, rerun=True, rerun_of=rerun_of) + + assert result == POST_RESPONSE_ID + session.post.assert_called_once() + call_args = session.post.call_args_list[0] + assert '/api/v2/project/launch' == call_args[0][0] + kwargs = call_args[1] + assert kwargs.get('data') is None + actual_json = kwargs.get('json') + assert actual_json is not None + assert actual_json.get('rerun') is True + assert actual_json.get('rerunOf') == rerun_of + assert actual_json.get('description') == description + assert actual_json.get('startTime') == start_time + actual_attributes = actual_json.get('attributes') + verify_attributes(attributes, actual_attributes) + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='the test requires AsyncMock which was introduced in Python 3.8') +@mock.patch('reportportal_client.aio.client.async_send_event') +@pytest.mark.asyncio +async def test_start_launch_statistics_send(async_send_event): + # noinspection PyTypeChecker + session = mock.AsyncMock() + client = Client('http://endpoint', 'project', api_key='api_key') + client._skip_analytics = False + client._session = session + mock_basic_post_response(session) + + launch_name = 'Test Launch' + start_time = str(1696921416000) + agent_name = 'pytest-reportportal' + agent_version = '5.0.4' + attributes = {'agent': f'{agent_name}|{agent_version}'} + await client.start_launch(launch_name, start_time, attributes=attributes) + async_send_event.assert_called_once() + call_args = async_send_event.call_args_list[0] + args = call_args[0] + kwargs = call_args[1] + assert len(args) == 3 + assert args[0] == 'start_launch' + assert args[1] == agent_name + assert args[2] == agent_version + assert len(kwargs.items()) == 0 + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='the test requires AsyncMock which was introduced in Python 3.8') +@mock.patch('reportportal_client.aio.client.getenv') +@mock.patch('reportportal_client.aio.client.async_send_event') +@pytest.mark.asyncio +async def test_start_launch_no_statistics_send(async_send_event, getenv): + getenv.return_value = '1' + # noinspection PyTypeChecker + session = mock.AsyncMock() + client = Client('http://endpoint', 'project', api_key='api_key') + client._session = session + mock_basic_post_response(session) + + launch_name = 'Test Launch' + start_time = str(1696921416000) + agent_name = 'pytest-reportportal' + agent_version = '5.0.4' + attributes = {'agent': f'{agent_name}|{agent_version}'} + await client.start_launch(launch_name, start_time, attributes=attributes) + async_send_event.assert_not_called() + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason="the test requires AsyncMock which was introduced in Python 3.8") +@pytest.mark.asyncio +async def test_launch_uuid_print(): + str_io = StringIO() + output_mock = mock.Mock() + output_mock.get_output.return_value = str_io + client = Client(endpoint='http://endpoint', project='project', + api_key='test', launch_uuid_print=True, print_output=output_mock) + client._session = mock.AsyncMock() + client._skip_analytics = True + await client.start_launch('Test Launch', timestamp()) + assert 'ReportPortal Launch UUID: ' in str_io.getvalue() + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason="the test requires AsyncMock which was introduced in Python 3.8") +@pytest.mark.asyncio +async def test_no_launch_uuid_print(): + str_io = StringIO() + output_mock = mock.Mock() + output_mock.get_output.return_value = str_io + client = Client(endpoint='http://endpoint', project='project', + api_key='test', launch_uuid_print=False, print_output=output_mock) + client._session = mock.AsyncMock() + client._skip_analytics = True + await client.start_launch('Test Launch', timestamp()) + assert 'ReportPortal Launch UUID: ' not in str_io.getvalue() + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason="the test requires AsyncMock which was introduced in Python 3.8") +@pytest.mark.asyncio +@mock.patch('reportportal_client.client.sys.stdout', new_callable=StringIO) +async def test_launch_uuid_print_default_io(mock_stdout): + client = Client(endpoint='http://endpoint', project='project', + api_key='test', launch_uuid_print=True) + client._session = mock.AsyncMock() + client._skip_analytics = True + await client.start_launch('Test Launch', timestamp()) + assert 'ReportPortal Launch UUID: ' in mock_stdout.getvalue() + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason="the test requires AsyncMock which was introduced in Python 3.8") +@pytest.mark.asyncio +@mock.patch('reportportal_client.client.sys.stdout', new_callable=StringIO) +async def test_launch_uuid_print_default_print(mock_stdout): + client = Client(endpoint='http://endpoint', project='project', + api_key='test') + client._session = mock.AsyncMock() + client._skip_analytics = True + await client.start_launch('Test Launch', timestamp()) + assert 'ReportPortal Launch UUID: ' not in mock_stdout.getvalue() + + +def connection_error(*args, **kwargs): + raise ServerConnectionError() + + +def json_error(*args, **kwargs): + raise JSONDecodeError('invalid Json', '502 Gateway Timeout', 0) + + +def response_error(*args, **kwargs): + result = mock.AsyncMock() + result.ok = False + result.json.side_effect = json_error + result.status_code = 502 + return result + + +def invalid_response(*args, **kwargs): + result = mock.AsyncMock() + result.ok = True + result.json.side_effect = json_error + result.status_code = 200 + return result + + +def request_error(*args, **kwargs): + raise ValueError() + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason="the test requires AsyncMock which was introduced in Python 3.8") +@pytest.mark.parametrize( + 'requests_method, client_method, client_params', + [ + ('post', 'start_launch', ['Test Launch', timestamp()]), + ('put', 'finish_launch', ['launch_uuid', timestamp()]), + ('post', 'start_test_item', ['launch_uuid', 'Test Item', timestamp(), 'STEP']), + ('put', 'finish_test_item', ['launch_uuid', 'test_item_id', timestamp()]), + ('put', 'update_test_item', ['test_item_id']), + ('get', 'get_item_id_by_uuid', ['test_item_uuid']), + ('get', 'get_launch_info', ['launch_uuid']), + ('get', 'get_launch_ui_id', ['launch_uuid']), + ('get', 'get_launch_ui_url', ['launch_uuid']), + ('get', 'get_project_settings', []), + ('post', 'log_batch', [[AsyncRPRequestLog('launch_uuid', timestamp(), item_uuid='test_item_uuid')]]) + ] +) +@pytest.mark.asyncio +async def test_connection_errors(aio_client, requests_method, client_method, + client_params): + getattr(await aio_client.session(), requests_method).side_effect = connection_error + try: + await getattr(aio_client, client_method)(*client_params) + except Exception as e: + # On this level we pass all errors through by design + assert type(e) == ServerConnectionError + + getattr(await aio_client.session(), requests_method).side_effect = response_error + result = await getattr(aio_client, client_method)(*client_params) + assert result is None + + getattr(await aio_client.session(), requests_method).side_effect = invalid_response + result = await getattr(aio_client, client_method)(*client_params) + assert result is None + + getattr(await aio_client.session(), requests_method).side_effect = request_error + result = await getattr(aio_client, client_method)(*client_params) + assert result is None + + +def verify_parameters(expected_parameters: dict, actual_parameters: List[dict]): + if expected_parameters is None: + assert actual_parameters is None + return + else: + assert actual_parameters is not None + assert len(actual_parameters) == len(expected_parameters) + for attribute in actual_parameters: + assert expected_parameters.get(attribute.get('key')) == attribute.get('value') + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='the test requires AsyncMock which was introduced in Python 3.8') +@pytest.mark.parametrize( + 'parent_id, expected_uri', + [ + ('test_parent_uuid', '/api/v2/project/item/test_parent_uuid'), + (None, '/api/v2/project/item'), + ] +) +@pytest.mark.asyncio +async def test_start_test_item(aio_client: Client, parent_id, expected_uri): + # noinspection PyTypeChecker + session: mock.AsyncMock = await aio_client.session() + mock_basic_post_response(session) + + launch_uuid = 'test_launch_uuid' + item_name = 'Test Item' + start_time = str(1696921416000) + item_type = 'STEP' + description = 'Test Launch description' + attributes = {'attribute_key': 'attribute_value'} + parameters = {'parameter_key': 'parameter_value'} + code_ref = 'io.reportportal.test' + test_case_id = 'io.reportportal.test[parameter_value]' + result = await aio_client.start_test_item(launch_uuid, item_name, start_time, item_type, + parent_item_id=parent_id, description=description, + attributes=attributes, parameters=parameters, + has_stats=False, code_ref=code_ref, test_case_id=test_case_id, + retry=True) + + assert result == POST_RESPONSE_ID + session.post.assert_called_once() + call_args = session.post.call_args_list[0] + assert expected_uri == call_args[0][0] + kwargs = call_args[1] + assert kwargs.get('data') is None + actual_json = kwargs.get('json') + assert actual_json is not None + assert actual_json.get('retry') is True + assert actual_json.get('testCaseId') == test_case_id + assert actual_json.get('codeRef') == code_ref + assert actual_json.get('hasStats') is False + assert actual_json.get('description') == description + assert actual_json.get('parentId') is None + assert actual_json.get('type') == item_type + assert actual_json.get('startTime') == start_time + assert actual_json.get('name') == item_name + assert actual_json.get('launchUuid') == launch_uuid + actual_attributes = actual_json.get('attributes') + verify_attributes(attributes, actual_attributes) + actual_parameters = actual_json.get('parameters') + verify_parameters(parameters, actual_parameters) + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='the test requires AsyncMock which was introduced in Python 3.8') +@pytest.mark.asyncio +async def test_start_test_item_default_values(aio_client: Client): + # noinspection PyTypeChecker + session: mock.AsyncMock = await aio_client.session() + mock_basic_post_response(session) + + expected_uri = '/api/v2/project/item' + launch_uuid = 'test_launch_uuid' + item_name = 'Test Item' + start_time = str(1696921416000) + item_type = 'STEP' + result = await aio_client.start_test_item(launch_uuid, item_name, start_time, item_type) + + assert result == POST_RESPONSE_ID + session.post.assert_called_once() + call_args = session.post.call_args_list[0] + assert expected_uri == call_args[0][0] + kwargs = call_args[1] + assert kwargs.get('data') is None + actual_json = kwargs.get('json') + assert actual_json is not None + assert actual_json.get('retry') is False + assert actual_json.get('testCaseId') is None + assert actual_json.get('codeRef') is None + assert actual_json.get('hasStats') is True + assert actual_json.get('description') is None + assert actual_json.get('parentId') is None + assert actual_json.get('type') == item_type + assert actual_json.get('startTime') == start_time + assert actual_json.get('name') == item_name + assert actual_json.get('launchUuid') == launch_uuid + assert actual_json.get('attributes') is None + assert actual_json.get('parameters') is None + + +def mock_basic_put_response(session): + return_object = mock.AsyncMock() + return_object.json.return_value = RETURN_PUT_JSON + session.put.return_value = return_object + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='the test requires AsyncMock which was introduced in Python 3.8') +@pytest.mark.asyncio +async def test_finish_test_item(aio_client: Client): + # noinspection PyTypeChecker + session: mock.AsyncMock = await aio_client.session() + mock_basic_put_response(session) + + launch_uuid = 'test_launch_uuid' + item_id = 'test_item_uuid' + expected_uri = f'/api/v2/project/item/{item_id}' + end_time = str(1696921416000) + status = 'FAILED' + description = 'Test Launch description' + attributes = {'attribute_key': 'attribute_value', 'system': True} + issue = Issue('pb001', comment='Horrible bug!') + + result = await aio_client.finish_test_item(launch_uuid, item_id, end_time, status=status, + description=description, attributes=attributes, + issue=issue, retry=True) + assert result == RESPONSE_MESSAGE + session.put.assert_called_once() + call_args = session.put.call_args_list[0] + assert expected_uri == call_args[0][0] + kwargs = call_args[1] + assert kwargs.get('data') is None + actual_json = kwargs.get('json') + assert actual_json is not None + assert actual_json.get('retry') is True + assert actual_json.get('description') == description + assert actual_json.get('launchUuid') == launch_uuid + assert actual_json.get('endTime') == end_time + assert actual_json.get('status') == status + actual_attributes = actual_json.get('attributes') + verify_attributes(attributes, actual_attributes) + actual_issue = actual_json.get('issue') + expected_issue = issue.payload + assert len(actual_issue) == len(expected_issue) + for entry in actual_issue.items(): + assert entry[1] == expected_issue[entry[0]] + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='the test requires AsyncMock which was introduced in Python 3.8') +@pytest.mark.asyncio +async def test_finish_test_item_default_values(aio_client: Client): + # noinspection PyTypeChecker + session: mock.AsyncMock = await aio_client.session() + mock_basic_put_response(session) + + launch_uuid = 'test_launch_uuid' + item_id = 'test_item_uuid' + expected_uri = f'/api/v2/project/item/{item_id}' + end_time = str(1696921416000) + + result = await aio_client.finish_test_item(launch_uuid, item_id, end_time) + assert result == RESPONSE_MESSAGE + session.put.assert_called_once() + call_args = session.put.call_args_list[0] + assert expected_uri == call_args[0][0] + kwargs = call_args[1] + assert kwargs.get('data') is None + actual_json = kwargs.get('json') + assert actual_json is not None + assert actual_json.get('retry') is False + assert actual_json.get('description') is None + assert actual_json.get('launchUuid') == launch_uuid + assert actual_json.get('endTime') == end_time + assert actual_json.get('status') is None + assert actual_json.get('attributes') is None + assert actual_json.get('issue') is None + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='the test requires AsyncMock which was introduced in Python 3.8') +@pytest.mark.asyncio +async def test_finish_launch(aio_client: Client): + # noinspection PyTypeChecker + session: mock.AsyncMock = await aio_client.session() + mock_basic_put_response(session) + + launch_uuid = 'test_launch_uuid' + expected_uri = f'/api/v2/project/launch/{launch_uuid}/finish' + end_time = str(1696921416000) + status = 'FAILED' + attributes = {'attribute_key': 'attribute_value', 'system': False} + + result = await aio_client.finish_launch(launch_uuid, end_time, status=status, attributes=attributes) + assert result == RESPONSE_MESSAGE + session.put.assert_called_once() + call_args = session.put.call_args_list[0] + assert expected_uri == call_args[0][0] + kwargs = call_args[1] + assert kwargs.get('data') is None + actual_json = kwargs.get('json') + assert actual_json is not None + assert actual_json.get('endTime') == end_time + assert actual_json.get('status') == status + actual_attributes = actual_json.get('attributes') + verify_attributes(attributes, actual_attributes) + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='the test requires AsyncMock which was introduced in Python 3.8') +@pytest.mark.asyncio +async def test_finish_launch_default_values(aio_client: Client): + # noinspection PyTypeChecker + session: mock.AsyncMock = await aio_client.session() + mock_basic_put_response(session) + + launch_uuid = 'test_launch_uuid' + expected_uri = f'/api/v2/project/launch/{launch_uuid}/finish' + end_time = str(1696921416000) + + result = await aio_client.finish_launch(launch_uuid, end_time) + assert result == RESPONSE_MESSAGE + session.put.assert_called_once() + call_args = session.put.call_args_list[0] + assert expected_uri == call_args[0][0] + kwargs = call_args[1] + assert kwargs.get('data') is None + actual_json = kwargs.get('json') + assert actual_json is not None + assert actual_json.get('endTime') == end_time + assert actual_json.get('status') is None + actual_attributes = actual_json.get('attributes') + verify_attributes(None, actual_attributes) + + +def mock_basic_get_response(session): + return_object = mock.AsyncMock() + return_object.json.return_value = RETURN_GET_JSON + session.get.return_value = return_object + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='the test requires AsyncMock which was introduced in Python 3.8') +@pytest.mark.asyncio +async def test_update_item(aio_client: Client): + # noinspection PyTypeChecker + session: mock.AsyncMock = await aio_client.session() + mock_basic_put_response(session) + mock_basic_get_response(session) + + item_id = 'test_item_uuid' + expected_uri = f'/api/v1/project/item/{GET_RESPONSE_ID}/update' + description = 'Test Launch description' + attributes = {'attribute_key': 'attribute_value', 'system': True} + + result = await aio_client.update_test_item(item_id, description=description, attributes=attributes) + assert result == RESPONSE_MESSAGE + session.put.assert_called_once() + call_args = session.put.call_args_list[0] + assert expected_uri == call_args[0][0] + kwargs = call_args[1] + assert kwargs.get('data') is None + actual_json = kwargs.get('json') + assert actual_json is not None + actual_attributes = actual_json.get('attributes') + verify_attributes(attributes, actual_attributes) + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='the test requires AsyncMock which was introduced in Python 3.8') +@pytest.mark.asyncio +async def test_get_item_id_by_uuid(aio_client: Client): + # noinspection PyTypeChecker + session: mock.AsyncMock = await aio_client.session() + mock_basic_get_response(session) + + item_id = 'test_item_uuid' + expected_uri = f'/api/v1/project/item/uuid/{item_id}' + + result = await aio_client.get_item_id_by_uuid(item_id) + assert result == GET_RESPONSE_ID + session.get.assert_called_once() + call_args = session.get.call_args_list[0] + assert expected_uri == call_args[0][0] + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='the test requires AsyncMock which was introduced in Python 3.8') +@pytest.mark.asyncio +async def test_get_launch_ui_url(aio_client: Client): + # noinspection PyTypeChecker + session: mock.AsyncMock = await aio_client.session() + mock_basic_get_response(session) + + launch_id = 'test_launch_uuid' + expected_uri = f'/api/v1/project/launch/uuid/{launch_id}' + + result = await aio_client.get_launch_ui_url(launch_id) + assert result == f'http://endpoint/ui/#project/launches/all/{GET_RESPONSE_ID}' + session.get.assert_called_once() + call_args = session.get.call_args_list[0] + assert expected_uri == call_args[0][0] diff --git a/tests/aio/test_async_client.py b/tests/aio/test_async_client.py new file mode 100644 index 00000000..aa79c8cd --- /dev/null +++ b/tests/aio/test_async_client.py @@ -0,0 +1,173 @@ +# Copyright (c) 2023 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 pickle +import sys +from unittest import mock + +# noinspection PyPackageRequirements +import pytest + +from reportportal_client.aio import AsyncRPClient +from reportportal_client.helpers import timestamp + + +def test_async_rp_client_pickling(): + client = AsyncRPClient('http://localhost:8080', 'default_personal', api_key='test_key') + pickled_client = pickle.dumps(client) + unpickled_client = pickle.loads(pickled_client) + assert unpickled_client is not None + + +def test_clone(): + args = ['http://endpoint', 'project'] + kwargs = {'api_key': 'api_key1', 'launch_uuid': 'launch_uuid', 'log_batch_size': 30, + 'log_batch_payload_limit': 30 * 1024 * 1024} + async_client = AsyncRPClient(*args, **kwargs) + async_client._add_current_item('test-321') + async_client._add_current_item('test-322') + client = async_client.client + step_reporter = async_client.step_reporter + cloned = async_client.clone() + assert ( + cloned is not None + and async_client is not cloned + and cloned.client is not None + and cloned.client is not client + and cloned.step_reporter is not None + and cloned.step_reporter is not step_reporter + ) + assert ( + cloned.endpoint == args[0] + and cloned.project == args[1] + and cloned.client.endpoint == args[0] + and cloned.client.project == args[1] + ) + assert ( + cloned.client.api_key == kwargs['api_key'] + and cloned.launch_uuid == kwargs['launch_uuid'] + and cloned.log_batch_size == kwargs['log_batch_size'] + and cloned.log_batch_payload_limit == kwargs['log_batch_payload_limit'] + ) + assert cloned._item_stack.qsize() == 1 \ + and async_client.current_item() == cloned.current_item() + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='the test requires AsyncMock which was introduced in Python 3.8') +@pytest.mark.asyncio +async def test_start_launch(): + aio_client = mock.AsyncMock() + client = AsyncRPClient('http://endpoint', 'project', api_key='api_key', + client=aio_client) + launch_name = 'Test Launch' + start_time = str(1696921416000) + description = 'Test Launch description' + attributes = {'attribute_key': 'attribute_value'} + rerun = True + rerun_of = 'test_prent_launch_uuid' + result = await client.start_launch(launch_name, start_time, description=description, + attributes=attributes, rerun=rerun, rerun_of=rerun_of) + + assert result is not None + assert client.launch_uuid == result + aio_client.start_launch.assert_called_once() + args, kwargs = aio_client.start_launch.call_args_list[0] + assert args[0] == launch_name + assert args[1] == start_time + assert kwargs.get('description') == description + assert kwargs.get('attributes') == attributes + assert kwargs.get('rerun') == rerun + assert kwargs.get('rerun_of') == rerun_of + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='the test requires AsyncMock which was introduced in Python 3.8') +@pytest.mark.parametrize( + 'launch_uuid, method, params', + [ + ('test_launch_uuid', 'start_test_item', ['Test Item', timestamp(), 'STEP']), + ('test_launch_uuid', 'finish_test_item', ['test_item_id', timestamp()]), + ('test_launch_uuid', 'get_launch_info', []), + ('test_launch_uuid', 'get_launch_ui_id', []), + ('test_launch_uuid', 'get_launch_ui_url', []), + ('test_launch_uuid', 'log', [timestamp(), 'Test message']), + (None, 'start_test_item', ['Test Item', timestamp(), 'STEP']), + (None, 'finish_test_item', ['test_item_id', timestamp()]), + (None, 'get_launch_info', []), + (None, 'get_launch_ui_id', []), + (None, 'get_launch_ui_url', []), + (None, 'log', [timestamp(), 'Test message']), + ] +) +@pytest.mark.asyncio +async def test_launch_uuid_usage(launch_uuid, method, params): + started_launch_uuid = 'new_test_launch_uuid' + aio_client = mock.AsyncMock() + aio_client.start_launch.return_value = started_launch_uuid + client = AsyncRPClient('http://endpoint', 'project', api_key='api_key', + client=aio_client, launch_uuid=launch_uuid, log_batch_size=1) + actual_launch_uuid = await client.start_launch('Test Launch', timestamp()) + await getattr(client, method)(*params) + finish_launch_message = await client.finish_launch(timestamp()) + + if launch_uuid is None: + aio_client.start_launch.assert_called_once() + assert actual_launch_uuid == started_launch_uuid + assert client.launch_uuid == started_launch_uuid + aio_client.finish_launch.assert_called_once() + assert finish_launch_message + else: + aio_client.start_launch.assert_not_called() + assert actual_launch_uuid == launch_uuid + assert client.launch_uuid == launch_uuid + aio_client.finish_launch.assert_not_called() + assert finish_launch_message == '' + assert client.launch_uuid == actual_launch_uuid + + if method == 'log': + assert len(getattr(aio_client, 'log_batch').call_args_list) == 2 + args, kwargs = getattr(aio_client, 'log_batch').call_args_list[0] + batch = args[0] + assert isinstance(batch, list) + assert len(batch) == 1 + log = batch[0] + assert log.launch_uuid == actual_launch_uuid + assert log.time == params[0] + assert log.message == params[1] + else: + getattr(aio_client, method).assert_called_once() + args, kwargs = getattr(aio_client, method).call_args_list[0] + assert args[0] == actual_launch_uuid + for i, param in enumerate(params): + assert args[i + 1] == param + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='the test requires AsyncMock which was introduced in Python 3.8') +@pytest.mark.asyncio +async def test_start_item_tracking(async_client: AsyncRPClient): + aio_client = async_client.client + + started_launch_uuid = 'new_test_launch_uuid' + aio_client.start_launch.return_value = started_launch_uuid + test_item_id = 'test_item_uuid' + aio_client.start_test_item.return_value = test_item_id + + await async_client.start_launch('Test Launch', timestamp()) + actual_item_id = await async_client.start_test_item('Test Item Name', timestamp(), 'STEP') + assert actual_item_id == test_item_id + assert async_client.current_item() == test_item_id + + await async_client.finish_test_item(actual_item_id, timestamp()) + assert async_client.current_item() is None diff --git a/tests/aio/test_batched_client.py b/tests/aio/test_batched_client.py new file mode 100644 index 00000000..9962a117 --- /dev/null +++ b/tests/aio/test_batched_client.py @@ -0,0 +1,139 @@ +# Copyright (c) 2023 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 pickle +import sys +from unittest import mock + +# noinspection PyPackageRequirements +import pytest + +from reportportal_client.aio import BatchedRPClient +from reportportal_client.helpers import timestamp + + +def test_batched_rp_client_pickling(): + client = BatchedRPClient('http://localhost:8080', 'default_personal', api_key='test_key') + pickled_client = pickle.dumps(client) + unpickled_client = pickle.loads(pickled_client) + assert unpickled_client is not None + + +async def __empty_string(): + return "" + + +def test_clone(): + args = ['http://endpoint', 'project'] + kwargs = {'api_key': 'api_key1', 'launch_uuid': 'launch_uuid', 'log_batch_size': 30, + 'log_batch_payload_limit': 30 * 1024 * 1024, 'task_timeout': 63, 'shutdown_timeout': 123, + 'trigger_num': 25, 'trigger_interval': 3} + async_client = BatchedRPClient(*args, **kwargs) + task1 = async_client.create_task(__empty_string()) + task2 = async_client.create_task(__empty_string()) + task1.blocking_result() + task2.blocking_result() + async_client._add_current_item(task1) + async_client._add_current_item(task2) + client = async_client.client + step_reporter = async_client.step_reporter + cloned = async_client.clone() + assert ( + cloned is not None + and async_client is not cloned + and cloned.client is not None + and cloned.client is client + and cloned.step_reporter is not None + and cloned.step_reporter is not step_reporter + and cloned._task_list is async_client._task_list + and cloned._task_mutex is async_client._task_mutex + and cloned._loop is async_client._loop + ) + assert ( + cloned.endpoint == args[0] + and cloned.project == args[1] + and cloned.client.endpoint == args[0] + and cloned.client.project == args[1] + ) + assert ( + cloned.client.api_key == kwargs['api_key'] + and cloned.launch_uuid.blocking_result() == kwargs['launch_uuid'] + and cloned.log_batch_size == kwargs['log_batch_size'] + and cloned.log_batch_payload_limit == kwargs['log_batch_payload_limit'] + and cloned.task_timeout == kwargs['task_timeout'] + and cloned.shutdown_timeout == kwargs['shutdown_timeout'] + and cloned.trigger_num == kwargs['trigger_num'] + and cloned.trigger_interval == kwargs['trigger_interval'] + ) + assert cloned._item_stack.qsize() == 1 \ + and async_client.current_item() == cloned.current_item() + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='the test requires AsyncMock which was introduced in Python 3.8') +@pytest.mark.parametrize( + 'launch_uuid, method, params', + [ + ('test_launch_uuid', 'start_test_item', ['Test Item', timestamp(), 'STEP']), + ('test_launch_uuid', 'finish_test_item', ['test_item_id', timestamp()]), + ('test_launch_uuid', 'get_launch_info', []), + ('test_launch_uuid', 'get_launch_ui_id', []), + ('test_launch_uuid', 'get_launch_ui_url', []), + ('test_launch_uuid', 'log', [timestamp(), 'Test message']), + (None, 'start_test_item', ['Test Item', timestamp(), 'STEP']), + (None, 'finish_test_item', ['test_item_id', timestamp()]), + (None, 'get_launch_info', []), + (None, 'get_launch_ui_id', []), + (None, 'get_launch_ui_url', []), + (None, 'log', [timestamp(), 'Test message']), + ] +) +def test_launch_uuid_usage(launch_uuid, method, params): + started_launch_uuid = 'new_test_launch_uuid' + aio_client = mock.AsyncMock() + aio_client.start_launch.return_value = started_launch_uuid + client = BatchedRPClient('http://endpoint', 'project', api_key='api_key', + client=aio_client, launch_uuid=launch_uuid, log_batch_size=1) + actual_launch_uuid = (client.start_launch('Test Launch', timestamp())).blocking_result() + getattr(client, method)(*params).blocking_result() + finish_launch_message = (client.finish_launch(timestamp())).blocking_result() + + if launch_uuid is None: + aio_client.start_launch.assert_called_once() + assert actual_launch_uuid == started_launch_uuid + assert client.launch_uuid.blocking_result() == started_launch_uuid + aio_client.finish_launch.assert_called_once() + assert finish_launch_message + else: + aio_client.start_launch.assert_not_called() + assert actual_launch_uuid == launch_uuid + assert client.launch_uuid.blocking_result() == launch_uuid + aio_client.finish_launch.assert_not_called() + assert finish_launch_message == '' + assert client.launch_uuid.blocking_result() == actual_launch_uuid + + if method == 'log': + assert len(getattr(aio_client, 'log_batch').call_args_list) == 2 + args, kwargs = getattr(aio_client, 'log_batch').call_args_list[0] + batch = args[0] + assert isinstance(batch, list) + assert len(batch) == 1 + log = batch[0] + assert log.launch_uuid.blocking_result() == actual_launch_uuid + assert log.time == params[0] + assert log.message == params[1] + else: + getattr(aio_client, method).assert_called_once() + args, kwargs = getattr(aio_client, method).call_args_list[0] + assert args[0].blocking_result() == actual_launch_uuid + for i, param in enumerate(params): + assert args[i + 1] == param diff --git a/tests/aio/test_threaded_client.py b/tests/aio/test_threaded_client.py new file mode 100644 index 00000000..10a9500c --- /dev/null +++ b/tests/aio/test_threaded_client.py @@ -0,0 +1,135 @@ +# Copyright (c) 2023 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 pickle +import sys +from unittest import mock + +import pytest + +from reportportal_client.aio import ThreadedRPClient +from reportportal_client.helpers import timestamp + + +def test_threaded_rp_client_pickling(): + client = ThreadedRPClient('http://localhost:8080', 'default_personal', api_key='test_key') + pickled_client = pickle.dumps(client) + unpickled_client = pickle.loads(pickled_client) + assert unpickled_client is not None + + +async def __empty_string(): + return "" + + +def test_clone(): + args = ['http://endpoint', 'project'] + kwargs = {'api_key': 'api_key1', 'launch_uuid': 'launch_uuid', 'log_batch_size': 30, + 'log_batch_payload_limit': 30 * 1024 * 1024, 'task_timeout': 63, 'shutdown_timeout': 123} + async_client = ThreadedRPClient(*args, **kwargs) + task1 = async_client.create_task(__empty_string()) + task2 = async_client.create_task(__empty_string()) + task1.blocking_result() + task2.blocking_result() + async_client._add_current_item(task1) + async_client._add_current_item(task2) + client = async_client.client + step_reporter = async_client.step_reporter + cloned = async_client.clone() + assert ( + cloned is not None + and async_client is not cloned + and cloned.client is not None + and cloned.client is client + and cloned.step_reporter is not None + and cloned.step_reporter is not step_reporter + and cloned._task_list is async_client._task_list + and cloned._task_mutex is async_client._task_mutex + and cloned._loop is async_client._loop + ) + assert ( + cloned.endpoint == args[0] + and cloned.project == args[1] + and cloned.client.endpoint == args[0] + and cloned.client.project == args[1] + ) + assert ( + cloned.client.api_key == kwargs['api_key'] + and cloned.launch_uuid.blocking_result() == kwargs['launch_uuid'] + and cloned.log_batch_size == kwargs['log_batch_size'] + and cloned.log_batch_payload_limit == kwargs['log_batch_payload_limit'] + and cloned.task_timeout == kwargs['task_timeout'] + and cloned.shutdown_timeout == kwargs['shutdown_timeout'] + ) + assert cloned._item_stack.qsize() == 1 \ + and async_client.current_item() == cloned.current_item() + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='the test requires AsyncMock which was introduced in Python 3.8') +@pytest.mark.parametrize( + 'launch_uuid, method, params', + [ + ('test_launch_uuid', 'start_test_item', ['Test Item', timestamp(), 'STEP']), + ('test_launch_uuid', 'finish_test_item', ['test_item_id', timestamp()]), + ('test_launch_uuid', 'get_launch_info', []), + ('test_launch_uuid', 'get_launch_ui_id', []), + ('test_launch_uuid', 'get_launch_ui_url', []), + ('test_launch_uuid', 'log', [timestamp(), 'Test message']), + (None, 'start_test_item', ['Test Item', timestamp(), 'STEP']), + (None, 'finish_test_item', ['test_item_id', timestamp()]), + (None, 'get_launch_info', []), + (None, 'get_launch_ui_id', []), + (None, 'get_launch_ui_url', []), + (None, 'log', [timestamp(), 'Test message']), + ] +) +def test_launch_uuid_usage(launch_uuid, method, params): + started_launch_uuid = 'new_test_launch_uuid' + aio_client = mock.AsyncMock() + aio_client.start_launch.return_value = started_launch_uuid + client = ThreadedRPClient('http://endpoint', 'project', api_key='api_key', + client=aio_client, launch_uuid=launch_uuid, log_batch_size=1) + actual_launch_uuid = (client.start_launch('Test Launch', timestamp())).blocking_result() + getattr(client, method)(*params).blocking_result() + finish_launch_message = (client.finish_launch(timestamp())).blocking_result() + + if launch_uuid is None: + aio_client.start_launch.assert_called_once() + assert actual_launch_uuid == started_launch_uuid + assert client.launch_uuid.blocking_result() == started_launch_uuid + aio_client.finish_launch.assert_called_once() + assert finish_launch_message + else: + aio_client.start_launch.assert_not_called() + assert actual_launch_uuid == launch_uuid + assert client.launch_uuid.blocking_result() == launch_uuid + aio_client.finish_launch.assert_not_called() + assert finish_launch_message == '' + assert client.launch_uuid.blocking_result() == actual_launch_uuid + + if method == 'log': + assert len(getattr(aio_client, 'log_batch').call_args_list) == 2 + args, kwargs = getattr(aio_client, 'log_batch').call_args_list[0] + batch = args[0] + assert isinstance(batch, list) + assert len(batch) == 1 + log = batch[0] + assert log.launch_uuid.blocking_result() == actual_launch_uuid + assert log.time == params[0] + assert log.message == params[1] + else: + getattr(aio_client, method).assert_called_once() + args, kwargs = getattr(aio_client, method).call_args_list[0] + assert args[0].blocking_result() == actual_launch_uuid + for i, param in enumerate(params): + assert args[i + 1] == param diff --git a/tests/conftest.py b/tests/conftest.py index fc040a8f..1572a2d6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,27 +1,43 @@ +# Copyright (c) 2023 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 common Pytest fixtures and hooks for unit tests.""" -from six.moves import mock +from unittest import mock # noinspection PyPackageRequirements from pytest import fixture from reportportal_client.client import RPClient +from reportportal_client.aio.client import Client, AsyncRPClient -@fixture() +@fixture def response(): """Cook up a mock for the Response with specific arguments.""" + def inner(ret_code, ret_value): """Set up response with the given parameters. :param ret_code: Return code for the response :param ret_value: Return value for the response - :return: Mocked Responce object with the given parameters + :return: Mocked Response object with the given parameters """ with mock.patch('requests.Response') as resp: resp.status_code = ret_code resp.json.return_value = ret_value return resp + return inner @@ -30,4 +46,22 @@ def rp_client(): """Prepare instance of the RPClient for testing.""" client = RPClient('http://endpoint', 'project', 'api_key') client.session = mock.Mock() + client._skip_analytics = True + return client + + +@fixture +def aio_client(): + """Prepare instance of the Client for testing.""" + client = Client('http://endpoint', 'project', api_key='api_key') + client._session = mock.AsyncMock() + client._skip_analytics = True + return client + + +@fixture +def async_client(): + """Prepare instance of the AsyncRPClient for testing.""" + client = AsyncRPClient('http://endpoint', 'project', api_key='api_key', + client=mock.AsyncMock()) return client diff --git a/tests/core/test_worker.py b/tests/core/test_worker.py index 27109c2c..34d8ea92 100644 --- a/tests/core/test_worker.py +++ b/tests/core/test_worker.py @@ -13,9 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License +import queue import time - -from six.moves import queue, mock +from unittest import mock from reportportal_client.core.rp_requests import ( HttpRequest, @@ -44,27 +44,23 @@ def test_worker_continue_working_on_request_error(): http_fail = HttpRequest( fail_session, LOG_REQUEST_URL, files=log_batch.payload, verify_ssl=False) - log_batch.http_request = http_fail - worker.send(log_batch) + worker.send(http_fail) start_time = time.time() while fail_session.call_count < 1 and time.time() - start_time < 10: time.sleep(0.1) assert fail_session.call_count == 1 - assert log_batch.response is None pass_session = mock.Mock() http_pass = HttpRequest( pass_session, LOG_REQUEST_URL, files=log_batch.payload, verify_ssl=False) - log_batch.http_request = http_pass - worker.send(log_batch) + worker.send(http_pass) start_time = time.time() while pass_session.call_count < 1 and time.time() - start_time < 10: time.sleep(0.1) assert pass_session.call_count == 1 - assert log_batch.response worker.stop() diff --git a/tests/logs/test_log_manager.py b/tests/logs/test_log_manager.py index 3d2896df..5267fb11 100644 --- a/tests/logs/test_log_manager.py +++ b/tests/logs/test_log_manager.py @@ -11,13 +11,14 @@ # See the License for the specific language governing permissions and # limitations under the License +import json import os - -from six.moves import mock +from unittest import mock from reportportal_client import helpers -from reportportal_client.logs.log_manager import LogManager, \ - MAX_LOG_BATCH_PAYLOAD_SIZE +from reportportal_client.core.rp_requests import HttpRequest +from reportportal_client.logs import MAX_LOG_BATCH_PAYLOAD_SIZE +from reportportal_client.logs.log_manager import LogManager RP_URL = 'http://docker.local:8080' API_VERSION = 'v2' @@ -45,8 +46,8 @@ def test_log_batch_send_by_length(): assert log_manager._worker.send.call_count == 1 batch = log_manager._worker.send.call_args[0][0] - assert len(batch.log_reqs) == 5 - assert batch.http_request is not None + assert isinstance(batch, HttpRequest) + assert len(json.loads(batch.files[0][1][1])) == 5 assert 'post' in session._mock_children assert len(log_manager._batch) == 0 assert log_manager._payload_size == helpers.TYPICAL_MULTIPART_FOOTER_LENGTH @@ -67,8 +68,8 @@ def test_log_batch_send_url_format(): assert log_manager._worker.send.call_count == 1 batch = log_manager._worker.send.call_args[0][0] - assert batch.http_request is not None - assert batch.http_request.url == \ + assert isinstance(batch, HttpRequest) + assert batch.url == \ RP_URL + '/api/' + API_VERSION + '/' + PROJECT_NAME + '/log' @@ -105,8 +106,8 @@ def test_log_batch_send_by_stop(): assert log_manager._worker.send.call_count == 1 batch = log_manager._worker.send.call_args[0][0] - assert len(batch.log_reqs) == 4 - assert batch.http_request is not None + assert isinstance(batch, HttpRequest) + assert len(json.loads(batch.files[0][1][1])) == 4 assert 'post' in session._mock_children assert len(log_manager._batch) == 0 assert log_manager._payload_size == helpers.TYPICAL_MULTIPART_FOOTER_LENGTH @@ -159,12 +160,11 @@ def test_log_batch_send_by_size(): assert log_manager._worker.send.call_count == 1 batch = log_manager._worker.send.call_args[0][0] - assert len(batch.log_reqs) == 1 - assert batch.http_request is not None + assert isinstance(batch, HttpRequest) + assert len(json.loads(batch.files[0][1][1])) == 1 assert 'post' in session._mock_children assert len(log_manager._batch) == 1 - assert log_manager._payload_size < \ - helpers.TYPICAL_MULTIPART_FOOTER_LENGTH + 1024 + assert log_manager._payload_size < helpers.TYPICAL_MULTIPART_FOOTER_LENGTH + 1024 # noinspection PyUnresolvedReferences @@ -189,8 +189,8 @@ def test_log_batch_triggers_previous_request_to_send(): assert log_manager._worker.send.call_count == 1 batch = log_manager._worker.send.call_args[0][0] - assert len(batch.log_reqs) == 1 - assert batch.http_request is not None + assert isinstance(batch, HttpRequest) + assert len(json.loads(batch.files[0][1][1])) == 1 assert 'post' in session._mock_children assert len(log_manager._batch) == 1 assert log_manager._payload_size > MAX_LOG_BATCH_PAYLOAD_SIZE diff --git a/tests/logs/test_rp_file.py b/tests/logs/test_rp_file.py index 4af5e60f..b846a31c 100644 --- a/tests/logs/test_rp_file.py +++ b/tests/logs/test_rp_file.py @@ -11,6 +11,7 @@ # See the License for the specific language governing permissions and # limitations under the License +# noinspection PyPackageRequirements import pytest from reportportal_client.core.rp_file import RPFile diff --git a/tests/logs/test_rp_log_handler.py b/tests/logs/test_rp_log_handler.py index 6f6fcf65..1f275a78 100644 --- a/tests/logs/test_rp_log_handler.py +++ b/tests/logs/test_rp_log_handler.py @@ -11,14 +11,14 @@ # See the License for the specific language governing permissions and # limitations under the License import re +# noinspection PyUnresolvedReferences +from unittest import mock # noinspection PyPackageRequirements import pytest -# noinspection PyUnresolvedReferences -from six.moves import mock # noinspection PyProtectedMember -from reportportal_client._local import set_current +from reportportal_client._internal.local import set_current from reportportal_client.logs import RPLogHandler, RPLogger @@ -96,7 +96,7 @@ def test_emit_custom_level(mocked_handle): log_handler = RPLogHandler() log_handler.emit(record) assert mock_client.log.call_count == 1 - call_kwargs = mock_client.log.call_args[1] + call_args, call_kwargs = mock_client.log.call_args assert call_kwargs['level'] == 'WARN' diff --git a/tests/logs/test_rp_logger.py b/tests/logs/test_rp_logger.py index 9475b3ed..a6beefb1 100644 --- a/tests/logs/test_rp_logger.py +++ b/tests/logs/test_rp_logger.py @@ -10,15 +10,18 @@ # 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 inspect import logging import sys from logging import LogRecord +from unittest import mock +# noinspection PyPackageRequirements import pytest -from six.moves import mock -from reportportal_client._local import set_current +# noinspection PyProtectedMember +from reportportal_client._internal.local import set_current from reportportal_client.logs import RPLogger, RPLogHandler diff --git a/tests/steps/conftest.py b/tests/steps/conftest.py index cd376d97..ec86929e 100644 --- a/tests/steps/conftest.py +++ b/tests/steps/conftest.py @@ -11,17 +11,16 @@ # See the License for the specific language governing permissions and # limitations under the License -from six.moves import mock +from unittest import mock +# noinspection PyPackageRequirements from pytest import fixture from reportportal_client.client import RPClient -from reportportal_client.steps import StepReporter @fixture def rp_client(): client = RPClient('http://endpoint', 'project', 'api_key') client.session = mock.Mock() - client.step_reporter = StepReporter(client) return client diff --git a/tests/steps/test_steps.py b/tests/steps/test_steps.py index 2a6615ad..8384ec28 100644 --- a/tests/steps/test_steps.py +++ b/tests/steps/test_steps.py @@ -12,10 +12,11 @@ # limitations under the License import random import time +from unittest import mock from reportportal_client import step -from reportportal_client._local import set_current -from six.moves import mock +# noinspection PyProtectedMember +from reportportal_client._internal.local import set_current NESTED_STEP_NAME = 'test nested step' PARENT_STEP_ID = '123-123-1234-123' @@ -81,7 +82,7 @@ def test_nested_step_decorator(rp_client): assert rp_client.session.post.call_count == 1 assert rp_client.session.put.call_count == 1 - assert len(rp_client._log_manager._batch) == 0 + assert len(rp_client._log_batcher._batch) == 0 def test_nested_step_failed(rp_client): @@ -136,8 +137,8 @@ def test_verify_parameters_logging_default_value(rp_client): rp_client.session.post.side_effect = item_id_gen rp_client._add_current_item(PARENT_STEP_ID) nested_step_params(1, 'two') - assert len(rp_client._log_manager._batch) == 1 - assert rp_client._log_manager._batch[0].message \ + assert len(rp_client._log_batcher._batch) == 1 + assert rp_client._log_batcher._batch[0].message \ == "Parameters: param1: 1; param2: two" @@ -145,8 +146,8 @@ def test_verify_parameters_logging_no_default_value(rp_client): rp_client.session.post.side_effect = item_id_gen rp_client._add_current_item(PARENT_STEP_ID) nested_step_params(1, 'two', 'three') - assert len(rp_client._log_manager._batch) == 1 - assert rp_client._log_manager._batch[0].message \ + assert len(rp_client._log_batcher._batch) == 1 + assert rp_client._log_batcher._batch[0].message \ == "Parameters: param1: 1; param2: two; param3: three" @@ -154,8 +155,8 @@ def test_verify_parameters_logging_named_value(rp_client): rp_client.session.post.side_effect = item_id_gen rp_client._add_current_item(PARENT_STEP_ID) nested_step_params(1, 'two', param3='three') - assert len(rp_client._log_manager._batch) == 1 - assert rp_client._log_manager._batch[0].message \ + assert len(rp_client._log_batcher._batch) == 1 + assert rp_client._log_batcher._batch[0].message \ == "Parameters: param1: 1; param2: two; param3: three" @@ -164,8 +165,8 @@ def test_verify_parameters_inline_logging(rp_client): rp_client._add_current_item(PARENT_STEP_ID) with step(NESTED_STEP_NAME, params={'param1': 1, 'param2': 'two'}): pass - assert len(rp_client._log_manager._batch) == 1 - assert rp_client._log_manager._batch[0].message \ + assert len(rp_client._log_batcher._batch) == 1 + assert rp_client._log_batcher._batch[0].message \ == "Parameters: param1: 1; param2: two" @@ -181,7 +182,7 @@ def test_two_level_nested_step_decorator(rp_client): assert rp_client.session.post.call_count == 2 assert rp_client.session.put.call_count == 2 - assert len(rp_client._log_manager._batch) == 0 + assert len(rp_client._log_batcher._batch) == 0 request_uri = rp_client.session.post.call_args_list[0][0][0] first_parent_id = request_uri[request_uri.rindex('/') + 1:] diff --git a/tests/test_client.py b/tests/test_client.py index 0e1f34ad..d4a1f3b0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -10,12 +10,15 @@ # 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 pickle from io import StringIO +from unittest import mock +# noinspection PyPackageRequirements import pytest from requests import Response from requests.exceptions import ReadTimeout -from six.moves import mock from reportportal_client import RPClient from reportportal_client.helpers import timestamp @@ -35,8 +38,8 @@ def response_error(*args, **kwargs): def invalid_response(*args, **kwargs): result = Response() result._content = \ - '405 Not Allowed' - result.status_code = 405 + 'Hello World!' + result.status_code = 200 return result @@ -57,7 +60,7 @@ def invalid_response(*args, **kwargs): ) def test_connection_errors(rp_client, requests_method, client_method, client_params): - rp_client.launch_id = 'test_launch_id' + rp_client._RPClient__launch_uuid = 'test_launch_id' getattr(rp_client.session, requests_method).side_effect = connection_error result = getattr(rp_client, client_method)(*client_params) assert result is None @@ -66,25 +69,6 @@ def test_connection_errors(rp_client, requests_method, client_method, result = getattr(rp_client, client_method)(*client_params) assert result is None - -@pytest.mark.parametrize( - 'requests_method, client_method, client_params', - [ - ('put', 'finish_launch', [timestamp()]), - ('put', 'finish_test_item', ['test_item_id', timestamp()]), - ('get', 'get_item_id_by_uuid', ['test_item_uuid']), - ('get', 'get_launch_info', []), - ('get', 'get_launch_ui_id', []), - ('get', 'get_launch_ui_url', []), - ('get', 'get_project_settings', []), - ('post', 'start_launch', ['Test Launch', timestamp()]), - ('post', 'start_test_item', ['Test Item', timestamp(), 'STEP']), - ('put', 'update_test_item', ['test_item_id']) - ] -) -def test_invalid_responses(rp_client, requests_method, client_method, - client_params): - rp_client.launch_id = 'test_launch_id' getattr(rp_client.session, requests_method).side_effect = invalid_response result = getattr(rp_client, client_method)(*client_params) assert result is None @@ -107,8 +91,8 @@ def test_invalid_responses(rp_client, requests_method, client_method, ] ) def test_launch_url_get(rp_client, launch_mode, project_name, expected_url): - rp_client.launch_id = 'test_launch_id' - rp_client.project = project_name + rp_client._RPClient__launch_uuid = 'test_launch_id' + rp_client._RPClient__project = project_name response = mock.Mock() response.is_success = True @@ -155,16 +139,19 @@ def test_clone(): cloned = client.clone() assert cloned is not None and client is not cloned assert cloned.endpoint == args[0] and cloned.project == args[1] - assert cloned.api_key == kwargs[ - 'api_key'] and cloned.log_batch_size == kwargs[ - 'log_batch_size'] and cloned.is_skipped_an_issue == kwargs[ - 'is_skipped_an_issue'] and cloned.verify_ssl == kwargs[ - 'verify_ssl'] and cloned.retries == kwargs[ - 'retries'] and cloned.max_pool_size == kwargs[ - 'max_pool_size'] and cloned.launch_id == kwargs[ - 'launch_id'] and cloned.http_timeout == kwargs[ - 'http_timeout'] and cloned.log_batch_payload_size == kwargs[ - 'log_batch_payload_size'] and cloned.mode == kwargs['mode'] + assert ( + cloned.api_key == kwargs['api_key'] + and cloned.log_batch_size == kwargs['log_batch_size'] + and cloned.is_skipped_an_issue == kwargs['is_skipped_an_issue'] + and cloned.verify_ssl == kwargs['verify_ssl'] + and cloned.retries == kwargs['retries'] + and cloned.max_pool_size == kwargs['max_pool_size'] + and cloned.launch_uuid == kwargs['launch_id'] + and cloned.launch_id == kwargs['launch_id'] + and cloned.http_timeout == kwargs['http_timeout'] + and cloned.log_batch_payload_size == kwargs['log_batch_payload_size'] + and cloned.mode == kwargs['mode'] + ) assert cloned._item_stack.qsize() == 1 \ and client.current_item() == cloned.current_item() @@ -201,8 +188,10 @@ def test_empty_api_key_argument(warn): def test_launch_uuid_print(): str_io = StringIO() + output_mock = mock.Mock() + output_mock.get_output.side_effect = lambda: str_io client = RPClient(endpoint='http://endpoint', project='project', - api_key='test', launch_uuid_print=True, print_output=str_io) + api_key='test', launch_uuid_print=True, print_output=output_mock) client.session = mock.Mock() client._skip_analytics = True client.start_launch('Test Launch', timestamp()) @@ -211,8 +200,10 @@ def test_launch_uuid_print(): def test_no_launch_uuid_print(): str_io = StringIO() + output_mock = mock.Mock() + output_mock.get_output.side_effect = lambda: str_io client = RPClient(endpoint='http://endpoint', project='project', - api_key='test', launch_uuid_print=False, print_output=str_io) + api_key='test', launch_uuid_print=False, print_output=output_mock) client.session = mock.Mock() client._skip_analytics = True client.start_launch('Test Launch', timestamp()) @@ -239,3 +230,10 @@ def test_launch_uuid_print_default_print(mock_stdout): client.start_launch('Test Launch', timestamp()) assert 'ReportPortal Launch UUID: ' not in mock_stdout.getvalue() + + +def test_client_pickling(): + client = RPClient('http://localhost:8080', 'default_personal', api_key='test_key') + pickled_client = pickle.dumps(client) + unpickled_client = pickle.loads(pickled_client) + assert unpickled_client is not None diff --git a/tests/test_client_factory.py b/tests/test_client_factory.py new file mode 100644 index 00000000..5b0461bb --- /dev/null +++ b/tests/test_client_factory.py @@ -0,0 +1,32 @@ +# Copyright (c) 2023 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 + +# noinspection PyPackageRequirements +import pytest + +from reportportal_client import (create_client, ClientType, RPClient, AsyncRPClient, ThreadedRPClient, + BatchedRPClient) + + +@pytest.mark.parametrize( + 'requested_type, expected_type', + [ + (ClientType.SYNC, RPClient), + (ClientType.ASYNC, AsyncRPClient), + (ClientType.ASYNC_THREAD, ThreadedRPClient), + (ClientType.ASYNC_BATCHED, BatchedRPClient), + ] +) +def test_client_factory_types(requested_type: ClientType, expected_type): + result = create_client(requested_type, 'http://endpoint', 'default_personal') + assert isinstance(result, expected_type) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 80748428..8d243a6f 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,5 +1,3 @@ -"""This script contains unit tests for the helpers script.""" - # Copyright (c) 2022 EPAM Systems # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,13 +11,17 @@ # See the License for the specific language governing permissions and # limitations under the License -from six.moves import mock +"""This script contains unit tests for the helpers script.""" + +from unittest import mock + +# noinspection PyPackageRequirements +import pytest from reportportal_client.helpers import ( gen_attributes, get_launch_sys_attrs, - get_package_version, - verify_value_length + verify_value_length, ATTRIBUTE_LENGTH_LIMIT, TRUNCATE_REPLACEMENT ) @@ -60,15 +62,35 @@ def test_get_launch_sys_attrs_docker(): assert result['cpu'] == 'unknown' -def test_get_package_version(): - """Test for the get_package_version() function-helper.""" - assert get_package_version('noname') == 'not found' - +@pytest.mark.parametrize( + 'attributes, expected_attributes', + [ + ({'tn': 'v' * 129}, [{'key': 'tn', 'value': 'v' * ( + ATTRIBUTE_LENGTH_LIMIT - len(TRUNCATE_REPLACEMENT)) + TRUNCATE_REPLACEMENT}]), + ({'tn': 'v' * 128}, [{'key': 'tn', 'value': 'v' * 128}]), + ({'k' * 129: 'v'}, [{'key': 'k' * ( + ATTRIBUTE_LENGTH_LIMIT - len(TRUNCATE_REPLACEMENT)) + TRUNCATE_REPLACEMENT, 'value': 'v'}]), + ({'k' * 128: 'v'}, [{'key': 'k' * 128, 'value': 'v'}]), + ({'tn': 'v' * 128, 'system': True}, [{'key': 'tn', 'value': 'v' * 128, 'system': True}]), + ({'tn': 'v' * 129, 'system': True}, [{'key': 'tn', 'value': 'v' * ( + ATTRIBUTE_LENGTH_LIMIT - len(TRUNCATE_REPLACEMENT)) + TRUNCATE_REPLACEMENT, 'system': True}]), + ({'k' * 129: 'v', 'system': False}, [{'key': 'k' * ( + ATTRIBUTE_LENGTH_LIMIT - len(TRUNCATE_REPLACEMENT)) + TRUNCATE_REPLACEMENT, 'value': 'v', + 'system': False}]), + ([{'key': 'tn', 'value': 'v' * 129}], [{'key': 'tn', 'value': 'v' * ( + ATTRIBUTE_LENGTH_LIMIT - len(TRUNCATE_REPLACEMENT)) + TRUNCATE_REPLACEMENT}]), + ([{'key': 'k' * 129, 'value': 'v'}], [{'key': 'k' * ( + ATTRIBUTE_LENGTH_LIMIT - len(TRUNCATE_REPLACEMENT)) + TRUNCATE_REPLACEMENT, 'value': 'v'}]), -def test_verify_value_length(): + ] +) +def test_verify_value_length(attributes, expected_attributes): """Test for validate verify_value_length() function.""" - inputl = [{'key': 'tn', 'value': 'v' * 130}, [1, 2], - {'value': 'tv2'}, {'value': 300}] - expected = [{'key': 'tn', 'value': 'v' * 128}, [1, 2], - {'value': 'tv2'}, {'value': 300}] - assert verify_value_length(inputl) == expected + result = verify_value_length(attributes) + assert len(result) == len(expected_attributes) + for i, element in enumerate(result): + expected = expected_attributes[i] + assert len(element) == len(expected) + assert element.get('key') == expected.get('key') + assert element.get('value') == expected.get('value') + assert element.get('system') == expected.get('system') diff --git a/tests/test_statistics.py b/tests/test_statistics.py deleted file mode 100644 index c38586ed..00000000 --- a/tests/test_statistics.py +++ /dev/null @@ -1,99 +0,0 @@ -"""This module contains unit tests for statistics used in the project.""" - -# Copyright (c) 2023 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 - -from requests.exceptions import RequestException -# noinspection PyUnresolvedReferences -from six.moves import mock - -from reportportal_client.services.constants import ENDPOINT, CLIENT_INFO -from reportportal_client.services.statistics import send_event - -EVENT_NAME = 'start_launch' - - -@mock.patch('reportportal_client.services.statistics.get_client_id', - mock.Mock(return_value='555')) -@mock.patch('reportportal_client.services.statistics.requests.post') -@mock.patch('reportportal_client.services.statistics.get_distribution') -@mock.patch('reportportal_client.services.statistics.python_version', - mock.Mock(return_value='3.6.6')) -def test_send_event(mocked_distribution, mocked_requests): - """Test functionality of the send_event() function. - - :param mocked_distribution: Mocked get_distribution() function - :param mocked_requests: Mocked requests module - """ - expected_cl_version, expected_cl_name = '5.0.4', 'reportportal-client' - agent_version, agent_name = '5.0.5', 'pytest-reportportal' - mocked_distribution.return_value.version = expected_cl_version - mocked_distribution.return_value.project_name = expected_cl_name - - expected_headers = {'User-Agent': 'python-requests'} - - expected_data = { - 'client_id': '555', - 'events': [{ - 'name': EVENT_NAME, - 'params': { - 'client_name': expected_cl_name, - 'client_version': expected_cl_version, - 'interpreter': 'Python 3.6.6', - 'agent_name': agent_name, - 'agent_version': agent_version, - } - }] - } - mid, key = CLIENT_INFO.split(':') - expected_params = {'measurement_id': mid, 'api_secret': key} - send_event(EVENT_NAME, agent_name, agent_version) - mocked_requests.assert_called_with( - url=ENDPOINT, json=expected_data, headers=expected_headers, - params=expected_params) - - -@mock.patch('reportportal_client.services.statistics.get_client_id', - mock.Mock(return_value='555')) -@mock.patch('reportportal_client.services.statistics.requests.post', - mock.Mock(side_effect=RequestException)) -@mock.patch('reportportal_client.services.statistics.get_distribution', - mock.Mock()) -def test_send_event_raises(): - """Test that the send_event() does not raise exceptions.""" - send_event(EVENT_NAME, 'pytest-reportportal', '5.0.5') - - -@mock.patch('reportportal_client.services.statistics.requests.post') -@mock.patch('reportportal_client.services.statistics.get_distribution') -@mock.patch('reportportal_client.services.statistics.python_version', - mock.Mock(return_value='3.6.6')) -def test_same_client_id(mocked_distribution, mocked_requests): - """Test functionality of the send_event() function. - - :param mocked_distribution: Mocked get_distribution() function - :param mocked_requests: Mocked requests module - """ - expected_cl_version, expected_cl_name = '5.0.4', 'reportportal-client' - agent_version, agent_name = '5.0.5', 'pytest-reportportal' - mocked_distribution.return_value.version = expected_cl_version - mocked_distribution.return_value.project_name = expected_cl_name - - send_event(EVENT_NAME, agent_name, agent_version) - send_event(EVENT_NAME, agent_name, agent_version) - args_list = mocked_requests.call_args_list - - result1 = args_list[0][1]['json']['client_id'] - result2 = args_list[1][1]['json']['client_id'] - - assert result1 == result2 diff --git a/tox.ini b/tox.ini index d82c5674..2f8dad03 100644 --- a/tox.ini +++ b/tox.ini @@ -25,8 +25,8 @@ commands = pre-commit run --all-files --show-diff-on-failure [gh-actions] python = - 3.7: pep, py37 - 3.8: py38 + 3.7: py37 + 3.8: pep, py38 3.9: py39 3.10: py310 3.11: py311