add update_xml_result to qa reporting tools

Change-Id: I09980290276ae38c8c90db8772681b3b7aea810f
Related-prod: PRODX-942
diff --git a/update_testrail_xml/README.md b/update_testrail_xml/README.md
new file mode 100644
index 0000000..680822f
--- /dev/null
+++ b/update_testrail_xml/README.md
@@ -0,0 +1,26 @@
+TestRail xUnit Updater
+======================
+
+This updater helps to update *.xml report if test-suite or test-class has failed.
+
+How to use
+----------
+
+Just update all the parameters in the update_xml.sh file
+
+```
+export TESTRAIL_PLAN_NAME="" \
+export TESTRAIL_URL="" \
+export TESTRAIL_SUITE="" \
+export TESTRAIL_PROJECT="" \
+export TESTRAIL_MILESTONE="" \
+export TESTRAIL_USER="" \
+export TEST_GROUP="" \
+export OUTPUT_XUNIT_REPORT="" \
+export PASTE_BASE_URL="" \
+export TESTRAIL_PASSWORD=""
+```
+
+Run update_xml.sh script
+
+``# . update_xml.sh``
diff --git a/update_testrail_xml/client.py b/update_testrail_xml/client.py
new file mode 100644
index 0000000..499b1b9
--- /dev/null
+++ b/update_testrail_xml/client.py
@@ -0,0 +1,234 @@
+from __future__ import absolute_import
+import logging
+import time
+
+import requests
+
+logger = logging.getLogger(__name__)
+
+requests_logger = logging.getLogger('requests.packages.urllib3')
+requests_logger.setLevel(logging.WARNING)
+
+
+class ItemSet(list):
+    def __init__(self, *args, **kwargs):
+        self._item_class = None
+        return super(ItemSet, self).__init__(*args, **kwargs)
+
+    def find_all(self, **kwargs):
+        filtered = ItemSet(
+            x for x in self
+            if all(getattr(x, k) == v for k, v in kwargs.items()))
+        filtered._item_class = self._item_class
+        return filtered
+
+    def find(self, **kwargs):
+        items = self.find_all(**kwargs)
+        if items:
+            return items[0]
+        else:
+            raise NotFound(self._item_class, **kwargs)
+
+
+class Collection(object):
+
+    _list_url = 'get_{name}s'
+    _add_url = 'add_{name}'
+
+    def __init__(self, item_class=None, parent_id=None, **kwargs):
+        self._item_class = item_class
+        self._handler = self._item_class._handler
+        self.parent_id = parent_id
+        for k, v in kwargs.items():
+            setattr(self, k, v)
+
+    def __call__(self, id=None):
+        name = self._item_class._api_name()
+        if id is None:
+            items = self._list(name)
+            if 'error' in items:
+                raise Exception(items)
+            items = ItemSet(self._to_object(x) for x in items)
+            items._item_class = self._item_class
+            return items
+
+        else:
+            return self._item_class.get(id)
+
+    def __repr__(self):
+        return '<Collection of {}>'.format(self._item_class.__name__)
+
+    def _to_object(self, data):
+        return self._item_class(**data)
+
+    def _list(self, name, params=None):
+        params = params or {}
+        url = self._list_url.format(name=name)
+        if self.parent_id is not None:
+            url += '/{}'.format(self.parent_id)
+        return self._handler('GET', url, params=params)
+
+    def find_all(self, **kwargs):
+        return self().find_all(**kwargs)
+
+    def find(self, **kwargs):
+        # if plan is searched perform an additional GET request to API
+        # in order to return full its data including 'entries' field
+        # see http://docs.gurock.com/testrail-api2/reference-plans#get_plans
+        if self._item_class is Plan:
+            return self.get(self().find(**kwargs).id)
+        return self().find(**kwargs)
+
+    def get(self, id):
+        return self._item_class.get(id)
+
+    def list(self):
+        name = self._item_class._api_name()
+        return ItemSet([self._item_class(**i) for i in self._list(name=name)])
+
+
+class Item(object):
+    _get_url = 'get_{name}/{id}'
+    _update_url = 'update_{name}/{id}'
+    _handler = None
+    _repr_field = 'name'
+
+    def __init__(self, id=None, **kwargs):
+        self.id = id
+        self._data = kwargs
+
+    @classmethod
+    def _api_name(cls):
+        return cls.__name__.lower()
+
+    def __getattr__(self, name):
+        if name in self._data:
+            return self._data[name]
+        else:
+            raise AttributeError
+
+    def __setattr__(self, name, value):
+        if '_data' in self.__dict__ and name not in self.__dict__:
+            self.__dict__['_data'][name] = value
+        else:
+            self.__dict__[name] = value
+
+    def __repr__(self):
+        name = getattr(self, self._repr_field, '')
+        name = repr(name)
+        return '<{c.__name__}({s.id}) {name} at 0x{id:x}>'.format(
+            s=self, c=self.__class__, id=id(self), name=name)
+
+    @classmethod
+    def get(cls, id):
+        name = cls._api_name()
+        url = cls._get_url.format(name=name, id=id)
+        result = cls._handler('GET', url)
+        if 'error' in result:
+            raise Exception(result)
+        return cls(**result)
+
+    def update(self):
+        url = self._update_url.format(name=self._api_name(), id=self.id)
+        self._handler('POST', url, json=self.data)
+
+    @property
+    def data(self):
+        return self._data
+
+
+class Project(Item):
+    @property
+    def suites(self):
+        return Collection(Suite, parent_id=self.id)
+
+
+class Suite(Item):
+    @property
+    def cases(self):
+        return CaseCollection(
+            Case,
+            _list_url='get_cases/{}&suite_id={}'.format(self.project_id,
+                                                        self.id))
+
+
+class CaseCollection(Collection):
+    pass
+
+
+class Case(Item):
+    pass
+
+
+class Plan(Item):
+    def __init__(self,
+                 name,
+                 description=None,
+                 milestone_id=None,
+                 entries=None,
+                 id=None,
+                 **kwargs):
+        add_kwargs = {
+            'name': name,
+            'description': description,
+            'milestone_id': milestone_id,
+            'entries': entries or [],
+        }
+        kwargs.update(add_kwargs)
+        return super(self.__class__, self).__init__(id, **kwargs)
+
+
+class Client(object):
+    def __init__(self, base_url, username, password):
+        self.username = username
+        self.password = password
+        self.base_url = base_url.rstrip('/') + '/index.php?/api/v2/'
+
+        Item._handler = self._query
+
+    def _query(self, method, url, **kwargs):
+        url = self.base_url + url
+        headers = {'Content-type': 'application/json'}
+        logger.debug('Make {} request to {}'.format(method, url))
+        for _ in range(5):
+            response = requests.request(
+                method,
+                url,
+                allow_redirects=False,
+                auth=(self.username, self.password),
+                headers=headers,
+                **kwargs)
+            # To many requests
+            if response.status_code == 429:
+                time.sleep(60)
+                continue
+            else:
+                break
+        # Redirect or error
+        if response.status_code >= 300:
+            raise requests.HTTPError("Wrong response:\n"
+                                     "status_code: {0.status_code}\n"
+                                     "headers: {0.headers}\n"
+                                     "content: '{0.content}'".format(response),
+                                     response=response)
+        result = response.json()
+        if 'error' in result:
+            logger.warning(result)
+        return result
+
+    @property
+    def projects(self):
+        return Collection(Project)
+
+
+class NotFound(Exception):
+    def __init__(self, item_class, **conditions):
+        self.item_class = item_class
+        self.conditions = conditions
+
+    def __str__(self):
+        conditions = ', '.join(['{}="{}"'.format(x, y)
+                               for (x, y) in self.conditions.items()])
+        return u'{type} with {conditions}'.format(
+            type=self.item_class._api_name().title(),
+            conditions=conditions)
\ No newline at end of file
diff --git a/update_testrail_xml/cmd.py b/update_testrail_xml/cmd.py
new file mode 100644
index 0000000..9238200
--- /dev/null
+++ b/update_testrail_xml/cmd.py
@@ -0,0 +1,217 @@
+#!/usr/bin/env python
+
+import argparse
+import logging
+import os
+import sys
+import traceback
+import warnings
+
+from reporter import Reporter
+
+warnings.simplefilter('always', DeprecationWarning)
+logger = logging.getLogger(__name__)
+
+if sys.version_info[0] == 3:
+    str_cls = str
+else:
+    str_cls = eval('unicode')
+
+
+def filename(string):
+    if not os.path.exists(string):
+        msg = "%r is not exists" % string
+        raise argparse.ArgumentTypeError(msg)
+    if not os.path.isfile(string):
+        msg = "%r is not a file" % string
+        raise argparse.ArgumentTypeError(msg)
+    return string
+
+
+def parse_args(args):
+    defaults = {
+        'TESTRAIL_URL': 'https://mirantis.testrail.com',
+        'TESTRAIL_USER': 'user@example.com',
+        'TESTRAIL_PASSWORD': 'password',
+        'TESTRAIL_PROJECT': 'Mirantis OpenStack',
+        'TESTRAIL_MILESTONE': '9.0',
+        'TESTRAIL_TEST_SUITE': '[{0.testrail_milestone}] MOSQA',
+        'XUNIT_REPORT': 'report.xml',
+        'OUTPUT_XUNIT_REPORT': 'output_report.xml',
+        'XUNIT_NAME_TEMPLATE': '{id}',
+        'TESTRAIL_NAME_TEMPLATE': '{custom_report_label}',
+        'ISO_ID': None,
+        'TESTRAIL_PLAN_NAME': None,
+        'ENV_DESCRIPTION': '',
+        'TEST_RESULTS_LINK': '',
+        'PASTE_BASE_URL': None
+    }
+    defaults = {k: os.environ.get(k, v) for k, v in defaults.items()}
+
+    parser = argparse.ArgumentParser(description='xUnit to testrail reporter')
+    parser.add_argument(
+        'xunit_report',
+        type=filename,
+        default=defaults['XUNIT_REPORT'],
+        help='xUnit report XML file')
+
+    parser.add_argument(
+        '--output-xunit-report',
+        type=str_cls,
+        default=defaults['OUTPUT_XUNIT_REPORT'],
+        help='Output xUnit report XML file after update')
+
+    parser.add_argument(
+        '--xunit-name-template',
+        type=str_cls,
+        default=defaults['XUNIT_NAME_TEMPLATE'],
+        help='template for xUnit cases to make id string')
+    parser.add_argument(
+        '--testrail-name-template',
+        type=str_cls,
+        default=defaults['TESTRAIL_NAME_TEMPLATE'],
+        help='template for TestRail cases to make id string')
+
+    parser.add_argument(
+        '--env-description',
+        type=str_cls,
+        default=defaults['ENV_DESCRIPTION'],
+        help='env deploy type description (for TestRun name)')
+
+    group = parser.add_mutually_exclusive_group()
+    group.add_argument(
+        '--iso-id',
+        type=str_cls,
+        default=defaults['ISO_ID'],
+        help='id of build Fuel iso (DEPRECATED)')
+    group.add_argument(
+        '--testrail-plan-name',
+        type=str_cls,
+        default=defaults['TESTRAIL_PLAN_NAME'],
+        help='name of test plan to be displayed in testrail')
+
+    parser.add_argument(
+        '--test-results-link',
+        type=str_cls,
+        default=defaults['TEST_RESULTS_LINK'],
+        help='link to test job results')
+    parser.add_argument(
+        '--testrail-url',
+        type=str_cls,
+        default=defaults['TESTRAIL_URL'],
+        help='base url of testrail')
+    parser.add_argument(
+        '--testrail-user',
+        type=str_cls,
+        default=defaults['TESTRAIL_USER'],
+        help='testrail user')
+    parser.add_argument(
+        '--testrail-password',
+        type=str_cls,
+        default=defaults['TESTRAIL_PASSWORD'],
+        help='testrail password')
+    parser.add_argument(
+        '--testrail-project',
+        type=str_cls,
+        default=defaults['TESTRAIL_PROJECT'],
+        help='testrail project name')
+    parser.add_argument(
+        '--testrail-milestone',
+        type=str_cls,
+        default=defaults['TESTRAIL_MILESTONE'],
+        help='testrail project milestone')
+    parser.add_argument(
+        '--testrail-suite',
+        type=str_cls,
+        default=defaults['TESTRAIL_TEST_SUITE'],
+        help='testrail project suite name')
+    parser.add_argument(
+        '--send-skipped',
+        action='store_true',
+        default=False,
+        help='send skipped cases to testrail')
+    parser.add_argument(
+        '--send-duplicates',
+        action='store_true',
+        default=False,
+        help='send duplicated cases to testrail')
+    parser.add_argument(
+        '--paste-url',
+        type=str_cls,
+        default=defaults['PASTE_BASE_URL'],
+        help=('pastebin service JSON API URL to send test case logs and trace,'
+              ' example: http://localhost:5000/'))
+    parser.add_argument(
+        '--testrail-run-update',
+        dest='use_test_run_if_exists',
+        action='store_true',
+        default=False,
+        help='don\'t create new test run if such already exists')
+    parser.add_argument(
+        '--dry-run', '-n',
+        action='store_true',
+        default=False,
+        help='Just print mapping table')
+    parser.add_argument(
+        '--verbose',
+        '-v',
+        action='store_true',
+        default=False,
+        help='Verbose mode')
+
+    return parser.parse_args(args)
+
+
+def main(args=None):
+
+    args = args or sys.argv[1:]
+
+    args = parse_args(args)
+
+    if not args.testrail_plan_name:
+        args.testrail_plan_name = ('{0.testrail_milestone} iso '
+                                   '#{0.iso_id}').format(args)
+
+        msg = ("--iso-id parameter is DEPRECATED. "
+               "It is recommended to use --testrail-plan-name parameter.")
+        warnings.warn(msg, DeprecationWarning)
+
+    logger_dict = dict(stream=sys.stderr)
+    if args.verbose:
+        logger_dict['level'] = logging.DEBUG
+
+    logging.basicConfig(**logger_dict)
+
+    reporter = Reporter(
+        xunit_report=args.xunit_report,
+        output_xunit_report=args.output_xunit_report,
+        env_description=args.env_description,
+        test_results_link=args.test_results_link,
+        paste_url=args.paste_url)
+    suite = args.testrail_suite.format(args)
+    reporter.config_testrail(
+        base_url=args.testrail_url,
+        username=args.testrail_user,
+        password=args.testrail_password,
+        project=args.testrail_project,
+        tests_suite=suite,
+        send_skipped=args.send_skipped,
+        send_duplicates=args.send_duplicates,
+        use_test_run_if_exists=args.use_test_run_if_exists)
+
+
+    all_cases = reporter.get_cases()
+    empty_classnames = reporter.get_empty_classnames()
+    all_empty_cases = reporter.get_testcases(all_cases, empty_classnames)
+
+    reporter.update_testcases(all_empty_cases)
+    reporter.delete_duplicates()
+    reporter.delete_temporary_file()
+
+
+if __name__ == '__main__':
+    try:
+        main()
+    except Exception:
+        traceback.print_exc(file=sys.stdout)
+        sys.exit(1)
diff --git a/update_testrail_xml/reporter.py b/update_testrail_xml/reporter.py
new file mode 100644
index 0000000..709e890
--- /dev/null
+++ b/update_testrail_xml/reporter.py
@@ -0,0 +1,198 @@
+from __future__ import absolute_import, print_function
+
+from functools import wraps
+import logging
+import os
+import re
+import six
+
+import xml.etree.ElementTree as ET
+
+from client import Client as TrClient
+
+import logging
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+
+def memoize(f):
+    @wraps(f)
+    def wrapper(self, *args, **kwargs):
+        key = f.__name__
+        cached = self._cache.get(key)
+        if cached is None:
+            cached = self._cache[key] = f(self, *args, **kwargs)
+        return cached
+
+    return wrapper
+
+
+class Reporter(object):
+    def __init__(self,
+                 xunit_report,
+                 output_xunit_report,
+                 env_description,
+                 test_results_link,
+                 paste_url, *args, **kwargs):
+        self._config = {}
+        self._cache = {}
+        self.xunit_report = xunit_report
+        self.output_xunit_report = output_xunit_report
+        self.env_description = env_description
+        self.test_results_link = test_results_link
+        self.paste_url = paste_url
+
+        super(Reporter, self).__init__(*args, **kwargs)
+
+    def config_testrail(self,
+                        base_url,
+                        username,
+                        password,
+                        project,
+                        tests_suite,
+                        send_skipped=False,
+                        use_test_run_if_exists=False, send_duplicates=False):
+        self._config['testrail'] = dict(base_url=base_url,
+                                        username=username,
+                                        password=password, )
+        self.project_name = project
+        self.tests_suite_name = tests_suite
+        self.send_skipped = send_skipped
+        self.send_duplicates = send_duplicates
+        self.use_test_run_if_exists = use_test_run_if_exists
+
+    @property
+    def testrail_client(self):
+        return TrClient(**self._config['testrail'])
+
+    @property
+    @memoize
+    def project(self):
+        return self.testrail_client.projects.find(name=self.project_name)
+
+    @property
+    @memoize
+    def suite(self):
+        return self.project.suites.find(name=self.tests_suite_name)
+
+    @property
+    @memoize
+    def cases(self):
+        return self.suite.cases()
+
+# ================================================================
+
+    temporary_filename = 'temporary_xunit_report.xml'
+    logger.info(' Temporrary filename is: {}'.format(temporary_filename))
+
+    def describe_testrail_case(self, case):
+        return {
+            k: v
+            for k, v in case.data.items() if isinstance(v, six.string_types)
+        }
+
+    def get_cases(self):
+        """Get all the testcases from the server"""
+        logger.info(' Start gerring cases from the Testrail')
+        cases_data = []
+        cases = self.suite.cases()
+        for case in cases:
+            case_data = self.describe_testrail_case(case)
+            cases_data.append(case_data)
+        logger.info(' Cases were got from the Testrail')
+        return cases_data
+
+    def get_empty_classnames(self):
+        tree = ET.parse(self.xunit_report)
+        root = tree.getroot()
+        
+        if root[0].tag == 'testsuite':
+            root = root[0]
+
+        classnames = []
+        for child in root:
+            if child.attrib['classname'] == '' and child[0].tag == 'failure':
+                m = re.search('\(.*\)', child.attrib['name'])
+                classname = m.group()[1:-1]
+                classnames.append({'classname': classname, 'data': child[0].text})
+
+        logger.info(' Got empty classnames from xml file')
+        return classnames
+
+    def get_testcases(self, all_cases, empty_classnames):
+        needed_cases = []
+        for empty_classname in empty_classnames:
+            for case in all_cases:
+                if empty_classname['classname'] in case['title']:
+
+                    updated_case = {'classname': empty_classname['classname'],
+                                    'name': case['custom_test_case_description'],
+                                    'data': empty_classname['data']}
+                    needed_cases.append(updated_case)
+        logger.info(' Got test cases for updating xml file')
+        return needed_cases
+
+    def update_testcases(self, cases):
+        tree = ET.parse(self.xunit_report)
+        root = tree.getroot()
+
+        for case in cases:
+            testcase = ET.Element("testcase")
+            testcase.attrib['classname'] = "{}".format(case['classname'])
+            testcase.attrib['name'] = "{}".format(case['name'])
+            testcase.attrib['time'] = "0.000"
+
+            skip = ET.SubElement(testcase, 'failure')
+            skip.text = case['data']
+
+            root.append(testcase)
+
+        for _ in cases:
+            for child in root:
+                try:
+                    if child.attrib['classname'] == "":
+                        child.clear()
+                except KeyError:
+                    pass
+        logger.info(' Create temporrary file: {}'.format(str(self.temporary_filename)))
+        tree = ET.ElementTree(root)
+        tree.write(self.temporary_filename)
+        logger.info(' Temporrary file was created: {}'.format(self.check_file_exists(self.temporary_filename)))
+
+    def delete_duplicates(self):
+        logger.info(' Start deleting duplicates from xml file: {}'.format(self.temporary_filename))
+        tree = ET.parse(self.temporary_filename)
+        root = tree.getroot()
+
+        all_cases = []
+        for child in root:
+            try:
+                all_cases.append((child.attrib['classname'], child.attrib['name']))
+            except KeyError:
+                pass
+        # Get duplicates
+        for_stack = lambda all_cases: sorted(list(set([x for x in all_cases if all_cases.count(x) > 1])))
+        duplicate_cases = for_stack(all_cases)
+
+        # Remove duplicates from xml
+        for case in duplicate_cases:
+            for child in root:
+                try:
+                    if child.attrib['classname'] == case[0] and child.attrib['name'] == case[1]:
+                        if child.attrib['time'] == '0.000':
+                            child.clear()
+                except KeyError:
+                    pass
+
+        logger.info(' Start saving results to the file: {}'.format(self.output_xunit_report))
+        tree = ET.ElementTree(root)
+        tree.write(self.output_xunit_report)
+
+        logger.info(' {} file was created: {}'.format(self.output_xunit_report, self.check_file_exists(self.output_xunit_report)))
+
+    def check_file_exists(self, filename):
+        return str(os.path.isfile(filename))
+
+    def delete_temporary_file(self):
+        os.remove(self.temporary_filename)
+        logger.info(' Temporrary file exists: {}'.format(self.check_file_exists(self.temporary_filename)))
diff --git a/update_testrail_xml/requirements.txt b/update_testrail_xml/requirements.txt
new file mode 100644
index 0000000..35b52a5
--- /dev/null
+++ b/update_testrail_xml/requirements.txt
@@ -0,0 +1,4 @@
+six>=1.11.0
+jinja2>=2.10
+requests>=2.19.1
+