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
+