diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..791f075d --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 119 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6c2853f2..1fe16d42 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,10 +31,10 @@ on: jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: matrix: - python-version: [ '2.7', '3.6', '3.7', '3.8', '3.9', '3.10' ] + python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11' ] steps: - name: Checkout repository uses: actions/checkout@v3 @@ -53,7 +53,7 @@ jobs: run: tox - name: Upload coverage to Codecov - if: matrix.python-version == 3.6 && success() + if: matrix.python-version == 3.7 && success() uses: codecov/codecov-action@v3 with: files: coverage.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index c1b9c147..572ba318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] ### Added +- `launch_uuid_print` and `print_output` arguments in `RPClient` class constructor, by @HardNorth +### Removed +- Python 2.7, 3.6 support, by @HardNorth + +## [5.3.5] +### Added - `__getstate__` and `__setstate__` methods in `RPClient` class to make it possible to pickle it, by @HardNorth ### Changed - `token` field of `RPClient` class was renamed to `api_key` to maintain common convention, by @HardNorth diff --git a/reportportal_client/client.py b/reportportal_client/client.py index 8f34bd61..534da666 100644 --- a/reportportal_client/client.py +++ b/reportportal_client/client.py @@ -14,13 +14,16 @@ # limitations under the License import logging +import sys import warnings from os import getenv +from typing import Union, Tuple, List, Dict, Any, Optional, TextIO 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, @@ -38,7 +41,7 @@ logger.addHandler(logging.NullHandler()) -class RPClient(object): +class RPClient: """Report portal client. The class is supposed to use by Report Portal agents: both custom and @@ -49,20 +52,48 @@ class RPClient(object): thread to avoid request/response messing and other issues. """ - def __init__(self, - endpoint, - project, - api_key=None, - log_batch_size=20, - is_skipped_an_issue=True, - verify_ssl=True, - retries=None, - max_pool_size=50, - launch_id=None, - http_timeout=(10, 10), - log_batch_payload_size=MAX_LOG_BATCH_PAYLOAD_SIZE, - mode='DEFAULT', - **kwargs): + _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: List[str] = ... + + def __init__( + self, + endpoint: str, + project: str, + api_key: str = None, + log_batch_size: int = 20, + is_skipped_an_issue: bool = True, + verify_ssl: bool = True, + retries: int = None, + max_pool_size: int = 50, + launch_id: 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, + **kwargs: Any + ) -> None: """Initialize required attributes. :param endpoint: Endpoint of the report portal service @@ -105,14 +136,16 @@ def __init__(self, self._item_stack = [] 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.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 ) @@ -120,10 +153,10 @@ def __init__(self, if not self.api_key: warnings.warn( - message="Argument `api_key` is `None` or empty string, " - "that's 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 ) @@ -131,7 +164,7 @@ def __init__(self, self.__init_session() self.__init_log_manager() - def __init_session(self): + def __init_session(self) -> None: retry_strategy = Retry( total=self.retries, backoff_factor=0.1, @@ -148,7 +181,7 @@ def __init_session(self): self.api_key) self.session = session - def __init_log_manager(self): + 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, @@ -156,10 +189,10 @@ def __init_log_manager(self): verify_ssl=self.verify_ssl) def finish_launch(self, - end_time, - status=None, - attributes=None, - **kwargs): + end_time: str, + status: str = None, + attributes: Optional[Union[List, Dict]] = None, + **kwargs: Any) -> Optional[str]: """Finish launch. :param end_time: Launch end time @@ -169,7 +202,7 @@ def finish_launch(self, :param attributes: Launch attributes """ if self.launch_id is NOT_FOUND or not self.launch_id: - logger.warning("Attempt to finish non-existent launch") + logger.warning('Attempt to finish non-existent launch') return url = uri_join(self.base_url_v2, 'launch', self.launch_id, 'finish') request_payload = LaunchFinishRequest( @@ -188,14 +221,14 @@ def finish_launch(self, return response.message def finish_test_item(self, - item_id, - end_time, - status=None, - issue=None, - attributes=None, - description=None, - retry=False, - **kwargs): + 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 suite/case/step/nested step item. :param item_id: ID of the test item @@ -212,7 +245,7 @@ def finish_test_item(self, "True" or "False" """ if item_id is NOT_FOUND or not item_id: - logger.warning("Attempt to finish non-existent item") + logger.warning('Attempt to finish non-existent item') return url = uri_join(self.base_url_v2, 'item', item_id) request_payload = ItemFinishRequest( @@ -234,7 +267,7 @@ def finish_test_item(self, logger.debug('response message: %s', response.message) return response.message - def get_item_id_by_uuid(self, uuid): + def get_item_id_by_uuid(self, uuid: str) -> Optional[str]: """Get test item ID by the given UUID. :param uuid: UUID returned on the item start @@ -245,7 +278,7 @@ def get_item_id_by_uuid(self, uuid): verify_ssl=self.verify_ssl).make() return response.id if response else None - def get_launch_info(self): + def get_launch_info(self) -> Optional[Dict]: """Get the current launch information. :return dict: Launch information in dictionary @@ -268,7 +301,7 @@ def get_launch_info(self): launch_info = {} return launch_info - def get_launch_ui_id(self): + def get_launch_ui_id(self) -> Optional[Dict]: """Get UI ID of the current launch. :return: UI ID of the given launch. None if UI ID has not been found. @@ -276,7 +309,7 @@ def get_launch_ui_id(self): launch_info = self.get_launch_info() return launch_info.get('id') if launch_info else None - def get_launch_ui_url(self): + def get_launch_ui_url(self) -> Optional[str]: """Get UI URL of the current launch. :return: launch URL or all launches URL. @@ -289,7 +322,7 @@ def get_launch_ui_url(self): if not mode: mode = self.mode - launch_type = "launches" if mode.upper() == 'DEFAULT' else 'userdebug' + 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, @@ -298,7 +331,7 @@ def get_launch_ui_url(self): logger.debug('get_launch_ui_url - ID: %s', self.launch_id) return url - def get_project_settings(self): + def get_project_settings(self) -> Optional[Dict]: """Get project settings. :return: HTTP response in dictionary @@ -308,7 +341,8 @@ def get_project_settings(self): verify_ssl=self.verify_ssl).make() return response.json if response else None - def log(self, time, message, level=None, attachment=None, item_id=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 Report Portal. :param time: Time in UTC @@ -319,18 +353,18 @@ def log(self, time, message, level=None, attachment=None, item_id=None): """ self._log_manager.log(time, message, level, attachment, item_id) - def start(self): + def start(self) -> None: """Start the client.""" self._log_manager.start() def start_launch(self, - name, - start_time, - description=None, - attributes=None, - rerun=False, - rerun_of=None, - **kwargs): + 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 @@ -346,12 +380,16 @@ def start_launch(self, # We are moving 'mode' param to the constructor, next code for the # transition period only. my_kwargs = dict(kwargs) - if 'mode' in my_kwargs.keys(): - mode = my_kwargs['mode'] + 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 - else: + if not mode: mode = self.mode request_payload = LaunchStartRequest( @@ -383,21 +421,23 @@ def start_launch(self, 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'Report Portal Launch UUID: {self.launch_id}', file=self.print_output) return self.launch_id def start_test_item(self, - name, - start_time, - item_type, - description=None, - attributes=None, - parameters=None, - parent_item_id=None, - has_stats=True, - code_ref=None, - retry=False, - test_case_id=None, - **kwargs): + 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 @@ -419,8 +459,7 @@ def start_test_item(self, :param test_case_id: A unique ID of the current step """ if parent_item_id is NOT_FOUND: - logger.warning("Attempt to start item for non-existent parent " - "item") + logger.warning('Attempt to start item for non-existent parent item.') return if parent_item_id: url = uri_join(self.base_url_v2, 'item', parent_item_id) @@ -455,11 +494,12 @@ def start_test_item(self, str(response.json)) return item_id - def terminate(self, *args, **kwargs): + def terminate(self, *_: Any, **__: Any) -> None: """Call this to terminate the client.""" self._log_manager.stop() - def update_test_item(self, item_uuid, attributes=None, description=None): + 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 Report Portal. :param str item_uuid: Test item UUID returned on the item start @@ -480,11 +520,11 @@ def update_test_item(self, item_uuid, attributes=None, description=None): logger.debug('update_test_item - Item: %s', item_id) return response.message - def current_item(self): + def current_item(self) -> Optional[str]: """Retrieve the last item reported by the client.""" return self._item_stack[-1] if len(self._item_stack) > 0 else None - def clone(self): + def clone(self) -> 'RPClient': """Clone the client object, set current Item ID as cloned item ID. :returns: Cloned client object @@ -509,7 +549,7 @@ def clone(self): cloned._item_stack.append(current_item) return cloned - def __getstate__(self): + def __getstate__(self) -> Dict[str, Any]: """Control object pickling and return object fields as Dictionary. :returns: object state dictionary @@ -522,7 +562,7 @@ def __getstate__(self): del state['_log_manager'] return state - def __setstate__(self, state): + def __setstate__(self, state: Dict[str, Any]) -> None: """Control object pickling, receives object state as Dictionary. :param dict state: object state dictionary diff --git a/reportportal_client/client.pyi b/reportportal_client/client.pyi deleted file mode 100644 index 1b06924d..00000000 --- a/reportportal_client/client.pyi +++ /dev/null @@ -1,116 +0,0 @@ -from typing import Any, Dict, List, Optional, Text, Tuple, Union - -from requests import Session - -from reportportal_client.core.rp_issues import Issue as Issue -from reportportal_client.logs.log_manager import LogManager as LogManager -from reportportal_client.steps import StepReporter - - -class RPClient: - _log_manager: LogManager = ... - api_v1: Text = ... - api_v2: Text = ... - base_url_v1: Text = ... - base_url_v2: Text = ... - endpoint: Text = ... - is_skipped_an_issue: bool = ... - launch_id: Text = ... - log_batch_size: int = ... - log_batch_payload_size: int = ... - project: Text = ... - api_key: Text = ... - verify_ssl: bool = ... - retries: int = ... - max_pool_size: int = ... - http_timeout: Union[float, Tuple[float, float]] = ... - session: Session = ... - step_reporter: StepReporter = ... - mode: str = ... - _skip_analytics: Text = ... - _item_stack: List[Text] = ... - - def __init__( - self, - endpoint: Text, - project: Text, - api_key: Text, - log_batch_size: int = ..., - is_skipped_an_issue: bool = ..., - verify_ssl: bool = ..., - retries: int = ..., - max_pool_size: int = ..., - launch_id: Text = ..., - http_timeout: Union[float, Tuple[float, float]] = ..., - log_batch_payload_size: int = ..., - mode: str = ... - ) -> None: ... - - def __init_session(self) -> None: ... - - def __init_log_manager(self) -> None: ... - - def finish_launch(self, - end_time: Text, - status: Text = ..., - attributes: Optional[Union[List, Dict]] = ..., - **kwargs: Any) -> Dict: ... - - def finish_test_item(self, - item_id: Text, - end_time: Text, - status: Text, - issue: Optional[Issue] = ..., - attributes: List = ..., - **kwargs: Any) -> Optional[Text]: ... - - def get_item_id_by_uuid(self, uuid: Text) -> Text: ... - - def get_launch_info(self) -> Dict: ... - - def get_launch_ui_id(self) -> Optional[Dict]: ... - - def get_launch_ui_url(self) -> Text: ... - - def get_project_settings(self) -> Dict: ... - - def log(self, - time: Text, - message: Text, - level: Optional[Union[int, Text]] = ..., - attachment: Optional[Dict] = ..., - item_id: Optional[Text] = ...) -> None: ... - - def start_launch(self, - name: Text, - start_time: Text, - description: Text = ..., - attributes: Optional[Union[List, Dict]] = ..., - mode: Text = ..., - rerun: bool = ..., - rerun_of: Text = ..., - **kwargs: Any) -> Text: ... - - def start_test_item(self, - name: Text, - start_time: Text, - item_type: Text, - description: Text = ..., - attributes: Optional[List[Dict]] = ..., - parameters: Dict = ..., - parent_item_id: Text = ..., - has_stats: bool = ..., - code_ref: Text = ..., - **kwargs: Any) -> Text: ... - - def terminate(self, *args: Tuple, **kwargs: Any) -> None: ... - - def update_test_item(self, item_uuid: Text, - attributes: Optional[Union[List, Dict]], - description: Optional[Text]) -> Text: ... - - def current_item(self) -> Text: ... - - def start(self) -> None: ... - - def clone(self) -> RPClient: ... diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..78b1a862 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +delayed-assert +pytest +pytest-cov diff --git a/setup.py b/setup.py index 8d1aaabd..ef554306 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages -__version__ = '5.3.5' +__version__ = '5.4.0' TYPE_STUBS = ['*.pyi'] diff --git a/tests/logs/test_rp_logger.py b/tests/logs/test_rp_logger.py index c9e29377..a893d820 100644 --- a/tests/logs/test_rp_logger.py +++ b/tests/logs/test_rp_logger.py @@ -78,4 +78,7 @@ def test_stacklevel_record_make(logger_handler): logger.error('test_log', exc_info=RuntimeError('test'), stack_info=inspect.stack(), stacklevel=2) record = verify_record(logger_handler) - assert record.stack_info.endswith('return func(*newargs, **newkeywargs)') + if sys.version_info < (3, 11): + assert record.stack_info.endswith('return func(*newargs, **newkeywargs)') + else: + assert record.stack_info.endswith("logger.error('test_log', exc_info=RuntimeError('test'),") diff --git a/tests/test_client.py b/tests/test_client.py index b63ad07b..b4cc4020 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -10,14 +10,15 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License +from io import StringIO import pytest from requests import Response from requests.exceptions import ReadTimeout from six.moves import mock -from reportportal_client.helpers import timestamp from reportportal_client import RPClient +from reportportal_client.helpers import timestamp def connection_error(*args, **kwargs): @@ -196,3 +197,45 @@ def test_empty_api_key_argument(warn): assert warn.call_count == 1 assert client.api_key == api_key + + +def test_launch_uuid_print(): + str_io = StringIO() + client = RPClient(endpoint='http://endpoint', project='project', + api_key='test', launch_uuid_print=True, print_output=str_io) + client.session = mock.Mock() + client._skip_analytics = True + client.start_launch('Test Launch', timestamp()) + assert 'Report Portal Launch UUID: ' in str_io.getvalue() + + +def test_no_launch_uuid_print(): + str_io = StringIO() + client = RPClient(endpoint='http://endpoint', project='project', + api_key='test', launch_uuid_print=False, print_output=str_io) + client.session = mock.Mock() + client._skip_analytics = True + client.start_launch('Test Launch', timestamp()) + assert 'Report Portal Launch UUID: ' not in str_io.getvalue() + + +@mock.patch('reportportal_client.client.sys.stdout', new_callable=StringIO) +def test_launch_uuid_print_default_io(mock_stdout): + client = RPClient(endpoint='http://endpoint', project='project', + api_key='test', launch_uuid_print=True) + client.session = mock.Mock() + client._skip_analytics = True + client.start_launch('Test Launch', timestamp()) + + assert 'Report Portal Launch UUID: ' in mock_stdout.getvalue() + + +@mock.patch('reportportal_client.client.sys.stdout', new_callable=StringIO) +def test_launch_uuid_print_default_print(mock_stdout): + client = RPClient(endpoint='http://endpoint', project='project', + api_key='test') + client.session = mock.Mock() + client._skip_analytics = True + client.start_launch('Test Launch', timestamp()) + + assert 'Report Portal Launch UUID: ' not in mock_stdout.getvalue() diff --git a/tox.ini b/tox.ini index efe13daa..d82c5674 100644 --- a/tox.ini +++ b/tox.ini @@ -2,19 +2,16 @@ isolated_build = True envlist = pep - py27 - py36 py37 py38 py39 py310 + py311 [testenv] deps = -rrequirements.txt - delayed-assert - pytest - pytest-cov + -rrequirements-dev.txt setenv = AGENT_NO_ANALYTICS = 1 @@ -26,20 +23,10 @@ skip_install = True deps = pre-commit>=1.11.0 commands = pre-commit run --all-files --show-diff-on-failure -[testenv:py27] -deps = - -rrequirements.txt - delayed-assert - mock - pytest - pytest-cov - [gh-actions] python = - 2.7: py27 - 3.5: py35 - 3.6: pep, py36 - 3.7: py37 + 3.7: pep, py37 3.8: py38 3.9: py39 3.10: py310 + 3.11: py311