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: - { - "