diff --git a/CHANGELOG.md b/CHANGELOG.md index 939fa4d..35ce4b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] ### Added +- Issue [#178](https://github.com/reportportal/agent-Python-RobotFramework/issues/178) Metadata attributes handling, by @HardNorth +### Changed +- Client version updated on [5.5.6](https://github.com/reportportal/client-Python/releases/tag/5.5.6), by @HardNorth +### Removed +- `model.pyi`, `static.pyi` stub files, as we don't really need them anymore, by @HardNorth + +## [5.5.2] +### Added - Binary data escaping in `listener` module (enhancing `Get Binary File` keyword logging), by @HardNorth ### Changed - Client version updated on [5.5.5](https://github.com/reportportal/client-Python/releases/tag/5.5.5), by @HardNorth diff --git a/examples/suite_metadata.robot b/examples/suite_metadata.robot new file mode 100644 index 0000000..6ffb626 --- /dev/null +++ b/examples/suite_metadata.robot @@ -0,0 +1,6 @@ +*** Settings *** +Metadata Author John Doe + +*** Test Cases *** +Simple test + Log Hello, world! diff --git a/requirements.txt b/requirements.txt index 6286d8a..83cee74 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ # Basic dependencies python-dateutil~=2.8.1 -reportportal-client~=5.5.5 +reportportal-client~=5.5.6 robotframework diff --git a/robotframework_reportportal/listener.py b/robotframework_reportportal/listener.py index db87617..4de50fa 100644 --- a/robotframework_reportportal/listener.py +++ b/robotframework_reportportal/listener.py @@ -20,38 +20,19 @@ import re from functools import wraps from mimetypes import guess_type -from types import MappingProxyType from typing import Optional, Dict, Union, Any from warnings import warn -from reportportal_client.helpers import gen_attributes, LifoQueue, is_binary, guess_content_type_from_bytes +from reportportal_client.helpers import LifoQueue, is_binary, guess_content_type_from_bytes from .model import Keyword, Launch, Test, LogMessage, Suite from .service import RobotService -from .static import MAIN_SUITE_ID, PABOT_WIHOUT_LAUNCH_ID_MSG +from .static import MAIN_SUITE_ID, PABOT_WITHOUT_LAUNCH_ID_MSG from .variables import Variables logger = logging.getLogger(__name__) VARIABLE_PATTERN = r'^\s*\${[^}]*}\s*=\s*' TRUNCATION_SIGN = "...'" -CONTENT_TYPE_TO_EXTENSIONS = MappingProxyType({ - 'application/pdf': 'pdf', - 'application/zip': 'zip', - 'application/java-archive': 'jar', - 'image/jpeg': 'jpg', - 'image/png': 'png', - 'image/gif': 'gif', - 'image/bmp': 'bmp', - 'image/vnd.microsoft.icon': 'ico', - 'image/webp': 'webp', - 'audio/mpeg': 'mp3', - 'audio/wav': 'wav', - 'video/mpeg': 'mpeg', - 'video/avi': 'avi', - 'video/webm': 'webm', - 'text/plain': 'txt', - 'application/octet-stream': 'bin' -}) def _unescape(binary_string: str, stop_at: int = -1): @@ -106,9 +87,9 @@ def wrap(*args, **kwargs): class listener: """Robot Framework listener interface for reporting to ReportPortal.""" - _items: LifoQueue = ... - _service: Optional[RobotService] = ... - _variables: Optional[Variables] = ... + _items: LifoQueue + _service: Optional[RobotService] + _variables: Optional[Variables] ROBOT_LISTENER_API_VERSION = 2 def __init__(self) -> None: @@ -165,7 +146,7 @@ def log_message(self, message: Dict) -> None: msg.message = (f'Binary data of type "{content_type}" logging skipped, as it was processed as text and' ' hence corrupted.') msg.level = 'WARN' - logger.debug('ReportPortal - Log Message: {0}'.format(message)) + logger.debug(f'ReportPortal - Log Message: {message}') self.service.log(message=msg) @check_rp_enabled @@ -182,8 +163,7 @@ def log_message_with_image(self, msg: Dict, image: str): 'data': fh.read(), 'mime': guess_type(image)[0] or 'application/octet-stream' } - logger.debug('ReportPortal - Log Message with Image: {0} {1}' - .format(mes, image)) + logger.debug(f'ReportPortal - Log Message with Image: {mes} {image}') self.service.log(message=mes) @property @@ -207,18 +187,17 @@ def variables(self) -> Variables: return self._variables @check_rp_enabled - def start_launch(self, attributes: Dict, ts: Optional[Any] = None) -> None: + def start_launch(self, attributes: Dict[str, Any], ts: Optional[Any] = None) -> None: """Start a new launch at the ReportPortal. :param attributes: Dictionary passed by the Robot Framework :param ts: Timestamp(used by the ResultVisitor) """ - launch = Launch(self.variables.launch_name, attributes) - launch.attributes = gen_attributes(self.variables.launch_attributes) + launch = Launch(self.variables.launch_name, attributes, self.variables.launch_attributes) launch.doc = self.variables.launch_doc or launch.doc if self.variables.pabot_used: - warn(PABOT_WIHOUT_LAUNCH_ID_MSG, stacklevel=2) - logger.debug('ReportPortal - Start Launch: {0}'.format(launch.attributes)) + warn(PABOT_WITHOUT_LAUNCH_ID_MSG, stacklevel=2) + logger.debug(f'ReportPortal - Start Launch: {launch.robot_attributes}') self.service.start_launch( launch=launch, mode=self.variables.mode, @@ -237,10 +216,10 @@ def start_suite(self, name: str, attributes: Dict, ts: Optional[Any] = None) -> if attributes['id'] == MAIN_SUITE_ID: self.start_launch(attributes, ts) if self.variables.pabot_used: - name += '.{0}'.format(self.variables.pabot_pool_id) - logger.debug('ReportPortal - Create global Suite: {0}'.format(attributes)) + name = f'{name}.{self.variables.pabot_pool_id}' + logger.debug(f'ReportPortal - Create global Suite: {attributes}') else: - logger.debug('ReportPortal - Start Suite: {0}'.format(attributes)) + logger.debug(f'ReportPortal - Start Suite: {attributes}') suite = Suite(name, attributes) suite.rp_parent_item_id = self.parent_id suite.rp_item_id = self.service.start_suite(suite=suite, ts=ts) @@ -255,12 +234,11 @@ def end_suite(self, _: Optional[str], attributes: Dict, ts: Optional[Any] = None :param ts: Timestamp(used by the ResultVisitor) """ suite = self._remove_current_item().update(attributes) - logger.debug('ReportPortal - End Suite: {0}'.format(suite.attributes)) + logger.debug(f'ReportPortal - End Suite: {suite.robot_attributes}') self.service.finish_suite(suite=suite, ts=ts) if attributes['id'] == MAIN_SUITE_ID: - launch = Launch(self.variables.launch_name, attributes) - logger.debug( - msg='ReportPortal - End Launch: {0}'.format(attributes)) + launch = Launch(self.variables.launch_name, attributes, None) + logger.debug(msg=f'ReportPortal - End Launch: {attributes}') self.service.finish_launch(launch=launch, ts=ts) @check_rp_enabled @@ -275,9 +253,8 @@ def start_test(self, name: str, attributes: Dict, ts: Optional[Any] = None) -> N # no 'source' parameter at this level for Robot versions < 4 attributes = attributes.copy() attributes['source'] = getattr(self.current_item, 'source', None) - test = Test(name=name, attributes=attributes) - logger.debug('ReportPortal - Start Test: {0}'.format(attributes)) - test.attributes = gen_attributes(self.variables.test_attributes + test.tags) + test = Test(name=name, robot_attributes=attributes, test_attributes=self.variables.test_attributes) + logger.debug(f'ReportPortal - Start Test: {attributes}') test.rp_parent_item_id = self.parent_id test.rp_item_id = self.service.start_test(test=test, ts=ts) self._add_current_item(test) @@ -291,13 +268,11 @@ def end_test(self, _: Optional[str], attributes: Dict, ts: Optional[Any] = None) :param ts: Timestamp(used by the ResultVisitor) """ test = self.current_item.update(attributes) - test.attributes = gen_attributes( - self.variables.test_attributes + test.tags) if not test.critical and test.status == 'FAIL': test.status = 'SKIP' if test.message: self.log_message({'message': test.message, 'level': 'DEBUG'}) - logger.debug('ReportPortal - End Test: {0}'.format(test.attributes)) + logger.debug(f'ReportPortal - End Test: {test.robot_attributes}') self._remove_current_item() self.service.finish_test(test=test, ts=ts) @@ -309,9 +284,9 @@ def start_keyword(self, name: str, attributes: Dict, ts: Optional[Any] = None) - :param attributes: Dictionary passed by the Robot Framework :param ts: Timestamp(used by the ResultVisitor) """ - kwd = Keyword(name=name, parent_type=self.current_item.type, attributes=attributes) + kwd = Keyword(name=name, parent_type=self.current_item.type, robot_attributes=attributes) kwd.rp_parent_item_id = self.parent_id - logger.debug('ReportPortal - Start Keyword: {0}'.format(attributes)) + logger.debug(f'ReportPortal - Start Keyword: {attributes}') kwd.rp_item_id = self.service.start_keyword(keyword=kwd, ts=ts) self._add_current_item(kwd) @@ -324,7 +299,7 @@ def end_keyword(self, _: Optional[str], attributes: Dict, ts: Optional[Any] = No :param ts: Timestamp(used by the ResultVisitor) """ kwd = self._remove_current_item().update(attributes) - logger.debug('ReportPortal - End Keyword: {0}'.format(kwd.attributes)) + logger.debug(f'ReportPortal - End Keyword: {kwd.robot_attributes}') self.service.finish_keyword(keyword=kwd, ts=ts) def log_file(self, log_path: str) -> None: diff --git a/robotframework_reportportal/logger.py b/robotframework_reportportal/logger.py index dfffccf..0494971 100644 --- a/robotframework_reportportal/logger.py +++ b/robotframework_reportportal/logger.py @@ -35,29 +35,29 @@ def log_free_memory(self): }, ) """ +from typing import Optional, Dict from robot.api import logger from .model import LogMessage -def write(msg, level='INFO', html=False, attachment=None, launch_log=False): +def write(msg: str, level: str = 'INFO', html: bool = False, attachment: Optional[Dict[str, str]] = None, + launch_log: bool = False) -> None: """Write the message to the log file using the given level. - Valid log levels are ``TRACE``, ``DEBUG``, ``INFO`` (default since RF - 2.9.1), ``WARN``, and ``ERROR`` (new in RF 2.9). Additionally it is - possible to use ``HTML`` pseudo log level that logs the message as HTML - using the ``INFO`` level. + Valid log levels are ``TRACE``, ``DEBUG``, ``INFO`` (default since RF 2.9.1), ``WARN``, + and ``ERROR`` (new in RF 2.9). Additionally, it is possible to use ``HTML`` pseudo log level that logs the message + as HTML using the ``INFO`` level. - Attachment should contain a dict with "name", "data" and "mime" values - defined. See module example. + Attachment should contain a dict with "name", "data" and "mime" values defined. See module example. - Instead of using this method, it is generally better to use the level - specific methods such as ``info`` and ``debug`` that have separate + Instead of using this method, it is generally better to use the level specific methods such as ``info`` and + ``debug`` that have separate - :param msg: argument to control the message format. - :param level: log level - :param html: format or not format the message as html. + :param msg: argument to control the message format. + :param level: log level + :param html: format or not format the message as html. :param attachment: a binary content to attach to the log entry :param launch_log: put the log entry on Launch level """ @@ -68,46 +68,43 @@ def write(msg, level='INFO', html=False, attachment=None, launch_log=False): logger.write(log_message, level, html) -def trace(msg, html=False, attachment=None, launch_log=False): +def trace(msg: str, html: bool = False, attachment: Optional[Dict[str, str]] = None, launch_log: bool = False) -> None: """Write the message to the log file using the ``TRACE`` level.""" write(msg, "TRACE", html, attachment, launch_log) -def debug(msg, html=False, attachment=None, launch_log=False): +def debug(msg: str, html: bool = False, attachment: Optional[Dict[str, str]] = None, launch_log: bool = False) -> None: """Write the message to the log file using the ``DEBUG`` level.""" write(msg, "DEBUG", html, attachment, launch_log) -def info(msg, html=False, also_console=False, attachment=None, - launch_log=False): +def info(msg: str, html: bool = False, also_console: bool = False, attachment: Optional[Dict[str, str]] = None, + launch_log: bool = False): """Write the message to the log file using the ``INFO`` level. - If ``also_console`` argument is set to ``True``, the message is - written both to the log file and to the console. + If ``also_console`` argument is set to ``True``, the message is written both to the log file and to the console. """ write(msg, "INFO", html, attachment, launch_log) if also_console: console(msg) -def warn(msg, html=False, attachment=None, launch_log=False): +def warn(msg: str, html: bool = False, attachment: Optional[Dict[str, str]] = None, launch_log: bool = False) -> None: """Write the message to the log file using the ``WARN`` level.""" - write(msg, "WARN", html, attachment, launch_log) + write(msg, 'WARN', html, attachment, launch_log) -def error(msg, html=False, attachment=None, launch_log=False): +def error(msg: str, html: bool = False, attachment: Optional[Dict[str, str]] = None, launch_log: bool = False) -> None: """Write the message to the log file using the ``ERROR`` level.""" - write(msg, "ERROR", html, attachment, launch_log) + write(msg, 'ERROR', html, attachment, launch_log) -def console(msg, newline=True, stream="stdout"): +def console(msg: str, newline: bool = True, stream: str = 'stdout') -> None: """Write the message to the console. - If the ``newline`` argument is ``True``, a newline character is - automatically added to the message. + If the ``newline`` argument is ``True``, a newline character is automatically added to the message. - By default the message is written to the standard output stream. - Using the standard error stream is possibly by giving the ``stream`` - argument value ``'stderr'``. + By default, the message is written to the standard output stream. + Using the standard error stream is possibly by giving the ``stream`` argument value ``'stderr'``. """ logger.console(msg, newline, stream) diff --git a/robotframework_reportportal/model.py b/robotframework_reportportal/model.py index 969b623..d530987 100644 --- a/robotframework_reportportal/model.py +++ b/robotframework_reportportal/model.py @@ -1,55 +1,84 @@ -# Copyright (c) 2023 EPAM Systems +# Copyright 2024 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 +# 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 +# limitations under the License. """This module contains models representing Robot Framework test items.""" import os +from typing import Any, Dict, List, Optional, Union + +from reportportal_client.helpers import gen_attributes class Suite: """Class represents Robot Framework test suite.""" - def __init__(self, name, attributes): + robot_attributes: Union[List[str], Dict[str, Any]] + doc: str + end_time: str + longname: str + message: str + metadata: Dict[str, str] + name: str + robot_id: str + rp_item_id: Optional[str] + rp_parent_item_id: Optional[str] + start_time: Optional[str] + statistics: str + status: str + suites: List[str] + tests: List[str] + total_tests: int + type: str = 'SUITE' + + def __init__(self, name: str, robot_attributes: Dict[str, Any]): """Initialize required attributes. :param name: Suite name - :param attributes: Suite attributes passed through the listener + :param robot_attributes: Suite attributes passed through the listener """ - self.attributes = attributes - self.doc = attributes['doc'] - self.end_time = attributes.get('endtime', '') - self.longname = attributes['longname'] - self.message = attributes.get('message') - self.metadata = attributes['metadata'] + self.robot_attributes = robot_attributes + self.doc = robot_attributes['doc'] + self.end_time = robot_attributes.get('endtime', '') + self.longname = robot_attributes['longname'] + self.message = robot_attributes.get('message') + self.metadata = robot_attributes['metadata'] self.name = name - self.robot_id = attributes['id'] + self.robot_id = robot_attributes['id'] self.rp_item_id = None self.rp_parent_item_id = None - self.start_time = attributes.get('starttime') - self.statistics = attributes.get('statistics') - self.status = attributes.get('status') - self.suites = attributes['suites'] - self.tests = attributes['tests'] - self.total_tests = attributes['totaltests'] + self.start_time = robot_attributes.get('starttime') + self.statistics = robot_attributes.get('statistics') + self.status = robot_attributes.get('status') + self.suites = robot_attributes['suites'] + self.tests = robot_attributes['tests'] + self.total_tests = robot_attributes['totaltests'] self.type = 'SUITE' @property - def source(self): + def attributes(self) -> Optional[List[Dict[str, str]]]: + """Get Suite attributes.""" + if self.metadata is None or not self.metadata: + return None + return [{'key': key, 'value': value} for key, value in self.metadata.items()] + + @property + def source(self) -> str: """Return the test case source file path.""" - if self.attributes.get('source') is not None: - return os.path.relpath(self.attributes['source'], os.getcwd()) + if self.robot_attributes.get('source') is not None: + return os.path.relpath(self.robot_attributes['source'], os.getcwd()) - def update(self, attributes): + def update(self, attributes: Dict[str, Any]) -> 'Suite': """Update suite attributes on suite finish. :param attributes: Suite attributes passed through the listener @@ -64,74 +93,105 @@ def update(self, attributes): class Launch(Suite): """Class represents Robot Framework test suite.""" - def __init__(self, name, attributes): + launch_attributes: Optional[List[Dict[str, str]]] + type: str = 'LAUNCH' + + def __init__(self, name: str, robot_attributes: Dict[str, Any], launch_attributes: Optional[List[str]]): """Initialize required attributes. :param name: Launch name - :param attributes: Suite attributes passed through the listener + :param robot_attributes: Suite attributes passed through the listener + :param launch_attributes: Launch attributes from variables """ - # noinspection PySuperArguments - super(Launch, self).__init__(name, attributes) + super().__init__(name, robot_attributes) + self.launch_attributes = gen_attributes(launch_attributes or []) self.type = 'LAUNCH' + @property + def attributes(self) -> Optional[List[Dict[str, str]]]: + """Get Launch attributes.""" + return self.launch_attributes + class Test: """Class represents Robot Framework test case.""" - def __init__(self, name, attributes): + _critical: str + _tags: List[str] + robot_attributes: Dict[str, Any] + test_attributes: Optional[List[Dict[str, str]]] + doc: str + end_time: str + longname: str + message: str + name: str + robot_id: str + rp_item_id: Optional[str] + rp_parent_item_id: Optional[str] + start_time: str + status: str + template: str + type: str = 'TEST' + + def __init__(self, name: str, robot_attributes: Dict[str, Any], test_attributes: List[str]): """Initialize required attributes. - :param name: Name of the test - :param attributes: Test attributes passed through the listener + :param name: Name of the test + :param robot_attributes: Attributes passed through the listener """ # for backward compatibility with Robot < 4.0 mark every test case # as critical if not set - self._critical = attributes.get('critical', 'yes') - self._tags = attributes['tags'] - self._attributes = attributes - self.doc = attributes['doc'] - self.end_time = attributes.get('endtime', '') - self.longname = attributes['longname'] - self.message = attributes.get('message') + self._critical = robot_attributes.get('critical', 'yes') + self._tags = robot_attributes['tags'] + self.test_attributes = gen_attributes(test_attributes) + self.robot_attributes = robot_attributes + self.doc = robot_attributes['doc'] + self.end_time = robot_attributes.get('endtime', '') + self.longname = robot_attributes['longname'] + self.message = robot_attributes.get('message') self.name = name - self.robot_id = attributes['id'] + self.robot_id = robot_attributes['id'] self.rp_item_id = None self.rp_parent_item_id = None - self.start_time = attributes['starttime'] - self.status = attributes.get('status') - self.template = attributes['template'] + self.start_time = robot_attributes['starttime'] + self.status = robot_attributes.get('status') + self.template = robot_attributes['template'] self.type = 'TEST' @property - def critical(self): + def critical(self) -> bool: """Form unique value for RF 4.0+ and older versions.""" return self._critical in ('yes', True) @property - def tags(self): + def tags(self) -> List[str]: """Get list of test tags excluding test_case_id.""" - return [ - tag for tag in self._tags if not tag.startswith('test_case_id')] + return [tag for tag in self._tags if not tag.startswith('test_case_id')] @property - def source(self): + def attributes(self) -> Optional[List[Dict[str, str]]]: + """Get Test attributes.""" + return self.test_attributes + gen_attributes(self.tags) + + @property + def source(self) -> str: """Return the test case source file path.""" - if self._attributes['source'] is not None: - return os.path.relpath(self._attributes['source'], os.getcwd()) + if self.robot_attributes['source'] is not None: + return os.path.relpath(self.robot_attributes['source'], os.getcwd()) @property - def code_ref(self): + def code_ref(self) -> str: """Return the test case code reference. The result line should be exactly how it appears in '.robot' file. """ - line_number = self._attributes.get("lineno") + line_number = self.robot_attributes.get("lineno") if line_number is not None: return '{0}:{1}'.format(self.source, line_number) return '{0}:{1}'.format(self.source, self.name) @property - def test_case_id(self): + def test_case_id(self) -> Optional[str]: """Get test case ID through the tags.""" # use test case id from tags if specified for tag in self._tags: @@ -140,7 +200,7 @@ def test_case_id(self): # generate it if not return '{0}:{1}'.format(self.source, self.name) - def update(self, attributes): + def update(self, attributes: Dict[str, Any]) -> 'Test': """Update test attributes on test finish. :param attributes: Suite attributes passed through the listener @@ -155,31 +215,48 @@ def update(self, attributes): class Keyword: """Class represents Robot Framework keyword.""" - def __init__(self, name, attributes, parent_type=None): + robot_attributes: Dict[str, Any] + args: List[str] + assign: List[str] + doc: str + end_time: str + keyword_name: str + keyword_type: str + libname: str + name: str + rp_item_id: Optional[str] + rp_parent_item_id: Optional[str] + parent_type: str + start_time: str + status: str + tags: List[str] + type: str = 'KEYWORD' + + def __init__(self, name: str, robot_attributes: Dict[str, Any], parent_type: Optional[str] = None): """Initialize required attributes. - :param name: Name of the keyword - :param attributes: Keyword attributes passed through the listener - :param parent_type: Type of the parent test item + :param name: Name of the keyword + :param robot_attributes: Attributes passed through the listener + :param parent_type: Type of the parent test item """ - self.attributes = attributes - self.args = attributes['args'] - self.assign = attributes['assign'] - self.doc = attributes['doc'] - self.end_time = attributes.get('endtime') - self.keyword_name = attributes['kwname'] - self.keyword_type = attributes['type'] - self.libname = attributes['libname'] + self.robot_attributes = robot_attributes + self.args = robot_attributes['args'] + self.assign = robot_attributes['assign'] + self.doc = robot_attributes['doc'] + self.end_time = robot_attributes.get('endtime') + self.keyword_name = robot_attributes['kwname'] + self.keyword_type = robot_attributes['type'] + self.libname = robot_attributes['libname'] self.name = name self.rp_item_id = None self.rp_parent_item_id = None self.parent_type = parent_type - self.start_time = attributes['starttime'] - self.status = attributes.get('status') - self.tags = attributes['tags'] + self.start_time = robot_attributes['starttime'] + self.status = robot_attributes.get('status') + self.tags = robot_attributes['tags'] self.type = 'KEYWORD' - def get_name(self): + def get_name(self) -> str: """Get name of the keyword suitable for ReportPortal.""" assign = ', '.join(self.assign) assignment = '{0} = '.format(assign) if self.assign else '' @@ -187,7 +264,7 @@ def get_name(self): full_name = f'{assignment}{self.name} ({arguments})' return full_name[:256] - def get_type(self): + def get_type(self) -> str: """Get keyword type.""" if self.keyword_type.lower() in ('setup', 'teardown'): if self.parent_type.lower() == 'keyword': @@ -199,7 +276,7 @@ def get_type(self): else: return 'STEP' - def update(self, attributes): + def update(self, attributes: Dict[str, Any]) -> 'Keyword': """Update keyword attributes on keyword finish. :param attributes: Suite attributes passed through the listener @@ -212,14 +289,16 @@ def update(self, attributes): class LogMessage(str): """Class represents Robot Framework messages.""" - def __init__(self, message): + attachment: Optional[Dict[str, str]] + launch_log: bool + item_id: Optional[str] + level: str + message: str + + def __init__(self, message: str): """Initialize required attributes.""" self.attachment = None self.item_id = None self.level = 'INFO' self.launch_log = False self.message = message - - def __repr__(self): - """Return string representation of the object.""" - return self.message diff --git a/robotframework_reportportal/model.pyi b/robotframework_reportportal/model.pyi deleted file mode 100644 index 79fd73d..0000000 --- a/robotframework_reportportal/model.pyi +++ /dev/null @@ -1,101 +0,0 @@ -# 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 Any, Dict, List, Optional, Tuple, Union - -class Suite: - attributes: Union[List[str], Dict[str, Any]] = ... - doc: str = ... - end_time: str = ... - longname: str = ... - message: str = ... - metadata: Dict[str, str] = ... - name: str = ... - robot_id: str = ... - rp_item_id: Optional[str] = ... - rp_parent_item_id: Optional[str] = ... - start_time: Optional[str] = ... - statistics: str = ... - status: str = ... - suites: List[str] = ... - tests: List[str] = ... - total_tests: int = ... - type: str = 'SUITE' - def __init__(self, name: str, attributes: Dict[str, Any]) -> None: ... - @property - def source(self) -> str: ... - def update(self, attributes: Dict[str, Any]) -> Union[Launch, Suite]: ... - -class Launch(Suite): - type: str = 'LAUNCH' - def __init__(self, name: str, attributes: Dict[str, Any]) -> None: ... - -class Test: - _critical: str = ... - _tags: List[str] = ... - _attributes: Dict[str, Any] = ... - attributes: List[Dict[str, str]] = ... - doc: str = ... - end_time: str = ... - longname: str = ... - message: str = ... - name: str = ... - robot_id: str = ... - rp_item_id: Optional[str] = ... - rp_parent_item_id: Optional[str] = ... - start_time: str = ... - status: str = ... - template: str = ... - type: str = 'TEST' - def __init__(self, name: str, attributes: Dict[str, Any]) -> None: ... - @property - def critical(self) -> bool: ... - @property - def tags(self) -> List[str]: ... - @property - def source(self) -> str: ... - @property - def code_ref(self) -> str: ... - @property - def test_case_id(self) -> Optional[str]: ... - def update(self, attributes: Dict[str, Any]) -> Test: ... - -class Keyword: - attributes: Dict[str, Any] = ... - args: List[str] = ... - assign: List[str] = ... - doc: str = ... - end_time: str = ... - keyword_name: str = ... - keyword_type: str = ... - libname: str = ... - name: str = ... - rp_item_id: Optional[str] = ... - rp_parent_item_id: Optional[str] = ... - parent_type: str = ... - start_time: str = ... - status: str = ... - tags: List[str] = ... - type: str = 'KEYWORD' - def __init__(self, name: str, attributes: Dict[str, Any], parent_type: Optional[str] = None) -> None: ... - def get_name(self) -> str: ... - def get_type(self) -> str: ... - def update(self, attributes: Dict[str, Any]) -> Keyword: ... - -class LogMessage(str): - attachment: Optional[Dict[str, str]] = ... - launch_log: bool = ... - item_id: Optional[str] = ... - level: str = ... - message: str = ... - def __init__(self, *args: Tuple, **kwargs: Dict) -> None: ... diff --git a/robotframework_reportportal/service.py b/robotframework_reportportal/service.py index b5bd69a..8cc1869 100644 --- a/robotframework_reportportal/service.py +++ b/robotframework_reportportal/service.py @@ -111,13 +111,13 @@ def start_launch(self, launch: Launch, mode: Optional[str] = None, rerun: bool = ts: Optional[str] = None) -> Optional[str]: """Call start_launch method of the common client. - :param launch: Instance of the Launch class - :param mode: Launch mode - :param rerun: Rerun mode. Allowable values 'True' of 'False' - :param rerun_of: Rerun mode. Specifies launch to be re-runned. - Should be used with the 'rerun' option. - :param ts: Start time - :return: launch UUID + :param launch: Instance of the Launch class + :param mode: Launch mode + :param rerun: Rerun mode. Allowable values 'True' of 'False' + :param rerun_of: Rerun mode. Specifies launch to be re-run. + Should be used with the 'rerun' option. + :param ts: Start time + :return: launch UUID """ sl_pt = { 'attributes': self._get_launch_attributes(launch.attributes), @@ -152,7 +152,7 @@ def start_suite(self, suite: Suite, ts: Optional[str] = None) -> Optional[str]: :return: Suite UUID """ start_rq = { - 'attributes': None, + 'attributes': suite.attributes, 'description': suite.doc, 'item_type': suite.type, 'name': suite.name, diff --git a/robotframework_reportportal/static.py b/robotframework_reportportal/static.py index 856c465..05b578d 100644 --- a/robotframework_reportportal/static.py +++ b/robotframework_reportportal/static.py @@ -14,7 +14,9 @@ """This module includes static variables of the agent.""" -LOG_LEVEL_MAPPING = { +from typing import Dict + +LOG_LEVEL_MAPPING: Dict[str, str] = { 'INFO': 'INFO', 'FAIL': 'ERROR', 'TRACE': 'TRACE', @@ -24,11 +26,10 @@ 'ERROR': 'ERROR', 'SKIP': 'INFO' } -MAIN_SUITE_ID = 's1' -PABOT_WIHOUT_LAUNCH_ID_MSG = ( - 'Pabot library is used but RP_LAUNCH_UUID was not provided. Please, ' - 'initialize listener with the RP_LAUNCH_UUID argument.') -STATUS_MAPPING = { +MAIN_SUITE_ID: str = 's1' +PABOT_WITHOUT_LAUNCH_ID_MSG: str = ('Pabot library is used but RP_LAUNCH_UUID was not provided. Please, ' + 'initialize listener with the RP_LAUNCH_UUID argument.') +STATUS_MAPPING: Dict[str, str] = { 'PASS': 'PASSED', 'FAIL': 'FAILED', 'NOT RUN': 'SKIPPED', diff --git a/robotframework_reportportal/static.pyi b/robotframework_reportportal/static.pyi deleted file mode 100644 index 4f6a19e..0000000 --- a/robotframework_reportportal/static.pyi +++ /dev/null @@ -1,20 +0,0 @@ -# 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. - -from typing import Text, Dict - -LOG_LEVEL_MAPPING: Dict -MAIN_SUITE_ID: Text -PABOT_WIHOUT_LAUNCH_ID_MSG: Text -STATUS_MAPPING: Dict diff --git a/robotframework_reportportal/variables.py b/robotframework_reportportal/variables.py index 2b62f1a..a12727b 100644 --- a/robotframework_reportportal/variables.py +++ b/robotframework_reportportal/variables.py @@ -15,7 +15,7 @@ from distutils.util import strtobool from os import path -from typing import Optional, Union, Dict, Tuple, Any +from typing import Optional, Union, Dict, Tuple, Any, List from warnings import warn from reportportal_client import OutputType, ClientType @@ -42,27 +42,27 @@ def get_variable(name: str, default: Optional[str] = None) -> Optional[str]: class Variables: """This class stores Robot Framework variables related to ReportPortal.""" - enabled: bool = ... - endpoint: Optional[str] = ... - launch_name: Optional[str] = ... - _pabot_pool_id: Optional[int] = ... - _pabot_used: Optional[str] = ... - project: Optional[str] = ... - api_key: Optional[str] = ... - attach_log: bool = ... - attach_report: bool = ... - attach_xunit: bool = ... - launch_attributes: list = ... - launch_id: Optional[str] = ... - launch_doc: Optional[str] = ... - log_batch_size: Optional[int] = ... - mode: Optional[str] = ... - pool_size: Optional[int] = ... - rerun: bool = ... - rerun_of: Optional[str] = ... - test_attributes: Optional[list] = ... - skipped_issue: bool = ... - log_batch_payload_size: int = ... + enabled: bool + endpoint: Optional[str] + launch_name: Optional[str] + _pabot_pool_id: Optional[int] + _pabot_used: Optional[str] + project: Optional[str] + api_key: Optional[str] + attach_log: bool + attach_report: bool + attach_xunit: bool + launch_attributes: List[str] + launch_id: Optional[str] + launch_doc: Optional[str] + log_batch_size: Optional[int] + mode: Optional[str] + pool_size: Optional[int] + rerun: bool + rerun_of: Optional[str] + test_attributes: List[str] + skipped_issue: bool + log_batch_payload_size: int launch_uuid_print: bool launch_uuid_print_output: Optional[OutputType] client_type: ClientType @@ -78,12 +78,9 @@ def __init__(self) -> None: self._pabot_used = None self.attach_log = bool(strtobool(get_variable( 'RP_ATTACH_LOG', default='False'))) - self.attach_report = bool(strtobool(get_variable( - 'RP_ATTACH_REPORT', default='False'))) - self.attach_xunit = bool(strtobool(get_variable( - 'RP_ATTACH_XUNIT', default='False'))) - self.launch_attributes = get_variable( - 'RP_LAUNCH_ATTRIBUTES', default='').split() + self.attach_report = bool(strtobool(get_variable('RP_ATTACH_REPORT', default='False'))) + self.attach_xunit = bool(strtobool(get_variable('RP_ATTACH_XUNIT', default='False'))) + self.launch_attributes = get_variable('RP_LAUNCH_ATTRIBUTES', default='').split() self.launch_id = get_variable('RP_LAUNCH_UUID') self.launch_doc = get_variable('RP_LAUNCH_DOC') self.log_batch_size = int(get_variable( @@ -95,11 +92,9 @@ def __init__(self) -> None: self.rerun_of = get_variable('RP_RERUN_OF', default=None) self.skipped_issue = bool(strtobool(get_variable( 'RP_SKIPPED_ISSUE', default='True'))) - self.test_attributes = get_variable( - 'RP_TEST_ATTRIBUTES', default='').split() - self.log_batch_payload_size = int(get_variable( - 'RP_LOG_BATCH_PAYLOAD_SIZE', - default=str(MAX_LOG_BATCH_PAYLOAD_SIZE))) + self.test_attributes = get_variable('RP_TEST_ATTRIBUTES', default='').split() + self.log_batch_payload_size = int(get_variable('RP_LOG_BATCH_PAYLOAD_SIZE', + default=str(MAX_LOG_BATCH_PAYLOAD_SIZE))) self.launch_uuid_print = bool(strtobool(get_variable('RP_LAUNCH_UUID_PRINT', default='False'))) output_type = get_variable('RP_LAUNCH_UUID_PRINT_OUTPUT') self.launch_uuid_print_output = OutputType[output_type.upper()] if output_type else None diff --git a/setup.py b/setup.py index f96de59..865cb4e 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ from setuptools import setup -__version__ = '5.5.2' +__version__ = '5.5.3' def read_file(fname): diff --git a/tests/integration/test_suite_metadata.py b/tests/integration/test_suite_metadata.py new file mode 100644 index 0000000..2eaa7e7 --- /dev/null +++ b/tests/integration/test_suite_metadata.py @@ -0,0 +1,60 @@ +# 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. + +from tests.helpers import utils +from unittest import mock + +from tests import REPORT_PORTAL_SERVICE + + +SIMPLE_TEST = 'examples/suite_metadata.robot' + + +@mock.patch(REPORT_PORTAL_SERVICE) +def test_suite_metadata_simple(mock_client_init): + result = utils.run_robot_tests([SIMPLE_TEST]) + assert result == 0 # the test successfully passed + + mock_client = mock_client_init.return_value + launch_start = mock_client.start_launch.call_args_list + launch_finish = mock_client.finish_launch.call_args_list + assert len(launch_start) == len(launch_finish) == 1 + + item_start_calls = mock_client.start_test_item.call_args_list + item_finish_calls = mock_client.finish_test_item.call_args_list + assert len(item_start_calls) == len(item_finish_calls) == 3 + + test_suite = item_start_calls[0] + assert test_suite[1]['attributes'] == [{'key': 'Author', 'value': 'John Doe'}] + + +@mock.patch(REPORT_PORTAL_SERVICE) +def test_suite_metadata_command_line_simple(mock_client_init): + result = utils.run_robot_tests([SIMPLE_TEST], arguments={'--metadata': 'Scope:Smoke'}) + assert result == 0 # the test successfully passed + + mock_client = mock_client_init.return_value + launch_start = mock_client.start_launch.call_args_list + launch_finish = mock_client.finish_launch.call_args_list + assert len(launch_start) == len(launch_finish) == 1 + + item_start_calls = mock_client.start_test_item.call_args_list + item_finish_calls = mock_client.finish_test_item.call_args_list + assert len(item_start_calls) == len(item_finish_calls) == 3 + + test_suite = item_start_calls[0] + attributes = test_suite[1]['attributes'] + assert len(attributes) == 2 + assert {'value': 'Smoke', 'key': 'Scope'} in attributes + assert {'key': 'Author', 'value': 'John Doe'} in attributes diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py index 5d3ea6d..12a8cc7 100644 --- a/tests/unit/test_model.py +++ b/tests/unit/test_model.py @@ -25,7 +25,7 @@ ]) def test_keyword_get_type(kwd_attributes, self_type, parent_type, expected): """Test for the get_type() method of the Keyword model.""" - kwd = Keyword(name='Test keyword', attributes=kwd_attributes, + kwd = Keyword(name='Test keyword', robot_attributes=kwd_attributes, parent_type=parent_type) kwd.keyword_type = self_type assert kwd.get_type() == expected