"""This module includes Service functions for work with pytest agent.""" import logging import sys import traceback from time import time import pkg_resources import pytest from _pytest.doctest import DoctestItem from _pytest.main import Session try: pkg_resources.get_distribution('pytest >= 3.4.0') from _pytest.nodes import File, Item except pkg_resources.VersionConflict: from _pytest.main import File, Item try: pkg_resources.get_distribution('pytest >= 3.8.0') from _pytest.warning_types import PytestWarning except pkg_resources.VersionConflict: from pytest_reportportal.errors import PytestWarning from _pytest.python import Class, Function, Instance, Module from _pytest.unittest import TestCaseFunction, UnitTestCase from reportportal_client import ReportPortalService from reportportal_client.service import _dict_to_payload from six import with_metaclass from six.moves import queue log = logging.getLogger(__name__) def timestamp(): """Time for difference between start and finish tests.""" return str(int(time() * 1000)) def trim_docstring(docstring): """ Convert docstring. :param docstring: input docstring :return: docstring """ if not docstring: return '' # Convert tabs to spaces (following the normal Python rules) # and split into a list of lines: lines = docstring.expandtabs().splitlines() # Determine minimum indentation (first line doesn't count): indent = sys.maxsize for line in lines[1:]: stripped = line.lstrip() if stripped: indent = min(indent, len(line) - len(stripped)) # Remove indentation (first line is special): trimmed = [lines[0].strip()] if indent < sys.maxsize: for line in lines[1:]: trimmed.append(line[indent:].rstrip()) # Strip off trailing and leading blank lines: while trimmed and not trimmed[-1]: trimmed.pop() while trimmed and not trimmed[0]: trimmed.pop(0) # Return a single string: return '\n'.join(trimmed) class Singleton(type): """Class Singleton pattern.""" _instances = {} def __call__(cls, *args, **kwargs): """Redefine call method. :param args: list of additional params :param kwargs: dict of additional params """ if cls not in cls._instances: cls._instances[cls] = super(Singleton, cls).__call__( *args, **kwargs) return cls._instances[cls] class PyTestServiceClass(with_metaclass(Singleton, object)): """Pytest service class for reporting test results to the Report Portal.""" def __init__(self): """Initialize instance attributes.""" self._agent_name = 'pytest-reportportal' self._errors = queue.Queue() self._hier_parts = {} self._issue_types = {} self._item_parts = {} self._loglevels = ('TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR') self.ignore_errors = True self.ignored_attributes = [] self.log_batch_size = 20 self.log_item_id = None self.parent_item_id = None self.rp = None self.rp_supports_parameters = True try: pkg_resources.get_distribution('reportportal_client >= 3.2.0') except pkg_resources.VersionConflict: self.rp_supports_parameters = False @property def issue_types(self): """Issue types for the Report Portal project.""" if not self._issue_types: if not self.project_settings: return self._issue_types for item_type in ("AUTOMATION_BUG", "PRODUCT_BUG", "SYSTEM_ISSUE", "NO_DEFECT", "TO_INVESTIGATE"): for item in self.project_settings["subTypes"][item_type]: self._issue_types[item["shortName"]] = item["locator"] return self._issue_types def init_service(self, endpoint, project, uuid, log_batch_size, ignore_errors, ignored_attributes, verify_ssl=True, retries=0): """Update self.rp with the instance of the ReportPortalService.""" self._errors = queue.Queue() if self.rp is None: self.ignore_errors = ignore_errors self.ignored_attributes = ignored_attributes if self.rp_supports_parameters: self.ignored_attributes = list( set(ignored_attributes).union({'parametrize'})) log.debug('ReportPortal - Init service: endpoint=%s, ' 'project=%s, uuid=%s', endpoint, project, uuid) self.rp = ReportPortalService( endpoint=endpoint, project=project, token=uuid, retries=retries, verify_ssl=verify_ssl ) self.project_settings = None if self.rp and hasattr(self.rp, "get_project_settings"): self.project_settings = self.rp.get_project_settings() else: log.debug('The pytest is already initialized') return self.rp def start_launch(self, launch_name, mode=None, description=None, attributes=None, **kwargs): """ Launch test items. :param launch_name: name of the launch :param mode: mode :param description: description of launch test :param kwargs: additional params :return: item ID """ self._stop_if_necessary() if self.rp is None: return sl_pt = { 'attributes': self._get_launch_attributes(attributes), 'name': launch_name, 'start_time': timestamp(), 'description': description, 'mode': mode, } log.debug('ReportPortal - Start launch: request_body=%s', sl_pt) item_id = self.rp.start_launch(**sl_pt) log.debug('ReportPortal - Launch started: id=%s', item_id) return item_id def collect_tests(self, session): """ Collect all tests. :param session: pytest.Session :return: None """ self._stop_if_necessary() if self.rp is None: return hier_dirs = False hier_module = False hier_class = False hier_param = False display_suite_file_name = True if not hasattr(session.config, 'slaveinput'): hier_dirs = session.config.getini('rp_hierarchy_dirs') hier_module = session.config.getini('rp_hierarchy_module') hier_class = session.config.getini('rp_hierarchy_class') hier_param = session.config.getini('rp_hierarchy_parametrize') display_suite_file_name = session.config.getini( 'rp_display_suite_test_file') try: hier_dirs_level = int( session.config.getini('rp_hierarchy_dirs_level')) except ValueError: hier_dirs_level = 0 dirs_parts = {} tests_parts = {} for item in session.items: # Start collecting test item parts parts = [] # Hierarchy for directories rp_name = self._add_item_hier_parts_dirs(item, hier_dirs, hier_dirs_level, parts, dirs_parts) # Hierarchy for Module and Class/UnitTestCase item_parts = self._get_item_parts(item) rp_name = self._add_item_hier_parts_other(item_parts, item, Module, hier_module, parts, rp_name) rp_name = self._add_item_hier_parts_other(item_parts, item, Class, hier_class, parts, rp_name) rp_name = self._add_item_hier_parts_other(item_parts, item, UnitTestCase, hier_class, parts, rp_name) # Hierarchy for parametrized tests if hier_param: rp_name = self._add_item_hier_parts_parametrize(item, parts, tests_parts, rp_name) # Hierarchy for test itself (Function/TestCaseFunction) item._rp_name = rp_name + ("::" if rp_name else "") + item.name # Result initialization for part in parts: part._rp_result = "PASSED" self._item_parts[item] = parts for part in parts: if '_pytest.python.Class' in str(type( part)) and not display_suite_file_name and not \ hier_module: part._rp_name = part._rp_name.split("::")[-1] if part not in self._hier_parts: self._hier_parts[part] = {"finish_counter": 1, "start_flag": False} else: self._hier_parts[part]["finish_counter"] += 1 def start_pytest_item(self, test_item=None): """ Start pytest_item. :param test_item: pytest.Item :return: item ID """ self._stop_if_necessary() if self.rp is None: return self.parent_item_id = None for part in self._item_parts[test_item]: if self._hier_parts[part]["start_flag"]: self.parent_item_id = self._hier_parts[part]["item_id"] continue self._hier_parts[part]["start_flag"] = True payload = { 'name': self._get_item_name(part), 'description': self._get_item_description(part), 'start_time': timestamp(), 'item_type': 'SUITE', 'parent_item_id': self.parent_item_id, 'code_ref': test_item.fspath } log.debug('ReportPortal - Start Suite: request_body=%s', payload) item_id = self.rp.start_test_item(**payload) self.log_item_id = item_id self.parent_item_id = item_id self._hier_parts[part]["item_id"] = item_id # Item type should be sent as "STEP" until we upgrade to RPv6. # Details at: # https://github.com/reportportal/agent-Python-RobotFramework/issues/56 start_rq = { 'attributes': self._get_item_markers(test_item), 'name': self._get_item_name(test_item), 'description': self._get_item_description(test_item), 'start_time': timestamp(), 'item_type': 'STEP', 'parent_item_id': self.parent_item_id, 'code_ref': '{0} - {1}'.format(test_item.fspath, test_item.name) } if self.rp_supports_parameters: start_rq['parameters'] = self._get_parameters(test_item) log.debug('ReportPortal - Start TestItem: request_body=%s', start_rq) item_id = self.rp.start_test_item(**start_rq) self.log_item_id = item_id return item_id def finish_pytest_item(self, test_item, item_id, status, issue=None): """ Finish pytest_item. :param test_item: test_item :param item_id: Pytest.Item :param status: an item finish status (PASSED, FAILED, STOPPED, SKIPPED, RESETED, CANCELLED, INFO, WARN) :param issue: an external system issue reference :return: None """ self._stop_if_necessary() if self.rp is None: return fta_rq = { 'end_time': timestamp(), 'status': status, 'issue': issue, 'item_id': item_id } log.debug('ReportPortal - Finish TestItem: request_body=%s', fta_rq) parts = self._item_parts[test_item] self.rp.finish_test_item(**fta_rq) while len(parts) > 0: part = parts.pop() if status == "FAILED": part._rp_result = status self._hier_parts[part]["finish_counter"] -= 1 if self._hier_parts[part]["finish_counter"] > 0: continue payload = { 'end_time': timestamp(), 'issue': issue, 'item_id': self._hier_parts[part]["item_id"], 'status': part._rp_result } log.debug('ReportPortal - End TestSuite: request_body=%s', payload) self.rp.finish_test_item(**payload) def finish_launch(self, status=None, **kwargs): """ Finish tests launch. :param status: an launch status (PASSED, FAILED, STOPPED, SKIPPED, INTERRUPTED, CANCELLED, INFO, WARN) :param kwargs: additional params :return: None """ self._stop_if_necessary() if self.rp is None: return # To finish launch session str parameter is needed fl_rq = { 'end_time': timestamp(), 'status': status } log.debug('ReportPortal - Finish launch: request_body=%s', fl_rq) self.rp.finish_launch(**fl_rq) def post_log(self, message, loglevel='INFO', attachment=None): """ Send a log message to the Report Portal. :param message: message in log body :param loglevel: a level of a log entry (ERROR, WARN, INFO, DEBUG, TRACE, FATAL, UNKNOWN) :param attachment: attachment file :return: None """ self._stop_if_necessary() if self.rp is None: return if loglevel not in self._loglevels: log.warning('Incorrect loglevel = %s. Force set to INFO. ' 'Available levels: %s.', loglevel, self._loglevels) loglevel = 'INFO' sl_rq = { 'item_id': self.log_item_id, 'time': timestamp(), 'message': message, 'level': loglevel, 'attachment': attachment } self.rp.log(**sl_rq) def _stop_if_necessary(self): """ Stop tests if any error occurs. :return: None """ try: exc, msg, tb = self._errors.get(False) traceback.print_exception(exc, msg, tb) sys.stderr.flush() if not self.ignore_errors: pytest.exit(msg) except queue.Empty: pass @staticmethod def _add_item_hier_parts_dirs(item, hier_flag, dirs_level, report_parts, dirs_parts, rp_name=""): """ Add item to hierarchy of parents located in directory. :param item: Pytest.Item :param hier_flag: flag :param dirs_level: int value of level :param report_parts: '' :param dirs_parts: '' :param rp_name: report name :return: str rp_name """ parts_dirs = PyTestServiceClass._get_item_dirs(item) dir_path = item.fspath.new(dirname="", basename="", drive="") rp_name_path = "" for dir_name in parts_dirs[dirs_level:]: dir_path = dir_path.join(dir_name) path = str(dir_path) if hier_flag: if path in dirs_parts: item_dir = dirs_parts[path] rp_name = "" else: item_dir = File(dir_path, nodeid=dir_name, session=item.session, config=item.session.config) rp_name += dir_name item_dir._rp_name = rp_name dirs_parts[path] = item_dir rp_name = "" report_parts.append(item_dir) else: rp_name_path = path[1:] if not hier_flag: rp_name += rp_name_path return rp_name @staticmethod def _add_item_hier_parts_parametrize(item, report_parts, tests_parts, rp_name=""): """ Add item to hierarchy of parents with params. :param item: pytest.Item :param report_parts: Parent reports :param tests_parts: test item parts :param rp_name: name of report :return: str rp_name """ for mark in item.own_markers: if mark.name == 'parametrize': ch_index = item.nodeid.find("[") test_fullname = item.nodeid[ :ch_index if ch_index > 0 else len( item.nodeid)] test_name = item.originalname rp_name += ("::" if rp_name else "") + test_name if test_fullname in tests_parts: item_test = tests_parts[test_fullname] else: item_test = Item(test_fullname, nodeid=test_fullname, session=item.session, config=item.session.config) item_test._rp_name = rp_name item_test.obj = item.obj item_test.keywords = item.keywords item_test.own_markers = item.own_markers item_test.parent = item.parent tests_parts[test_fullname] = item_test rp_name = "" report_parts.append(item_test) break return rp_name @staticmethod def _add_item_hier_parts_other(item_parts, item, item_type, hier_flag, report_parts, rp_name=""): """ Add item to hierarchy of parents. :param item_parts: Parent_items :param item: pytest.Item :param item_type: (SUITE, STORY, TEST, SCENARIO, STEP, BEFORE_CLASS, BEFORE_GROUPS, BEFORE_METHOD, BEFORE_SUITE, BEFORE_TEST, AFTER_CLASS, AFTER_GROUPS, AFTER_METHOD, AFTER_SUITE, AFTER_TEST) :param hier_flag: bool state :param report_parts: list of parent reports :param rp_name: report name :return: str rp_name """ for part in item_parts: if type(part) is item_type: if item_type is Module: module_path = str( item.fspath.new(dirname=rp_name, basename=part.fspath.basename, drive="")) rp_name = module_path if rp_name else module_path[1:] elif item_type in (Class, Function, UnitTestCase, TestCaseFunction): rp_name += ("::" if rp_name else "") + part.name if hier_flag: part._rp_name = rp_name rp_name = "" report_parts.append(part) return rp_name @staticmethod def _get_item_parts(item): """ Get item of parents. :param item: pytest.Item :return list of parents """ parts = [] parent = item.parent if not isinstance(parent, Instance): parts.append(parent) while True: parent = parent.parent if parent is None: break if isinstance(parent, Instance): continue if isinstance(parent, Session): break parts.append(parent) parts.reverse() return parts @staticmethod def _get_item_dirs(item): """ Get directory of item. :param item: pytest.Item :return: list of dirs """ root_path = item.session.config.rootdir.strpath dir_path = item.fspath.new(basename="") rel_dir = dir_path.new(dirname=dir_path.relto(root_path), basename="", drive="") dir_list = [] for directory in rel_dir.parts(reverse=False): dir_name = directory.basename if dir_name: dir_list.append(dir_name) return dir_list def _get_launch_attributes(self, ini_attrs): """Generate launch attributes in the format supported by the client. :param list ini_attrs: List for attributes from the pytest.ini file """ attributes = ini_attrs or [] system_info = self.rp.get_system_information(self._agent_name) system_info['system'] = True system_attributes = _dict_to_payload(system_info) return attributes + system_attributes def _get_item_markers(self, item): """ Get attributes of item. :param item: pytest.Item :return: list of tags """ # Try to extract names of @pytest.mark.* decorators used for test item # and exclude those which present in rp_ignore_attributes parameter def get_marker_value(item, keyword): try: marker = item.get_closest_marker(keyword) except AttributeError: # pytest < 3.6 marker = item.keywords.get(keyword) return "{}:{}".format(keyword, marker.args[0]) \ if marker and marker.args else keyword try: get_marker = getattr(item, "get_closest_marker") except AttributeError: get_marker = getattr(item, "get_marker") attributes = [{"value": get_marker_value(item, k)} for k in item.keywords if get_marker(k) is not None and k not in self.ignored_attributes] attributes.extend([{"value": tag} for tag in item.session.config.getini('rp_tests_attributes')]) return attributes def _get_parameters(self, item): """ Get params of item. :param item: Pytest.Item :return: dict of params """ return item.callspec.params if hasattr(item, 'callspec') else None @staticmethod def _get_item_name(test_item): """ Get name of item. :param test_item: pytest.Item :return: name """ name = test_item._rp_name if len(name) > 256: name = name[:256] test_item.warn( PytestWarning( 'Test node ID was truncated to "{}" because of name size ' 'constrains on reportportal'.format(name) ) ) return name @staticmethod def _get_item_description(test_item): """ Get description of item. :param test_item: pytest.Item :return string description """ if isinstance(test_item, (Class, Function, Module, Item)): doc = test_item.obj.__doc__ if doc is not None: return trim_docstring(doc) if isinstance(test_item, DoctestItem): return test_item.reportinfo()[2]