Repository: reportportal/agent-Python-RobotFramework

Issue:
Add 'metadata' parameters processing for Launch and Suite level
**Is your feature request related to a problem? Please describe.**
We now have many test suites in one launch. But we can't input the suite attribute. It is difficult to filter them and sort them.


**Describe the solution you'd like**
I would like to have available suite attribute settings the same way as launch attributes or tets attributes in parameters. At least. 

**Describe alternatives you've considered**
I considered my client which would access the API of the report portal and set it. But it is an unsystematic approach and I think more people appreciate this feature.

I considered transferring these suite attributes to launch attributes.
But, attributes for launch are limited in length so can't be all attributes from the suite transferred to launch. 
When there is too long an attribute you can't set it for launch (you give an error on API, level). 



Discussion:

Patch:
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 os
 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 check_rp_enabled(func):
 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 @@ class listener:
                 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 @@ class listener:
                 '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 @@ class listener:
         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 @@ class listener:
         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 @@ class listener:
         :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 @@ class listener:
             # 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 @@ class listener:
         :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 @@ class listener:
         :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 @@ class listener:
         :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 @@ class MyLibrary(object):
             },
         )
 """
+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 @@ class Suite:
 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 @@ class Test:
         # 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 @@ class Test:
 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 @@ class Keyword:
         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 @@ class Keyword:
         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 @@ class Keyword:
 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 @@ class RobotService(object):
                      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 @@ class RobotService(object):
         :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 @@ LOG_LEVEL_MAPPING = {
     '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 @@ class Variables:
         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 @@ class Variables:
         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 @@ import os
 from setuptools import setup
 
 
-__version__ = '5.5.2'
+__version__ = '5.5.3'
 
 
 def read_file(fname):


Test patch:
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 @@ from robotframework_reportportal.model import Keyword
 ])
 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