diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 459ab20d..26a18ddc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,25 @@ -name: Upload Python Package +# Copyright 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. + +name: Release client on: push: - branches: ['master'] + branches: [ 'master' ] paths-ignore: - '.github/**' - CHANGELOG.md - - README.rst + - README.md - CONTRIBUTING.rst env: @@ -22,99 +35,95 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - - name: Generate versions - uses: HardNorth/github-version-generate@v1.2.0 - with: - version-source: file - version-file: ${{ env.VERSION_FILE }} - version-file-extraction-pattern: ${{ env.VERSION_EXTRACT_PATTERN }} - - - name: Setup git credentials - uses: oleksiyrudenko/gha-git-credentials@v2 - with: - name: 'reportportal.io' - email: 'support@reportportal.io' - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Tagging new version - id: newVersionTag - run: | - git tag -a ${{ env.RELEASE_VERSION }} -m "Release ${{ env.RELEASE_VERSION }}" - git push --tags - - - name: Set up Python - uses: actions/setup-python@v1 - with: - python-version: '3.6' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python setup.py sdist bdist_wheel - twine upload dist/* - - - name: Checkout develop branch - uses: actions/checkout@v2 - with: - ref: 'develop' - fetch-depth: 0 - - - name: Update CHANGELOG.md - id: changelogUpdate - run: | - sed '/\[Unreleased\]/q' ${{ env.CHANGE_LOG_FILE }} >> ${{ env.CHANGE_LOG_FILE }}${{ env.TMP_SUFFIX }} - sed -E '1,/#?#\s*\[Unreleased\]/d' ${{ env.CHANGE_LOG_FILE }} | sed -E '/#?#\s*\[/q' | \ - { echo -e '\n## [${{ env.RELEASE_VERSION }}]'; sed '$d'; } >> ${{ env.CHANGE_LOG_FILE }}${{ env.TMP_SUFFIX }} - grep -E '#?#\s*\[[0-9]' ${{ env.CHANGE_LOG_FILE }} | head -n1 >> ${{ env.CHANGE_LOG_FILE }}${{ env.TMP_SUFFIX }} - sed -E '1,/#?#\s*\[[0-9]/d' ${{ env.CHANGE_LOG_FILE }} >> ${{ env.CHANGE_LOG_FILE }}${{ env.TMP_SUFFIX }} - rm ${{ env.CHANGE_LOG_FILE }} - mv ${{ env.CHANGE_LOG_FILE }}${{ env.TMP_SUFFIX }} ${{ env.CHANGE_LOG_FILE }} - git add ${{ env.CHANGE_LOG_FILE }} - git commit -m "Changelog update" - - - name: Read changelog Entry - id: readChangelogEntry - uses: mindsers/changelog-reader-action@v1.3.1 - with: - version: ${{ env.RELEASE_VERSION }} - path: ./${{ env.CHANGE_LOG_FILE }} - - - name: Create Release - id: createRelease - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ env.RELEASE_VERSION }} - release_name: Release ${{ env.RELEASE_VERSION }} - body: ${{ steps.readChangelogEntry.outputs.log_entry }} - draft: false - prerelease: false - - - name: Merge release branch into develop - id: mergeIntoDevelop - run: | - git merge -m 'Merge master branch into develop after a release' origin/master - git status | (! grep -Fq 'both modified:') || git status | grep -F 'both modified:' \ - | { echo -e 'Unable to merge master into develop, merge conflicts:'; (! grep -Eo '[^ ]+$') } - - - name: Update version file - id: versionFileUpdate - run: | - export CURRENT_VERSION_VALUE=`echo '${{ env.CURRENT_VERSION }}' | sed -E "s/(.*)/${{ env.VERSION_REPLACE_PATTERN }}/"` - export NEXT_VERSION_VALUE=`echo '${{ env.NEXT_VERSION }}' | sed -E "s/(.*)/${{ env.VERSION_REPLACE_PATTERN }}/"` - sed "s/${CURRENT_VERSION_VALUE}/${NEXT_VERSION_VALUE}/g" ${{ env.VERSION_FILE }} > ${{ env.VERSION_FILE }}${{ env.TMP_SUFFIX }} - rm ${{ env.VERSION_FILE }} - mv ${{ env.VERSION_FILE }}${{ env.TMP_SUFFIX }} ${{ env.VERSION_FILE }} - git add ${{ env.VERSION_FILE }} - git commit -m "Version update" - git push + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.8' + + - name: Install dependencies + run: python -m pip install --upgrade pip setuptools wheel + + - name: Build package + run: python setup.py sdist bdist_wheel + + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: ${{ secrets.PYPI_USERNAME }} + password: ${{ secrets.PYPI_PASSWORD }} + + - name: Generate versions + uses: HardNorth/github-version-generate@v1 + with: + version-source: file + version-file: ${{ env.VERSION_FILE }} + version-file-extraction-pattern: ${{ env.VERSION_EXTRACT_PATTERN }} + + - name: Setup git credentials + uses: oleksiyrudenko/gha-git-credentials@v2.1.1 + with: + name: 'reportportal.io' + email: 'support@reportportal.io' + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Tagging new version + id: newVersionTag + run: | + git tag -a ${{ env.RELEASE_VERSION }} -m "Release ${{ env.RELEASE_VERSION }}" + git push --tags + + - name: Update CHANGELOG.md + id: changelogUpdate + run: | + sed '/\[Unreleased\]/q' ${{ env.CHANGE_LOG_FILE }} >> ${{ env.CHANGE_LOG_FILE }}${{ env.TMP_SUFFIX }} + sed -E '1,/#?#\s*\[Unreleased\]/d' ${{ env.CHANGE_LOG_FILE }} | sed -E '/#?#\s*\[/q' | \ + { echo -e '\n## [${{ env.RELEASE_VERSION }}]'; sed '$d'; } >> ${{ env.CHANGE_LOG_FILE }}${{ env.TMP_SUFFIX }} + grep -E '#?#\s*\[[0-9]' ${{ env.CHANGE_LOG_FILE }} | head -n1 >> ${{ env.CHANGE_LOG_FILE }}${{ env.TMP_SUFFIX }} + sed -E '1,/#?#\s*\[[0-9]/d' ${{ env.CHANGE_LOG_FILE }} >> ${{ env.CHANGE_LOG_FILE }}${{ env.TMP_SUFFIX }} + rm ${{ env.CHANGE_LOG_FILE }} + mv ${{ env.CHANGE_LOG_FILE }}${{ env.TMP_SUFFIX }} ${{ env.CHANGE_LOG_FILE }} + git add ${{ env.CHANGE_LOG_FILE }} + git commit -m "Changelog update" + + - name: Read changelog Entry + id: readChangelogEntry + uses: mindsers/changelog-reader-action@v2 + with: + version: ${{ env.RELEASE_VERSION }} + path: ./${{ env.CHANGE_LOG_FILE }} + + - name: Create Release + id: createRelease + uses: ncipollo/release-action@v1 + with: + tag: ${{ env.RELEASE_VERSION }} + name: Release ${{ env.RELEASE_VERSION }} + body: ${{ steps.readChangelogEntry.outputs.changes }} + + - name: Checkout develop branch + uses: actions/checkout@v3 + with: + ref: 'develop' + fetch-depth: 0 + + - name: Merge release branch into develop + id: mergeIntoDevelop + run: | + git merge -m 'Merge master branch into develop after a release' origin/master + git status | (! grep -Fq 'both modified:') || git status | grep -F 'both modified:' \ + | { echo -e 'Unable to merge master into develop, merge conflicts:'; (! grep -Eo '[^ ]+$') } + + - name: Update version file + id: versionFileUpdate + run: | + export CURRENT_VERSION_VALUE=`echo '${{ env.CURRENT_VERSION }}' | sed -E "s/(.*)/${{ env.VERSION_REPLACE_PATTERN }}/"` + export NEXT_VERSION_VALUE=`echo '${{ env.NEXT_VERSION }}' | sed -E "s/(.*)/${{ env.VERSION_REPLACE_PATTERN }}/"` + sed "s/${CURRENT_VERSION_VALUE}/${NEXT_VERSION_VALUE}/g" ${{ env.VERSION_FILE }} > ${{ env.VERSION_FILE }}${{ env.TMP_SUFFIX }} + rm ${{ env.VERSION_FILE }} + mv ${{ env.VERSION_FILE }}${{ env.TMP_SUFFIX }} ${{ env.VERSION_FILE }} + git add ${{ env.VERSION_FILE }} + git commit -m 'Version update' + git push diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0c7271dd..6c2853f2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,38 +1,61 @@ +# Copyright 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. + name: Tests on: push: branches: - - '*' - - '!master' + - '*' + - '!master' + + paths-ignore: + - README.md + - README_TEMPLATE.md + - CHANGELOG.md + pull_request: branches: - - 'master' - - 'develop' + - 'master' + - 'develop' jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 strategy: matrix: - python-version: ['2.7', '3.6', '3.7', '3.8', '3.9', '3.10'] + python-version: [ '2.7', '3.6', '3.7', '3.8', '3.9', '3.10' ] steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install tox tox-gh-actions - - name: Test with tox - run: tox - - name: Upload coverage to Codecov - if: matrix.python-version == 3.6 && success() - uses: codecov/codecov-action@v1 - with: - files: coverage.xml - flags: unittests - name: codecov-client-reportportal - path_to_write_report: codecov_report.txt + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + + - name: Test with tox + run: tox + + - name: Upload coverage to Codecov + if: matrix.python-version == 3.6 && success() + uses: codecov/codecov-action@v3 + with: + files: coverage.xml + flags: unittests + name: codecov-client-reportportal diff --git a/.gitignore b/.gitignore index 3f352558..64284216 100644 --- a/.gitignore +++ b/.gitignore @@ -72,8 +72,8 @@ target/ .python-version # virtualenv -venv/ -.venv/ +venv*/ +?venv*/ # pycharm .idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d234dfb0..d7a3b1b3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: rev: v1.0.1 hooks: - id: rst-linter -- repo: https://gitlab.com/pycqa/flake8.git - rev: 3.9.0 +- repo: https://github.com/pycqa/flake8 + rev: 5.0.4 hooks: - id: flake8 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1256f687..2f2b1d7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,18 @@ ## [Unreleased] ### Fixed +- Issue [#198](https://github.com/reportportal/client-Python/issues/198): Python 3.8+ logging issue, by @HardNorth +- Issue [#200](https://github.com/reportportal/client-Python/issues/200): max_pool_size not worked without retries setting, by @ericscobell +- Issue [#202](https://github.com/reportportal/client-Python/issues/202): TypeError on request make, by @HardNorth +### Changed +- Statistics service rewrite, by @HardNorth +### Removed +- Deprecated code, `service.py` and `LogManager` in `core` package, by @HardNorth + +## [5.2.5] +### Fixed - Issue [#194](https://github.com/reportportal/client-Python/issues/194): logging URL generation, by @HardNorth -- Issue [#195](https://github.com/reportportal/client-Python/issues/195): `None` mode exception +- Issue [#195](https://github.com/reportportal/client-Python/issues/195): `None` mode exception, by @HardNorth ## [5.2.4] ### Changed diff --git a/README.md b/README.md index a4f638b7..82ed92bd 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Python versions](https://img.shields.io/pypi/pyversions/reportportal-client.svg)](https://pypi.org/project/reportportal-client) [![Build Status](https://github.com/reportportal/client-Python/actions/workflows/tests.yml/badge.svg)](https://github.com/reportportal/client-Python/actions/workflows/tests.yml) [![codecov.io](https://codecov.io/gh/reportportal/client-Python/branch/master/graph/badge.svg)](https://codecov.io/gh/reportportal/client-Python) -[![Join Slack chat!](https://reportportal-slack-auto.herokuapp.com/badge.svg)](https://reportportal-slack-auto.herokuapp.com) +[![Join Slack chat!](https://slack.epmrpp.reportportal.io/badge.svg)](https://slack.epmrpp.reportportal.io/) [![stackoverflow](https://img.shields.io/badge/reportportal-stackoverflow-orange.svg?style=flat)](http://stackoverflow.com/questions/tagged/reportportal) [![Build with Love](https://img.shields.io/badge/build%20with-❤%EF%B8%8F%E2%80%8D-lightgrey.svg)](http://reportportal.io?style=flat) diff --git a/reportportal_client/__init__.py b/reportportal_client/__init__.py index ff19c8d0..bde50a59 100644 --- a/reportportal_client/__init__.py +++ b/reportportal_client/__init__.py @@ -16,13 +16,13 @@ from ._local import current from .logs import RPLogger, RPLogHandler -from .service import ReportPortalService +from .client import RPClient from .steps import step __all__ = [ 'current', 'RPLogger', 'RPLogHandler', - 'ReportPortalService', + 'RPClient', 'step', ] diff --git a/reportportal_client/client.py b/reportportal_client/client.py index 34af8448..e53d5aaa 100644 --- a/reportportal_client/client.py +++ b/reportportal_client/client.py @@ -1,22 +1,23 @@ -"""This module contains Report Portal Client class. +"""This module contains Report Portal 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. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License -Copyright (c) 2022 https://reportportal.io . - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -https://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" import logging +from os import getenv + import requests -from requests.adapters import HTTPAdapter, Retry +from requests.adapters import HTTPAdapter, Retry, DEFAULT_RETRIES from ._local import set_current from .core.rp_requests import ( @@ -28,6 +29,7 @@ ) 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 @@ -101,16 +103,18 @@ def __init__(self, self.step_reporter = StepReporter(self) self._item_stack = [] self.mode = mode - if retries: - retry_strategy = Retry( - total=retries, - backoff_factor=0.1, - status_forcelist=[429, 500, 502, 503, 504] - ) - self.session.mount('https://', HTTPAdapter( - max_retries=retry_strategy, pool_maxsize=max_pool_size)) - self.session.mount('http://', HTTPAdapter( - max_retries=retry_strategy, pool_maxsize=max_pool_size)) + self._skip_analytics = getenv('AGENT_NO_ANALYTICS') + + retry_strategy = Retry( + total=retries, + backoff_factor=0.1, + status_forcelist=[429, 500, 502, 503, 504] + ) if retries else DEFAULT_RETRIES + self.session.mount('https://', HTTPAdapter( + max_retries=retry_strategy, pool_maxsize=max_pool_size)) + # noinspection HttpUrlsUsage + self.session.mount('http://', HTTPAdapter( + max_retries=retry_strategy, pool_maxsize=max_pool_size)) self.session.headers['Authorization'] = 'bearer {0}'.format(self.token) self._log_manager = LogManager( @@ -301,9 +305,9 @@ def start_launch(self, :param start_time: Launch start time :param description: Launch description :param attributes: Launch attributes - :param rerun: Enables launch rerun mode - :param rerun_of: Rerun mode. Specifies launch to be re-runned. - Should be used with the 'rerun' option. + :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') @@ -334,6 +338,17 @@ def start_launch(self, 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) + self._log_manager.launch_id = self.launch_id = response.id logger.debug('start_launch - ID: %s', self.launch_id) return self.launch_id diff --git a/reportportal_client/client.pyi b/reportportal_client/client.pyi index b7711e1f..95d1f074 100644 --- a/reportportal_client/client.pyi +++ b/reportportal_client/client.pyi @@ -2,8 +2,8 @@ from typing import Any, Dict, List, Optional, Text, Tuple, Union from requests import Session -from reportportal_client.logs.log_manager import LogManager as LogManager 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 @@ -28,6 +28,7 @@ class RPClient: session: Session = ... step_reporter: StepReporter = ... mode: str = ... + _skip_analytics: Text = ... def __init__( self, @@ -103,3 +104,5 @@ class RPClient: description: Optional[Text]) -> Text: ... def current_item(self) -> Text: ... + + def start(self) -> None : ... diff --git a/reportportal_client/core/__init__.py b/reportportal_client/core/__init__.py index 6df40acc..78e27ba6 100644 --- a/reportportal_client/core/__init__.py +++ b/reportportal_client/core/__init__.py @@ -12,9 +12,3 @@ # limitations under the License """This package contains core reportportal-client modules.""" - -from reportportal_client.logs import log_manager - -__all__ = [ - 'log_manager' -] diff --git a/reportportal_client/core/log_manager.py b/reportportal_client/core/log_manager.py deleted file mode 100644 index cbc82fa4..00000000 --- a/reportportal_client/core/log_manager.py +++ /dev/null @@ -1,36 +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 - -"""Deprecated module stub to avoid execution crashes. - -.. deprecated:: 5.2.4 - Use `logs.log_manager` instead. -""" - -import warnings - -from reportportal_client.logs.log_manager import LogManager, \ - MAX_LOG_BATCH_PAYLOAD_SIZE - -warnings.warn( - message="`core.log_manager` is deprecated since 5.2.4 and will be subject " - "for removing in the next major version. Use logs.log_manager` " - "instead", - category=DeprecationWarning, - stacklevel=2 -) - -__all__ = [ - 'LogManager', - 'MAX_LOG_BATCH_PAYLOAD_SIZE' -] diff --git a/reportportal_client/core/rp_requests.py b/reportportal_client/core/rp_requests.py index 8585b252..9f2b6b18 100644 --- a/reportportal_client/core/rp_requests.py +++ b/reportportal_client/core/rp_requests.py @@ -76,7 +76,7 @@ def make(self): timeout=self.http_timeout) ) # https://github.com/reportportal/client-Python/issues/39 - except (KeyError, IOError, ValueError) as exc: + except (KeyError, IOError, ValueError, TypeError) as exc: logger.warning( "Report Portal %s request failed", self.name, diff --git a/reportportal_client/core/rp_responses.py b/reportportal_client/core/rp_responses.py index a9bfe039..910e3b11 100644 --- a/reportportal_client/core/rp_responses.py +++ b/reportportal_client/core/rp_responses.py @@ -71,15 +71,7 @@ def _get_json(data): :param data: requests.Response object :return: dict """ - if not data.text: - return {} - try: - return data.json() - except ValueError as error: - logger.warning('Invalid response: {0}: {1}' - .format(error, data.text), - exc_info=error) - return {} + return data.json() @property def id(self): diff --git a/reportportal_client/external/__init__.py b/reportportal_client/external/__init__.py deleted file mode 100644 index bf7e85df..00000000 --- a/reportportal_client/external/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""This package contains handles connections with external services. - -Copyright (c) 2020 http://reportportal.io . - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" diff --git a/reportportal_client/external/constants.py b/reportportal_client/external/constants.py deleted file mode 100644 index cd338618..00000000 --- a/reportportal_client/external/constants.py +++ /dev/null @@ -1,33 +0,0 @@ -"""This module contains constants for the external services. - -Copyright (c) 2020 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. -""" - -import base64 - - -def _decode_string(text): - """Decode value of the given string. - - :param text: Encoded string - :return: Decoded value - """ - base64_bytes = text.encode('ascii') - message_bytes = base64.b64decode(base64_bytes) - return message_bytes.decode('ascii') - - -GA_INSTANCE = _decode_string('VUEtMTczNDU2ODA5LTE=') -GA_ENDPOINT = 'https://www.google-analytics.com/collect' diff --git a/reportportal_client/external/constants.pyi b/reportportal_client/external/constants.pyi deleted file mode 100644 index 8e212e7e..00000000 --- a/reportportal_client/external/constants.pyi +++ /dev/null @@ -1,6 +0,0 @@ -from typing import Text - -def _decode_string(text: Text) -> Text: ... - -GA_INSTANCE: Text -GA_ENDPOINT: Text diff --git a/reportportal_client/external/google_analytics.py b/reportportal_client/external/google_analytics.py deleted file mode 100644 index 2344dcc5..00000000 --- a/reportportal_client/external/google_analytics.py +++ /dev/null @@ -1,72 +0,0 @@ -"""This modules contains interfaces for communications with GA. - -Copyright (c) 2020 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. -""" - -import logging -from platform import python_version -from pkg_resources import get_distribution -import requests -from uuid import uuid4 - -from .constants import GA_INSTANCE, GA_ENDPOINT - -logger = logging.getLogger(__name__) - - -def _get_client_info(): - """Get name of the client and its version. - - :return: ('reportportal-client', '5.0.4') - """ - client = get_distribution('reportportal-client') - return client.project_name, client.version - - -def _get_platform_info(): - """Get current platform basic info, e.g.: 'Python 3.6.1'. - - :return: str represents the current platform, e.g.: 'Python 3.6.1' - """ - return 'Python ' + python_version() - - -def send_event(agent_name, agent_version): - """Send an event to GA about client and agent versions with their names. - - :param agent_name: Name of the agent that uses the client - :param agent_version: Version of the agent - """ - client_name, client_version = _get_client_info() - payload = { - 'v': '1', - 'tid': GA_INSTANCE, - 'aip': '1', - 'cid': str(uuid4()), - 't': 'event', - 'ec': 'Client name "{}", version "{}", interpreter "{}"'.format( - client_name, client_version, _get_platform_info() - ), - 'ea': 'Start launch', - 'el': 'Agent name "{}", version "{}"'.format( - agent_name, agent_version - ) - } - headers = {'User-Agent': 'Universal Analytics'} - try: - return requests.post(url=GA_ENDPOINT, data=payload, headers=headers) - except requests.exceptions.RequestException as err: - logger.debug('Failed to send data to Google Analytics: %s', - str(err)) diff --git a/reportportal_client/external/google_analytics.pyi b/reportportal_client/external/google_analytics.pyi deleted file mode 100644 index abbb07fc..00000000 --- a/reportportal_client/external/google_analytics.pyi +++ /dev/null @@ -1,12 +0,0 @@ -from logging import Logger -import requests -from .constants import GA_ENDPOINT as GA_ENDPOINT, GA_INSTANCE as GA_INSTANCE -from typing import Text - -logger: Logger - -def _get_client_info() -> tuple: ... - -def _get_platform_info() -> Text: ... - -def send_event(agent_name: Text, agent_version: Text) -> requests.Response: ... diff --git a/reportportal_client/helpers.py b/reportportal_client/helpers.py index 2064e9d2..51a0f8b8 100644 --- a/reportportal_client/helpers.py +++ b/reportportal_client/helpers.py @@ -16,7 +16,6 @@ import logging import time import uuid -import warnings from platform import machine, processor, system import six @@ -169,12 +168,11 @@ def get_function_params(func, args, kwargs): :param kwargs: function's kwargs :return: a dictionary of values """ - # Use deprecated method for python 2.7 compatibility, it's still here for - # Python 3.10.2, so it's completely redundant to show the warning - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', category=DeprecationWarning) - # noinspection PyDeprecation + if six.PY2: + # Use deprecated method for python 2.7 compatibility arg_spec = inspect.getargspec(func) + else: + arg_spec = inspect.getfullargspec(func) result = dict() for i, arg_name in enumerate(arg_spec.args): if i >= len(args): diff --git a/reportportal_client/items/rp_base_item.py b/reportportal_client/items/rp_base_item.py index 12674023..6979e2a4 100644 --- a/reportportal_client/items/rp_base_item.py +++ b/reportportal_client/items/rp_base_item.py @@ -1,24 +1,19 @@ -""" -This module contains functional for Base RP items management. - -Copyright (c) 2018 http://reportportal.io . - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" +"""This module contains functional for Base RP items management.""" + +# Copyright (c) 2023 EPAM Systems +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License from reportportal_client.core.rp_requests import HttpRequest -from reportportal_client.core.rp_responses import RPResponse -from reportportal_client.static.defines import NOT_FOUND class BaseRPItem(object): @@ -52,34 +47,6 @@ def http_request(self): """ return self.http_requests[-1] if self.http_requests else None - @property - def response(self): - """Get last http response. - - :return: Response data object - """ - return self.responses[-1] if self.responses else None - - @response.setter - def response(self, data): - """Set the response object for the test item. - - :param data: Response data object - """ - response = RPResponse(data) - self.responses.append(response) - self.uuid = response.id if (response.id is - not NOT_FOUND) else self.uuid - - @property - def unhandled_requests(self): - """Get list of requests that were not handled. - - :return: list of HttpRequest objects - """ - return [request for request in self.http_requests - if not request.response] - def add_request(self, endpoint, method, request_class, *args, **kwargs): """Add new request object. diff --git a/reportportal_client/logs/__init__.py b/reportportal_client/logs/__init__.py index b541bdab..71215a6e 100644 --- a/reportportal_client/logs/__init__.py +++ b/reportportal_client/logs/__init__.py @@ -18,6 +18,7 @@ from six import PY2 from six.moves.urllib.parse import urlparse +# noinspection PyProtectedMember from reportportal_client._local import current from reportportal_client.helpers import timestamp @@ -34,8 +35,8 @@ def __init__(self, name, level=0): """ super(RPLogger, self).__init__(name, level=level) - def _log(self, level, msg, args, - exc_info=None, extra=None, stack_info=False, attachment=None): + def _log(self, level, msg, args, exc_info=None, extra=None, + stack_info=False, attachment=None, **kwargs): """ Low-level logging routine which creates a LogRecord and then calls. @@ -59,7 +60,11 @@ def _log(self, level, msg, args, # and returns 3 elements fn, lno, func = self.findCaller() else: - fn, lno, func, sinfo = self.findCaller(stack_info) + if 'stacklevel' in kwargs: + fn, lno, func, sinfo = \ + self.findCaller(stack_info, kwargs['stacklevel']) + else: + fn, lno, func, sinfo = self.findCaller(stack_info) except ValueError: # pragma: no cover fn, lno, func = '(unknown file)', 0, '(unknown function)' @@ -133,10 +138,25 @@ def filter(self, record): # Filter the reportportal_client requests instance # urllib3 usage hostname = urlparse(self.endpoint).hostname - if hostname in self.format(record): - return False + if hostname: + if hasattr(hostname, 'decode') and callable(hostname.decode): + if hostname.decode('utf-8') in self.format(record): + return False + else: + if str(hostname) in self.format(record): + return False return True + def _get_rp_log_level(self, levelno): + return next( + ( + self._loglevel_map[level] + for level in self._sorted_levelnos + if levelno >= level + ), + self._loglevel_map[logging.NOTSET], + ) + def emit(self, record): """ Emit function. @@ -145,6 +165,7 @@ def emit(self, record): """ msg = '' + # noinspection PyBroadException try: msg = self.format(record) except (KeyboardInterrupt, SystemExit): @@ -152,18 +173,17 @@ def emit(self, record): except Exception: self.handleError(record) - for level in self._sorted_levelnos: - if level <= record.levelno: - if self.rp_client: - rp_client = self.rp_client - else: - rp_client = current() - if rp_client: - rp_client.log( - timestamp(), - msg, - level=self._loglevel_map[level], - attachment=record.__dict__.get('attachment', None), - item_id=rp_client.current_item() - ) - return + log_level = self._get_rp_log_level(record.levelno) + if self.rp_client: + rp_client = self.rp_client + else: + rp_client = current() + if rp_client: + rp_client.log( + timestamp(), + msg, + level=log_level, + attachment=record.__dict__.get('attachment', None), + item_id=rp_client.current_item() + ) + return diff --git a/reportportal_client/service.py b/reportportal_client/service.py deleted file mode 100644 index 9f374e07..00000000 --- a/reportportal_client/service.py +++ /dev/null @@ -1,575 +0,0 @@ -""" -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. -""" - -import json -import logging -import uuid -from time import sleep - -import requests -import six -from requests.adapters import HTTPAdapter -from six.moves.collections_abc import Mapping - -from .errors import ResponseError, EntryCreatedError, OperationCompletionError -from .helpers import verify_value_length - -POST_LOGBATCH_RETRY_COUNT = 10 -logger = logging.getLogger(__name__) -logger.addHandler(logging.NullHandler()) - - -def _convert_string(value): - """Support and convert strings in py2 and py3. - - :param value: input string - :return value: convert string - """ - if isinstance(value, six.text_type): - # Don't try to encode 'unicode' in Python 2. - return value - return str(value) - - -def _dict_to_payload(dictionary): - """Convert dict to list of dicts. - - :param dictionary: initial dict - :return list: list of dicts - """ - system = dictionary.pop("system", False) - return [ - {"key": key, "value": _convert_string(value), "system": system} - for key, value in sorted(dictionary.items()) - ] - - -def _get_id(response): - """Get id from Response. - - :param response: Response object - :return id: int value of id - """ - try: - return _get_data(response)["id"] - except KeyError: - raise EntryCreatedError( - "No 'id' in response: {0}".format(response.text)) - - -def _get_msg(response): - """ - Get message from Response. - - :param response: Response object - :return: data: json data - """ - try: - return _get_data(response) - except KeyError: - raise OperationCompletionError( - "No 'message' in response: {0}".format(response.text)) - - -def _get_data(response): - """ - Get data from Response. - - :param response: Response object - :return: json data - """ - data = _get_json(response) - error_messages = _get_messages(data) - error_count = len(error_messages) - - if error_count == 1: - raise ResponseError(error_messages[0]) - elif error_count > 1: - raise ResponseError( - "\n - ".join(["Multiple errors:"] + error_messages)) - elif not response.ok: - response.raise_for_status() - elif not data: - raise ResponseError("Empty response") - else: - return data - - -def _get_json(response): - """ - Get json from Response. - - :param response: Response object - :return: data: json object - """ - try: - if response.text: - return response.json() - else: - return {} - except ValueError as value_error: - raise ResponseError( - "Invalid response: {0}: {1}".format(value_error, response.text)) - - -def _get_messages(data): - """ - Get messages (ErrorCode) from Response. - - :param data: dict of datas - :return list: Empty list or list of errors - """ - error_messages = [] - for ret in data.get("responses", [data]): - if "errorCode" in ret: - error_messages.append( - "{0}: {1}".format(ret["errorCode"], ret.get("message")) - ) - - return error_messages - - -def uri_join(*uri_parts): - """Join uri parts. - - Avoiding usage of urlparse.urljoin and os.path.join - as it does not clearly join parts. - Args: - *uri_parts: tuple of values for join, can contain back and forward - slashes (will be stripped up). - Returns: - An uri string. - """ - return '/'.join(str(s).strip('/').strip('\\') for s in uri_parts) - - -class ReportPortalService(object): - """Service class with report portal event callbacks.""" - - def __init__(self, - endpoint, - project, - token, - log_batch_size=20, - is_skipped_an_issue=True, - verify_ssl=True, - retries=None, - max_pool_size=50, - http_timeout=(10, 10), - **kwargs): - """Init the service class. - - Args: - endpoint: endpoint of report portal service. - project: project name to use for launch names. - token: authorization token. - log_batch_size: option to set the maximum number of logs - that can be processed in one batch - is_skipped_an_issue: option to mark skipped tests as not - 'To Investigate' items on Server side. - verify_ssl: option to not verify ssl certificates - max_pool_size: option to set the maximum number of - connections to save in the pool. - http_timeout: a float in seconds for connect and read - timeout. Use a Tuple to specific connect and - read separately. - """ - self._batch_logs = [] - self.endpoint = endpoint - self.log_batch_size = log_batch_size - self.project = project - self.token = token - self.is_skipped_an_issue = is_skipped_an_issue - self.base_url_v1 = uri_join(self.endpoint, "api/v1", self.project) - self.base_url_v2 = uri_join(self.endpoint, "api/v2", self.project) - self.http_timeout = http_timeout - - self.session = requests.Session() - if retries: - self.session.mount('https://', HTTPAdapter( - max_retries=retries, pool_maxsize=max_pool_size)) - self.session.mount('http://', HTTPAdapter( - max_retries=retries, pool_maxsize=max_pool_size)) - self.session.headers["Authorization"] = "Bearer {0}".format(self.token) - self.launch_id = kwargs.get('launch_id') - self.verify_ssl = verify_ssl - - def terminate(self, *args, **kwargs): - """Call this to terminate the service.""" - if self._batch_logs: - self._log_batch(None, force=True) - - def start_launch(self, - name, - start_time, - description=None, - attributes=None, - mode=None, - rerun=False, - rerunOf=None, - **kwargs): - """Start a new launch with the given parameters.""" - if attributes and isinstance(attributes, dict): - attributes = _dict_to_payload(attributes) - data = { - "name": name, - "description": description, - "attributes": verify_value_length(attributes), - "startTime": start_time, - "mode": mode, - "rerun": rerun, - "rerunOf": rerunOf - } - url = uri_join(self.base_url_v2, "launch") - try: - r = self.session.post(url, json=data, verify=self.verify_ssl, - timeout=self.http_timeout) - except (ValueError, KeyError, IOError) as exc: - logger.warning("Report Portal Start Launch request failed", - exc_info=exc) - return - self.launch_id = _get_id(r) - logger.debug("start_launch - ID: %s", self.launch_id) - return self.launch_id - - def finish_launch(self, end_time, status=None, attributes=None, **kwargs): - """Finish a launch with the given parameters. - - Status can be one of the followings: - (PASSED, FAILED, STOPPED, SKIPPED, RESETED, CANCELLED) - """ - # process log batches firstly. Remove this step when all the agents - # start using terminate() method. - if self._batch_logs: - self._log_batch(None, force=True) - if attributes and isinstance(attributes, dict): - attributes = _dict_to_payload(attributes) - data = { - "endTime": end_time, - "status": status, - "attributes": verify_value_length(attributes) - } - url = uri_join(self.base_url_v2, "launch", self.launch_id, "finish") - try: - r = self.session.put(url, json=data, verify=self.verify_ssl, - timeout=self.http_timeout) - except (ValueError, KeyError, IOError) as exc: - logger.warning("Report Portal Finish Launch request failed", - exc_info=exc) - return - logger.debug("finish_launch - ID: %s", self.launch_id) - return _get_msg(r) - - def get_launch_info(self, max_retries=5): - """Get the current launch information. - - Perform "max_retries" attempts to get current launch information - with 0.5 second sleep between them. - :param int max_retries: Number of retries to get launch information. - :return dict: launch information - """ - if self.launch_id is None: - return {} - - url = uri_join(self.base_url_v1, "launch/uuid", self.launch_id) - - for _ in range(max_retries): - logger.debug("get_launch_info - ID: %s", self.launch_id) - try: - resp = self.session.get(url, verify=self.verify_ssl, - timeout=self.http_timeout) - except (ValueError, KeyError, IOError) as exc: - logger.warning("Report Portal Launch Info request failed", - exc_info=exc) - continue - - if resp.status_code == 200: - launch_info = _get_json(resp) - logger.debug("get_launch_info - Launch info: %s", launch_info) - break - - logger.debug("get_launch_info - Launch info: Response code %s\n%s", - resp.status_code, resp.text) - sleep(0.5) - 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, max_retries=5): - """Get UI ID of the current launch. - - :return str: UI ID of the given launch. - None if UI ID has not been found. - """ - return self.get_launch_info(max_retries=max_retries).get("id") - - def get_launch_ui_url(self, max_retries=5): - """Get UI URL of the current launch. - - If UI ID can`t be found after max_retries, return URL of all launches. - :return str: launch URL or all launches URL. - """ - ui_id = self.get_launch_ui_id(max_retries=max_retries) or "" - path = "ui/#{0}/launches/all/{1}".format(self.project, ui_id) - url = uri_join(self.endpoint, path) - logger.debug("get_launch_ui_url - ID: %s", self.launch_id) - return url - - 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, - test_case_id=None, - **kwargs): - """ - 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: - { - "": "", - "": "", - ... - } - """ - if attributes and isinstance(attributes, dict): - attributes = _dict_to_payload(attributes) - if parameters: - parameters = _dict_to_payload(parameters) - - data = { - "name": name, - "description": description, - "attributes": verify_value_length(attributes), - "startTime": start_time, - "launchUuid": self.launch_id, - "type": item_type, - "parameters": parameters, - "hasStats": has_stats, - "codeRef": code_ref, - "testCaseId": test_case_id, - "retry": kwargs.get('retry', False) - } - if parent_item_id: - url = uri_join(self.base_url_v2, "item", parent_item_id) - else: - url = uri_join(self.base_url_v2, "item") - try: - r = self.session.post(url, json=data, verify=self.verify_ssl, - timeout=self.http_timeout) - except (ValueError, KeyError, IOError) as exc: - logger.warning("Report Portal Start Item request failed", - exc_info=exc) - return - - item_id = _get_id(r) - logger.debug("start_test_item - ID: %s", item_id) - return item_id - - def update_test_item(self, item_uuid, attributes=None, description=None): - """Update existing test item at the Report Portal. - - :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'}, ...] - """ - 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") - try: - r = self.session.put(url, json=data, verify=self.verify_ssl, - timeout=self.http_timeout) - except (ValueError, KeyError, IOError) as exc: - logger.warning("Report Portal Update Item request failed", - exc_info=exc) - return - logger.debug("update_test_item - Item: %s", item_id) - return _get_msg(r) - - def finish_test_item(self, - item_id, - end_time, - status, - issue=None, - attributes=None, - **kwargs): - """Finish the test item and return HTTP response. - - :param item_id: id of the test item - :param end_time: time in UTC format - :param status: status of the test - :param issue: description of an issue - :param attributes: list of attributes - :param kwargs: other parameters - :return: json message - """ - # check if skipped test should not be marked as "TO INVESTIGATE" - if issue is None and status == "SKIPPED" \ - and not self.is_skipped_an_issue: - issue = {"issue_type": "NOT_ISSUE"} - - if attributes and isinstance(attributes, dict): - attributes = _dict_to_payload(attributes) - - data = { - "endTime": end_time, - "status": status, - "issue": issue, - "launchUuid": self.launch_id, - "attributes": verify_value_length(attributes) - } - url = uri_join(self.base_url_v2, "item", item_id) - try: - r = self.session.put(url, json=data, verify=self.verify_ssl) - except (ValueError, KeyError, IOError) as exc: - logger.warning("Report Portal Finish Item request failed", - exc_info=exc) - return - logger.debug("finish_test_item - ID: %s", item_id) - return _get_msg(r) - - def get_item_id_by_uuid(self, uuid): - """Get test item ID by the given UUID. - - :param str uuid: UUID returned on the item start - :return str: Test item id - """ - url = uri_join(self.base_url_v1, "item", "uuid", uuid) - try: - r = self.session.get(url, verify=self.verify_ssl, - timeout=self.http_timeout) - except (ValueError, KeyError, IOError) as exc: - logger.warning("Report Portal Item Details request failed", - exc_info=exc) - return - return _get_json(r)["id"] - - def get_project_settings(self): - """ - Get settings from project. - - :return: json body - """ - url = uri_join(self.base_url_v1, "settings") - try: - r = self.session.get(url, json={}, verify=self.verify_ssl, - timeout=self.http_timeout) - except (ValueError, KeyError, IOError) as exc: - logger.warning("Report Portal Project Settings request failed", - exc_info=exc) - return - logger.debug("settings") - return _get_json(r) - - def log(self, time, message, level=None, attachment=None, item_id=None): - """ - Create log for test. - - :param time: time in UTC - :param message: description - :param level: - :param attachment: files - :param item_id: id of item - """ - data = { - "launchUuid": self.launch_id, - "time": time, - "message": message, - "level": level, - } - if item_id: - data["itemUuid"] = item_id - if attachment: - data["attachment"] = attachment - self._log_batch(data) - - def _log_batch(self, log_data, force=False): - """ - Log batch of messages with attachment. - - Args: - log_data: log record that needs to be processed. - log record is a dict of; - time, message, level, attachment - attachment is a dict of: - name: name of attachment - data: fileobj or content - mime: content type for attachment - item_id: UUID of the test item that owns log_data - force: Flag that forces client to process all the logs - stored in self._batch_logs immediately - """ - if log_data: - self._batch_logs.append(log_data) - - if len(self._batch_logs) < self.log_batch_size and not force: - return - - url = uri_join(self.base_url_v2, "log") - attachments = [] - for log_item in self._batch_logs: - log_item["launchUuid"] = self.launch_id - attachment = log_item.pop("attachment", None) - if attachment: - if not isinstance(attachment, Mapping): - attachment = {"data": attachment} - - name = attachment.get("name", str(uuid.uuid4())) - log_item["file"] = {"name": name} - attachments.append(("file", ( - name, - attachment["data"], - attachment.get("mime", "application/octet-stream") - ))) - - files = [( - "json_request_part", ( - None, - json.dumps(self._batch_logs), - "application/json" - ) - )] - files.extend(attachments) - exceptions = [] - for _ in range(POST_LOGBATCH_RETRY_COUNT): - try: - r = self.session.post(url, files=files, verify=self.verify_ssl, - timeout=self.http_timeout) - logger.debug("log_batch response: %s", r.text) - self._batch_logs = [] - return _get_data(r) - except (ValueError, KeyError, IOError) as exc: - exceptions.append(exc) - - logger.warning( - "Report Portal Batch Log request failed after %d attempts", - POST_LOGBATCH_RETRY_COUNT, - exc_info=exceptions[-1] - ) diff --git a/reportportal_client/services/__init__.py b/reportportal_client/services/__init__.py new file mode 100644 index 00000000..f2e04167 --- /dev/null +++ b/reportportal_client/services/__init__.py @@ -0,0 +1,14 @@ +"""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. +# 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 diff --git a/reportportal_client/services/constants.py b/reportportal_client/services/constants.py new file mode 100644 index 00000000..ded17d73 --- /dev/null +++ b/reportportal_client/services/constants.py @@ -0,0 +1,34 @@ +"""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. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License + +import base64 + + +def _decode_string(text): + """Decode value of the given string. + + :param text: Encoded string + :return: Decoded value + """ + base64_bytes = text.encode('ascii') + message_bytes = base64.b64decode(base64_bytes) + return message_bytes.decode('ascii') + + +CLIENT_INFO = \ + _decode_string('Ry1XUDU3UlNHOFhMOm5Ib3dqRjJQUVotNDFJbzBPcDRoZlE=') +ENDPOINT = 'https://www.google-analytics.com/mp/collect' +CLIENT_ID_PROPERTY = 'client.id' +USER_AGENT = '' diff --git a/reportportal_client/services/constants.pyi b/reportportal_client/services/constants.pyi new file mode 100644 index 00000000..9eb46855 --- /dev/null +++ b/reportportal_client/services/constants.pyi @@ -0,0 +1,20 @@ +# Copyright (c) 2023 EPAM Systems +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License + +from typing import Text + +def _decode_string(text: Text) -> Text: ... + +CLIENT_INFO: Text +ENDPOINT: Text +CLIENT_ID_PROPERTY: Text diff --git a/reportportal_client/services/statistics.py b/reportportal_client/services/statistics.py new file mode 100644 index 00000000..bfbf8319 --- /dev/null +++ b/reportportal_client/services/statistics.py @@ -0,0 +1,127 @@ +"""This module sends statistics events to a statistics service.""" + +# Copyright (c) 2023 EPAM Systems +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License + +import logging +import os +from platform import python_version +from uuid import uuid4 + +import requests +from pkg_resources import get_distribution + +from .constants import CLIENT_INFO, ENDPOINT, CLIENT_ID_PROPERTY + +logger = logging.getLogger(__name__) + +ID, KEY = CLIENT_INFO.split(':') + + +def _get_client_info(): + """Get name of the client and its version. + + :return: ('reportportal-client', '5.0.4') + """ + client = get_distribution('reportportal-client') + return client.project_name, client.version + + +def _get_platform_info(): + """Get current platform basic info, e.g.: 'Python 3.6.1'. + + :return: str represents the current platform, e.g.: 'Python 3.6.1' + """ + return 'Python ' + python_version() + + +def _load_properties(filepath, sep='=', comment_str='#'): + """Read the file passed as parameter as a properties file. + + :param filepath: path to property file + :param sep: separator string between key and value + :param comment_str: a string which designate comment line + :return: property file as Dict + """ + result = {} + with open(filepath, "rt") as f: + for line in f: + s_line = line.strip() + if s_line and not s_line.startswith(comment_str): + sep_idx = s_line.index(sep) + key = s_line[0:sep_idx] + value = s_line[sep_idx + 1:] + result[key.rstrip()] = value.lstrip() + return result + + +def _get_client_id(): + """Get client ID. + + :return: str represents the client ID + """ + rp_folder = os.path.expanduser('~/.rp') + rp_properties = os.path.join(rp_folder, 'rp.properties') + client_id = None + if os.path.exists(rp_properties): + config = _load_properties(rp_properties) + client_id = config.get(CLIENT_ID_PROPERTY) + if not client_id: + if not os.path.exists(rp_folder): + os.mkdir(rp_folder) + client_id = str(uuid4()) + with open(rp_properties, 'a') as f: + f.write('\n' + CLIENT_ID_PROPERTY + '=' + client_id + '\n') + return client_id + + +def send_event(event_name, agent_name, agent_version): + """Send an event to statistics service. + + Use client and agent versions with their names. + + :param event_name: Event name to be used + :param agent_name: Name of the agent that uses the client + :param agent_version: Version of the agent + """ + client_name, client_version = _get_client_info() + params = { + 'client_name': client_name, + 'client_version': client_version, + 'interpreter': _get_platform_info(), + 'agent_name': agent_name, + 'agent_version': agent_version, + } + + if agent_name: + params['agent_name'] = agent_name + if agent_version: + params['agent_version'] = agent_version + + payload = { + 'client_id': _get_client_id(), + 'events': [{ + 'name': event_name, + 'params': params + }] + } + headers = {'User-Agent': 'python-requests'} + params = { + 'measurement_id': ID, + 'api_secret': KEY + } + try: + return requests.post(url=ENDPOINT, json=payload, headers=headers, + params=params) + except requests.exceptions.RequestException as err: + logger.debug('Failed to send data to Statistics service: %s', str(err)) diff --git a/reportportal_client/services/statistics.pyi b/reportportal_client/services/statistics.pyi new file mode 100644 index 00000000..a65ba402 --- /dev/null +++ b/reportportal_client/services/statistics.pyi @@ -0,0 +1,29 @@ +# Copyright (c) 2023 EPAM Systems +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License + +from logging import Logger +from typing import Text, Optional + +import requests + +logger: Logger + + +def _get_client_info() -> tuple: ... + + +def _get_platform_info() -> Text: ... + + +def send_event(event_name: Text, agent_name: Optional[Text], + agent_version: Optional[Text]) -> requests.Response: ... diff --git a/setup.py b/setup.py index b690dc97..5ddc9601 100644 --- a/setup.py +++ b/setup.py @@ -1,30 +1,41 @@ """Config for setup package client Python.""" +import os from setuptools import setup, find_packages -__version__ = '5.2.5' +__version__ = '5.3.0' + + +def read_file(fname): + """Read the given file. + + :param fname: Name of the file to be read + :return: Output of the given file + """ + with open(os.path.join(os.path.dirname(__file__), fname)) as f: + return f.read() -with open('requirements.txt') as f: - requirements = f.read().splitlines() setup( name='reportportal-client', packages=find_packages(exclude=('tests', 'tests.*')), version=__version__, description='Python client for Report Portal v5.', - author_email='SupportEPMC-TSTReportPortal@epam.com', + long_description=read_file('README.md'), + long_description_content_type='text/markdown', + author_email='support@reportportal.io', url='https://github.com/reportportal/client-Python', download_url=('https://github.com/reportportal/client-Python/' 'tarball/%s' % __version__), license='Apache 2.0.', - keywords=['testing', 'reporting', 'reportportal'], + keywords=['testing', 'reporting', 'reportportal', 'client'], classifiers=[ 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10' + 'Programming Language :: Python :: 3.10', ], - install_requires=requirements + install_requires=read_file('requirements.txt').splitlines(), ) diff --git a/tests/conftest.py b/tests/conftest.py index a7d3799f..115fa316 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,10 @@ from six.moves import mock +# noinspection PyPackageRequirements from pytest import fixture from reportportal_client.client import RPClient -from reportportal_client.service import ReportPortalService @fixture() @@ -25,14 +25,6 @@ def inner(ret_code, ret_value): return inner -@fixture(scope='session') -def rp_service(): - """Prepare instance of the ReportPortalService for testing.""" - service = ReportPortalService('http://endpoint', 'project', 'token') - service.session = mock.Mock() - return service - - @fixture def rp_client(): """Prepare instance of the RPClient for testing.""" diff --git a/tests/logs/test_rp_log_handler.py b/tests/logs/test_rp_log_handler.py index 2a56d948..6f6fcf65 100644 --- a/tests/logs/test_rp_log_handler.py +++ b/tests/logs/test_rp_log_handler.py @@ -12,9 +12,12 @@ # limitations under the License import re +# noinspection PyPackageRequirements import pytest +# noinspection PyUnresolvedReferences from six.moves import mock +# noinspection PyProtectedMember from reportportal_client._local import set_current from reportportal_client.logs import RPLogHandler, RPLogger diff --git a/tests/logs/test_rp_logger.py b/tests/logs/test_rp_logger.py index ce79fbcf..c9e29377 100644 --- a/tests/logs/test_rp_logger.py +++ b/tests/logs/test_rp_logger.py @@ -10,7 +10,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License +import inspect import logging +import sys from logging import LogRecord import pytest @@ -65,3 +67,15 @@ def test_log_level_filter(handler_level, log_level, expected_calls): getattr(logger, log_level)('test_log') assert mock_client.log.call_count == expected_calls + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='"stacklevel" introduced in Python 3.8, so not ' + 'actual for earlier versions') +@mock.patch('reportportal_client.logs.logging.Logger.handle') +def test_stacklevel_record_make(logger_handler): + logger = RPLogger('test_logger') + 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)') diff --git a/tests/test_analytics.py b/tests/test_analytics.py deleted file mode 100644 index 8d3751c5..00000000 --- a/tests/test_analytics.py +++ /dev/null @@ -1,71 +0,0 @@ -"""This module contains unit tests for analytics used in the project. - -Copyright (c) 2020 http://reportportal.io . - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" - -from requests.exceptions import RequestException - -from six.moves import mock - -from reportportal_client.external.constants import GA_ENDPOINT, GA_INSTANCE -from reportportal_client.external.google_analytics import send_event - - -@mock.patch('reportportal_client.external.google_analytics.uuid4', - mock.Mock(return_value=555)) -@mock.patch('reportportal_client.external.google_analytics.requests.post') -@mock.patch('reportportal_client.external.google_analytics.get_distribution') -@mock.patch('reportportal_client.external.google_analytics.python_version', - mock.Mock(return_value='3.6.6')) -def test_send_event(mocked_distribution, mocked_requests): - """Test functionality of the send_event() function. - - :param mocked_distribution: Mocked get_distribution() function - :param mocked_requests: Mocked requests module - """ - expected_cl_version, expected_cl_name = '5.0.4', 'reportportal-client' - agent_version, agent_name = '5.0.5', 'pytest-reportportal' - mocked_distribution.return_value.version = expected_cl_version - mocked_distribution.return_value.project_name = expected_cl_name - - expected_headers = {'User-Agent': 'Universal Analytics'} - - expected_data = { - 'v': '1', - 'tid': GA_INSTANCE, - 'aip': '1', - 'cid': '555', - 't': 'event', - 'ec': 'Client name "{}", version "{}", interpreter "Python 3.6.6"' - .format(expected_cl_name, expected_cl_version), - 'ea': 'Start launch', - 'el': 'Agent name "{}", version "{}"'.format( - agent_name, agent_version - ) - } - send_event(agent_name, agent_version) - mocked_requests.assert_called_with( - url=GA_ENDPOINT, data=expected_data, headers=expected_headers) - - -@mock.patch('reportportal_client.external.google_analytics.uuid4', - mock.Mock(return_value=555)) -@mock.patch('reportportal_client.external.google_analytics.requests.post', - mock.Mock(side_effect=RequestException)) -@mock.patch('reportportal_client.external.google_analytics.get_distribution', - mock.Mock()) -def test_send_event_raises(): - """Test that the send_event() does not raise exceptions.""" - send_event('pytest-reportportal', '5.0.5') diff --git a/tests/test_client.py b/tests/test_client.py index 08fc6c37..75405d3d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -17,7 +17,6 @@ from six.moves import mock from reportportal_client.helpers import timestamp -from reportportal_client.static.defines import NOT_FOUND def connection_error(*args, **kwargs): @@ -31,24 +30,31 @@ def response_error(*args, **kwargs): return result +def invalid_response(*args, **kwargs): + result = Response() + result._content = \ + '405 Not Allowed' + result.status_code = 405 + return result + + @pytest.mark.parametrize( - 'requests_method, client_method, client_params, expected_result', + 'requests_method, client_method, client_params', [ - ('put', 'finish_launch', [timestamp()], NOT_FOUND), - ('put', 'finish_test_item', ['test_item_id', timestamp()], NOT_FOUND), - ('get', 'get_item_id_by_uuid', ['test_item_uuid'], NOT_FOUND), - ('get', 'get_launch_info', [], {}), - ('get', 'get_launch_ui_id', [], None), - ('get', 'get_launch_ui_url', [], None), - ('get', 'get_project_settings', [], {}), - ('post', 'start_launch', ['Test Launch', timestamp()], NOT_FOUND), - ('post', 'start_test_item', ['Test Item', timestamp(), 'STEP'], - NOT_FOUND), - ('put', 'update_test_item', ['test_item_id'], NOT_FOUND) + ('put', 'finish_launch', [timestamp()]), + ('put', 'finish_test_item', ['test_item_id', timestamp()]), + ('get', 'get_item_id_by_uuid', ['test_item_uuid']), + ('get', 'get_launch_info', []), + ('get', 'get_launch_ui_id', []), + ('get', 'get_launch_ui_url', []), + ('get', 'get_project_settings', []), + ('post', 'start_launch', ['Test Launch', timestamp()]), + ('post', 'start_test_item', ['Test Item', timestamp(), 'STEP']), + ('put', 'update_test_item', ['test_item_id']) ] ) def test_connection_errors(rp_client, requests_method, client_method, - client_params, expected_result): + client_params): rp_client.launch_id = 'test_launch_id' getattr(rp_client.session, requests_method).side_effect = connection_error result = getattr(rp_client, client_method)(*client_params) @@ -56,7 +62,30 @@ def test_connection_errors(rp_client, requests_method, client_method, getattr(rp_client.session, requests_method).side_effect = response_error result = getattr(rp_client, client_method)(*client_params) - assert result == expected_result + assert result is None + + +@pytest.mark.parametrize( + 'requests_method, client_method, client_params', + [ + ('put', 'finish_launch', [timestamp()]), + ('put', 'finish_test_item', ['test_item_id', timestamp()]), + ('get', 'get_item_id_by_uuid', ['test_item_uuid']), + ('get', 'get_launch_info', []), + ('get', 'get_launch_ui_id', []), + ('get', 'get_launch_ui_url', []), + ('get', 'get_project_settings', []), + ('post', 'start_launch', ['Test Launch', timestamp()]), + ('post', 'start_test_item', ['Test Item', timestamp(), 'STEP']), + ('put', 'update_test_item', ['test_item_id']) + ] +) +def test_invalid_responses(rp_client, requests_method, client_method, + client_params): + rp_client.launch_id = 'test_launch_id' + getattr(rp_client.session, requests_method).side_effect = invalid_response + result = getattr(rp_client, client_method)(*client_params) + assert result is None LAUNCH_ID = 333 diff --git a/tests/test_service.py b/tests/test_service.py deleted file mode 100644 index 88ccca44..00000000 --- a/tests/test_service.py +++ /dev/null @@ -1,346 +0,0 @@ -"""These modules include unit tests for the service.py module.""" - -from datetime import datetime - -import pytest -from delayed_assert import assert_expectations, expect -from requests import ReadTimeout -from six.moves import mock - -from reportportal_client.helpers import timestamp -from reportportal_client.service import ( - _convert_string, - _dict_to_payload, - _get_data, - _get_id, - _get_json, - _get_messages, - _get_msg -) - - -class TestServiceFunctions: - """This class contains test methods for helper functions.""" - - def test_check_convert_to_string(self): - """Test for support and convert strings to utf-8.""" - expect(_convert_string('Hello world') == 'Hello world') - expect(lambda: isinstance(_convert_string('Hello world'), str)) - assert_expectations() - - @pytest.mark.parametrize('system', [True, False]) - def test_dict_to_payload_with_system_key(self, system): - """Test convert dict to list of dicts with key system.""" - initial_dict = {'aa': 1, 'b': 2, 'system': system} - expected_list = [{'key': 'aa', 'value': '1', 'system': system}, - {'key': 'b', 'value': '2', 'system': system}] - assert _dict_to_payload(initial_dict) == expected_list - - def test_get_id(self, response): - """Test for the get_id function.""" - assert _get_id(response(200, {'id': 123})) == 123 - - def test_get_msg(self, response): - """Test for the get_msg function.""" - fake_json = {'id': 123} - assert _get_msg(response(200, fake_json)) == fake_json - - def test_get_data(self, response): - """Test for the get_data function.""" - fake_json = {'id': 123} - assert _get_data(response(200, fake_json)) == fake_json - - def test_get_json(self, response): - """Test for the get_json function.""" - fake_json = {'id': 123} - assert _get_json(response(200, fake_json)) == fake_json - - def test_get_messages(self): - """Test for the get_messages function.""" - data = {'responses': [{'errorCode': 422, 'message': 'error'}]} - assert _get_messages(data) == ['422: error'] - - -class TestReportPortalService: - """This class stores methods which test ReportPortalService.""" - - @mock.patch('reportportal_client.service._get_data') - def test_start_launch(self, mock_get, rp_service): - """Test start launch and sending request. - - :param mock_get: Mocked _get_data() function - :param rp_service: Pytest fixture - """ - mock_get.return_value = {'id': 111} - launch_id = rp_service.start_launch('name', datetime.now().isoformat()) - assert launch_id == 111 - - @mock.patch('reportportal_client.service._get_data') - def test_start_launch_with_rerun(self, mock_get, rp_service): - """Test start launch and sending request. - - :param mock_get: Mocked _get_data() function - :param rp_service: Pytest fixture - """ - mock_get.return_value = {'id': 111} - launch_id = rp_service.start_launch('name', datetime.now().isoformat(), - rerun=True, rerun_of="111") - assert launch_id == 111 - - @mock.patch('reportportal_client.service._get_msg') - def test_finish_launch(self, mock_get, rp_service): - """Test finish launch and sending request. - - :param mock_get: Mocked _get_msg() function - :param rp_service: Pytest fixture - """ - mock_get.return_value = {'id': 111} - _get_msg = rp_service.finish_launch( - 'name', datetime.now().isoformat()) - assert _get_msg == {'id': 111} - - @mock.patch('reportportal_client.service._get_json', - mock.Mock(return_value={'id': 112})) - def test_get_launch_info(self, rp_service, monkeypatch): - """Test get current launch information. - - :param rp_service: Pytest fixture that represents ReportPortalService - object with mocked session. - :param monkeypatch: Pytest fixture to safely set/delete an attribute - """ - mock_resp = mock.Mock() - mock_resp.status_code = 200 - - mock_request = mock.Mock(return_value=mock_resp) - monkeypatch.setattr(rp_service.session, 'get', mock_request) - monkeypatch.setattr(rp_service, 'launch_id', '1234-cafe') - - launch_id = rp_service.get_launch_info() - mock_request.assert_called_once_with( - '{0}/launch/uuid/{1}'.format(rp_service.base_url_v1, - rp_service.launch_id), - verify=rp_service.verify_ssl, - timeout=(10, 10)) - assert launch_id == {'id': 112} - - def test_get_launch_info_launch_id_none(self, rp_service, monkeypatch): - """Test get launch information for a non started launch. - - :param rp_service: Pytest fixture that represents ReportPortalService - object with mocked session. - :param monkeypatch: Pytest fixture to safely set/delete an attribute - """ - mock_get = mock.Mock() - monkeypatch.setattr(rp_service.session, 'get', mock_get) - monkeypatch.setattr(rp_service, 'launch_id', None) - - launch_info = rp_service.get_launch_info() - mock_get.assert_not_called() - assert launch_info == {} - - @mock.patch('reportportal_client.service.sleep', mock.Mock()) - @mock.patch('reportportal_client.service._get_json', - mock.Mock(return_value={"errorCode": 4041})) - def test_get_launch_info_wrong_launch_id(self, rp_service, monkeypatch): - """Test get launch information for a non existed launch. - - :param rp_service: Pytest fixture that represents ReportPortalService - object with mocked session. - :param monkeypatch: Pytest fixture to safely set/delete an attribute - """ - mock_request = mock.Mock() - monkeypatch.setattr(rp_service.session, 'get', mock_request) - monkeypatch.setattr(rp_service, 'launch_id', '1234') - - launch_info = rp_service.get_launch_info() - expect(mock_request.call_count == 5) - expect(launch_info == {}) - assert_expectations() - - @mock.patch('reportportal_client.service.sleep', mock.Mock()) - @mock.patch('reportportal_client.service._get_json', - mock.Mock(return_value={'id': 112})) - def test_get_launch_info_1st_failed(self, rp_service, monkeypatch): - """Test get launch information with 1st attempt failed. - - :param rp_service: Pytest fixture that represents ReportPortalService - object with mocked session. - :param monkeypatch: Pytest fixture to safely set/delete an attribute - """ - mock_resp1 = mock.Mock() - mock_resp1.status_code = 404 - mock_resp2 = mock.Mock() - mock_resp2.status_code = 200 - mock_request = mock.Mock() - mock_request.side_effect = [mock_resp1, mock_resp2] - monkeypatch.setattr(rp_service.session, 'get', mock_request) - monkeypatch.setattr(rp_service, 'launch_id', '1234') - - launch_info = rp_service.get_launch_info() - expect(mock_request.call_count == 2) - expect(launch_info == {'id': 112}) - assert_expectations() - - def test_get_launch_ui_id(self, rp_service, monkeypatch): - """Test get launch UI ID. - - :param rp_service: Pytest fixture that represents ReportPortalService - object with mocked session. - :param monkeypatch: Pytest fixture to safely set/delete an attribute - """ - mock_get_launch_info = mock.Mock(return_value={'id': 113}) - monkeypatch.setattr(rp_service, - 'get_launch_info', - mock_get_launch_info) - assert rp_service.get_launch_ui_id() == 113 - - def test_get_launch_ui_no_id(self, rp_service, monkeypatch): - """Test get launch UI ID when no ID has been retrieved. - - :param rp_service: Pytest fixture that represents ReportPortalService - object with mocked session. - :param monkeypatch: Pytest fixture to safely set/delete an attribute - """ - mock_get_launch_info = mock.Mock(return_value={}) - monkeypatch.setattr(rp_service, - 'get_launch_info', - mock_get_launch_info) - assert rp_service.get_launch_ui_id() is None - - def test_get_launch_ui_url(self, rp_service, monkeypatch): - """Test get launch UI URL. - - :param rp_service: Pytest fixture that represents ReportPortalService - object with mocked session. - :param monkeypatch: Pytest fixture to safely set/delete an attribute - """ - mock_get_launch_ui_id = mock.Mock(return_value=1) - monkeypatch.setattr(rp_service, - 'get_launch_ui_id', - mock_get_launch_ui_id) - url = rp_service.get_launch_ui_url() - assert url == '{0}/ui/#{1}/launches/all/1'.format(rp_service.endpoint, - rp_service.project) - - def test_get_launch_ui_url_no_id(self, rp_service, monkeypatch): - """Test get launch UI URL no ID has been retrieved. - - :param rp_service: Pytest fixture that represents ReportPortalService - object with mocked session. - :param monkeypatch: Pytest fixture to safely set/delete an attribute - """ - mock_get_launch_ui_id = mock.Mock(return_value=0) - monkeypatch.setattr(rp_service, - 'get_launch_ui_id', - mock_get_launch_ui_id) - url = rp_service.get_launch_ui_url() - assert url == '{0}/ui/#{1}/launches/all'.format(rp_service.endpoint, - rp_service.project) - - @mock.patch('reportportal_client.service._get_data', - mock.Mock(return_value={'id': 123})) - def test_start_item(self, rp_service): - """Test for validate start_test_item. - - :param: rp_service: fixture of ReportPortal - """ - rp_start = rp_service.start_test_item(name='name', - start_time=1591032041348, - item_type='STORY') - expected_result = (['http://endpoint/api/v2/project/item'], - dict(json={'name': 'name', - 'description': None, - 'attributes': None, - 'startTime': 1591032041348, - 'launchUuid': 111, - 'type': 'STORY', 'parameters': None, - 'hasStats': True, - 'codeRef': None, - 'testCaseId': None, - 'retry': False}, - timeout=(10, 10), - verify=True) - ) - - rp_service.session.post.assert_called_with(*expected_result[0], - **expected_result[1]) - assert rp_start == 123 - - start_item_optional = [ - ('code_ref', '/path/to/test - test_item', 'codeRef', - '/path/to/test - test_item'), - ('attributes', {'attr1': True}, 'attributes', - [{'key': 'attr1', 'value': 'True', 'system': False}]) - ] - - @pytest.mark.parametrize( - 'field_name,field_value,expected_name,expected_value', - start_item_optional) - @mock.patch('reportportal_client.service._get_data', - mock.Mock(return_value={'id': 123})) - def test_start_item_code_optional_params(self, rp_service, field_name, - field_value, expected_name, - expected_value): - """Test for validate different fields in start_test_item. - - :param: rp_service: fixture of ReportPortal - :param: field_name: a name of a field bypassed to - rp_service.start_test_item method - :param: field_value: a value of a field bypassed to - rp_service.start_test_item method - :param: expected_name: a name of a field which should be in the result - JSON request - :param: expected_value: an exact value of a field which should be in - the result JSON request - """ - rp_service.start_test_item(name='name', start_time=1591032041348, - item_type='STORY', - **{field_name: field_value}) - expected_result = (['http://endpoint/api/v2/project/item'], - dict(json={'name': 'name', - 'description': None, - 'attributes': None, - 'startTime': 1591032041348, - 'launchUuid': 111, - 'type': 'STORY', 'parameters': None, - 'hasStats': True, - 'codeRef': None, - 'testCaseId': None, - 'retry': False}, - timeout=(10, 10), - verify=True)) - expected_result[1]['json'][expected_name] = expected_value - rp_service.session.post.assert_called_with(*expected_result[0], - **expected_result[1]) - - -def connection_error(*args, **kwargs): - raise ReadTimeout() - - -@pytest.mark.parametrize( - 'requests_method, client_method, client_params, expected_result', - [ - ('put', 'finish_launch', [timestamp()], None), - ('put', 'finish_test_item', ['test_item_id', timestamp(), 'PASSED'], - None), - ('get', 'get_item_id_by_uuid', ['test_item_uuid'], None), - ('get', 'get_launch_info', [], {}), - ('get', 'get_launch_ui_id', [], None), - ('get', 'get_launch_ui_url', [], - 'http://endpoint/ui/#project/launches/all'), - ('get', 'get_project_settings', [], None), - ('post', 'start_launch', ['Test Launch', timestamp()], None), - ('post', 'start_test_item', ['Test Item', timestamp(), 'STEP'], None), - ('put', 'update_test_item', ['test_item_id'], None), - ('put', '_log_batch', [{'launchUuid': 'test_launch_uuid'}, True], - None), - ] -) -def test_connection_errors(rp_service, requests_method, client_method, - client_params, expected_result): - rp_service.launch_id = 'test_launch_id' - getattr(rp_service.session, requests_method).side_effect = connection_error - result = getattr(rp_service, client_method)(*client_params) - - assert result == expected_result diff --git a/tests/test_statistics.py b/tests/test_statistics.py new file mode 100644 index 00000000..9a6d3681 --- /dev/null +++ b/tests/test_statistics.py @@ -0,0 +1,100 @@ +"""This module contains unit tests for statistics used in the project.""" + +# Copyright (c) 2023 EPAM Systems +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License + +from requests.exceptions import RequestException +# noinspection PyUnresolvedReferences +from six.moves import mock + +from reportportal_client.services.constants import ENDPOINT, CLIENT_INFO, \ + CLIENT_ID_PROPERTY +from reportportal_client.services.statistics import send_event + +EVENT_NAME = 'start_launch' + + +@mock.patch('reportportal_client.services.statistics.uuid4', + mock.Mock(return_value=555)) +@mock.patch('reportportal_client.services.statistics._load_properties', + mock.Mock(return_value={CLIENT_ID_PROPERTY: '555'})) +@mock.patch('reportportal_client.services.statistics.requests.post') +@mock.patch('reportportal_client.services.statistics.get_distribution') +@mock.patch('reportportal_client.services.statistics.python_version', + mock.Mock(return_value='3.6.6')) +def test_send_event(mocked_distribution, mocked_requests): + """Test functionality of the send_event() function. + + :param mocked_distribution: Mocked get_distribution() function + :param mocked_requests: Mocked requests module + """ + expected_cl_version, expected_cl_name = '5.0.4', 'reportportal-client' + agent_version, agent_name = '5.0.5', 'pytest-reportportal' + mocked_distribution.return_value.version = expected_cl_version + mocked_distribution.return_value.project_name = expected_cl_name + + expected_headers = {'User-Agent': 'python-requests'} + + expected_data = { + 'client_id': '555', + 'events': [{ + 'name': EVENT_NAME, + 'params': { + 'client_name': expected_cl_name, + 'client_version': expected_cl_version, + 'interpreter': 'Python 3.6.6', + 'agent_name': agent_name, + 'agent_version': agent_version, + } + }] + } + mid, key = CLIENT_INFO.split(':') + expected_params = {'measurement_id': mid, 'api_secret': key} + send_event(EVENT_NAME, agent_name, agent_version) + mocked_requests.assert_called_with( + url=ENDPOINT, json=expected_data, headers=expected_headers, + params=expected_params) + + +@mock.patch('reportportal_client.services.statistics.uuid4', + mock.Mock(return_value=555)) +@mock.patch('reportportal_client.services.statistics.requests.post', + mock.Mock(side_effect=RequestException)) +@mock.patch('reportportal_client.services.statistics.get_distribution', + mock.Mock()) +def test_send_event_raises(): + """Test that the send_event() does not raise exceptions.""" + send_event(EVENT_NAME, 'pytest-reportportal', '5.0.5') + + +@mock.patch('reportportal_client.services.statistics.requests.post') +@mock.patch('reportportal_client.services.statistics.get_distribution') +@mock.patch('reportportal_client.services.statistics.python_version', + mock.Mock(return_value='3.6.6')) +def test_same_client_id(mocked_distribution, mocked_requests): + """Test functionality of the send_event() function. + + :param mocked_distribution: Mocked get_distribution() function + :param mocked_requests: Mocked requests module + """ + expected_cl_version, expected_cl_name = '5.0.4', 'reportportal-client' + agent_version, agent_name = '5.0.5', 'pytest-reportportal' + mocked_distribution.return_value.version = expected_cl_version + mocked_distribution.return_value.project_name = expected_cl_name + + send_event(EVENT_NAME, agent_name, agent_version) + send_event(EVENT_NAME, agent_name, agent_version) + args_list = mocked_requests.call_args_list + + assert args_list[0][1]['json']['client_id'] == \ + args_list[1][1]['json']['client_id'] diff --git a/tox.ini b/tox.ini index c98fba12..efe13daa 100644 --- a/tox.ini +++ b/tox.ini @@ -3,10 +3,11 @@ isolated_build = True envlist = pep py27 - py35 py36 py37 py38 + py39 + py310 [testenv] deps = @@ -40,3 +41,5 @@ python = 3.6: pep, py36 3.7: py37 3.8: py38 + 3.9: py39 + 3.10: py310