import logging import uuid from itertools import groupby from .api import SquadApi, ApiException from squad_client.exceptions import InvalidSquadObject, InvalidSquadLookup from squad_client.utils import first, parse_test_name, parse_metric_name, to_json, get_class_name from squad_client import settings logger = logging.getLogger() DEFAULT_COUNT = settings.DEFAULT_NUM_OF_OBJECTS ALL = -1 class SquadObjectException(Exception): pass class SquadObject: endpoint = None attrs = [] types = None def __init__(self, _id=None): if _id: self.endpoint += str(_id) self.__fetch__() @classmethod def get_type(cls, _type): if SquadObject.types is None: SquadObject.types = {c.__name__: c for c in SquadObject.__subclasses__() if c is not Squad} returned_type = SquadObject.types.get(_type) if returned_type is None: raise InvalidSquadObject('There is no SquadObject of type "%s"' % _type) return returned_type @property def __id__(self): if not hasattr(self, 'id'): setattr(self, 'id', None) if self.id is None: self.id = uuid.uuid1() return self.id def __fill_object__(self, result, obj=None): if obj is None: obj = self nested_endpoints = [x[1] for x in SquadApi.schema_nested_eps if x[0].lower() == get_class_name(obj).lower()] if nested_endpoints: setattr(obj, 'nested_endpoints', nested_endpoints) attrs = obj.attrs if len(obj.attrs) else [attr for attr in result.keys()] for attr in attrs: if attr in result.keys(): setattr(obj, attr.replace(' ', '_').replace('/', '_').replace('-', '_'), result[attr]) def __fill__(self, klass, results): objects = {} for result in results: obj = klass() self.__fill_object__(result, obj) objects[obj.__id__] = obj return objects def __str__(self): class_name = get_class_name(self) attrs_str = [] for attr in self.attrs: attrs_str.append('%s: "%s"' % (attr, getattr(self, attr) if hasattr(self, attr) else None)) return '%s(%s)' % (class_name, ', '.join(attrs_str)) def __fetch__(self, klass=None, filters=None, count=DEFAULT_COUNT, endpoint=None): """ Generic get method to retrieve objects from API count: number of objects to fetch, defaults to 50, -1 (ALL) means follow pagination """ if klass is None: response = SquadApi.get(self.endpoint) result = response.json() self.__fill_object__(result) return if count == ALL: count = settings.MAX_NUM_OF_OBJECTS filters['limit'] = count if count < settings.SQUAD_MAX_PAGE_LIMIT else settings.SQUAD_MAX_PAGE_LIMIT objects = {} url = endpoint or klass.endpoint while url and len(objects) < count: response = SquadApi.get(url, filters) result = response.json() url = result['next'] objects.update(self.__fill__(klass, result['results'])) if len(objects) > settings.MAX_NUM_OF_OBJECTS: logger.warn('Maximum number of objects reached [%d]!' % len(objects)) return objects def get(self, _id): count = 1 result = self.__fetch__(self.__class__, {'id': _id}, count) return first(result) if len(result) else None def pre_save(self): pass def post_save(self): pass def save(self): self.pre_save() data = {} class_name = get_class_name(self) for attr in self.attrs: if not hasattr(self, attr): continue value = getattr(self, attr, None) if value is None: continue if isinstance(value, SquadObject): # value is a relation object if not hasattr(value, 'id'): raise SquadObjectException('Failed to save %s: %s has no id' % (class_name, attr)) # TODO: some objects have url as reference to other objects, ex: # project.group is a group url, instead of id # we need to standardize it # For now, only projects can be created this way data[attr] = value.url else: data[attr] = value endpoint = self.endpoint request = SquadApi.post if hasattr(self, 'id') and type(self.id) is not uuid.UUID: endpoint = '%s%s/' % (endpoint, self.id) request = SquadApi.patch try: response = request(endpoint, data=data) if response.status_code in [400, 401, 405]: raise SquadObjectException('Failed to save %s: %s' % (class_name, response.text)) self.__fill_object__(response.json()) self.post_save() except ApiException as e: logger.error(e) raise SquadObjectException(str(e)) def delete(self): class_name = get_class_name(self) if not hasattr(self, 'id') or type(self.id) is uuid.UUID: raise SquadObjectException('Failed to delete %s: it must contain a valid "id"' % class_name) endpoint = '%s%s/' % (self.endpoint, self.id) try: response = SquadApi.delete(endpoint) if response.status_code in [400, 401, 405]: raise SquadObjectException('Failed to delete %s: %s' % (class_name, response.text)) except ApiException as e: logger.error(e) raise SquadObjectException(str(e)) class Squad(SquadObject): def fetch(self, klass, count=ALL, **filters): return self.__fetch__(klass, filters, count) def groups(self, count=DEFAULT_COUNT, **filters): return self.__fetch__(Group, filters, count) def group(self, slug, **filters): filters.update({'slug': slug}) objects = self.groups(count=1, **filters) return first(objects) def projects(self, count=DEFAULT_COUNT, **filters): return self.__fetch__(Project, filters, count) def builds(self, count=DEFAULT_COUNT, **filters): return self.__fetch__(Build, filters, count) def testjobs(self, count=DEFAULT_COUNT, **filters): return self.__fetch__(TestJob, filters, count) def testruns(self, count=DEFAULT_COUNT, **filters): return self.__fetch__(TestRun, filters, count) def tests(self, count=DEFAULT_COUNT, **filters): return self.__fetch__(Test, filters, count) def metrics(self, count=DEFAULT_COUNT, **filters): return self.__fetch__(Metric, filters, count) def suites(self, count=DEFAULT_COUNT, **filters): return self.__fetch__(Suite, filters, count) def environments(self, count=DEFAULT_COUNT, **filters): return self.__fetch__(Environment, filters, count) def backends(self, count=DEFAULT_COUNT, **filters): return self.__fetch__(Backend, filters, count) def emailtemplates(self, count=DEFAULT_COUNT, **filters): return self.__fetch__(EmailTemplate, filters, count) def knownissues(self, count=DEFAULT_COUNT, **filters): return self.__fetch__(KnownIssue, filters, count) def suitemetadata(self, count=DEFAULT_COUNT, **filters): return self.__fetch__(SuiteMetadata, filters, count) def annotations(self, count=DEFAULT_COUNT, **filters): return self.__fetch__(Annotation, filters, count) def metricthresholds(self, count=DEFAULT_COUNT, **filters): return self.__fetch__(MetricThreshold, filters, count) def reports(self, count=DEFAULT_COUNT, **filters): return self.__fetch__(Report, filters, count) def submit(self, group=None, project=None, build=None, environment=None, tests=None, metrics=None, metadata=None, log=None, attachments=None): path = '/api/submit/%s/%s/%s/%s' % (group.slug, project.slug, build.version, environment.slug) num_tests = 0 num_metrics = 0 data = {} if tests: tests_dict = {} for test in tests.values(): if hasattr(test, 'log') and test.log is not None and len(test.log): value = {'log': test.log, 'result': test.status} else: value = test.status tests_dict[test.name] = value num_tests = len(tests_dict) data['tests'] = to_json(tests_dict) if metrics: metrics_dict = {metric.name: metric.result for metric in metrics.values()} num_metrics = len(metrics_dict) data['metrics'] = to_json(metrics_dict) if metadata: data['metadata'] = to_json(metadata) if log: data['log'] = log logger.info('Submitting %i tests, %i metrics' % (num_tests, num_metrics)) # TODO handle attachments response = SquadApi.post(path, data=data) status_code = response.status_code if status_code not in [200, 201, 500]: logger.error('Failed to submit results: %s' % response.text) return response.ok class Group(SquadObject): endpoint = '/api/groups/' attrs = ['id', 'url', 'slug', 'name', 'description'] def projects(self, count=DEFAULT_COUNT, **filters): filters.update({'group': self.id}) return self.__fetch__(Project, filters, count) def project(self, slug): filters = {'slug': slug} objects = self.projects(count=1, **filters) return first(objects) def create_project(self, slug=None, plugins_list=None): new_project = Project() new_project.slug = slug new_project.group = self new_project.enabled_plugins_list = plugins_list or ['linux-log-parser'] new_project.save() def __repr__(self): return self.slug class Project(SquadObject): endpoint = '/api/projects/' attrs = ['id', 'custom_email_template', 'data_retention_days', 'description', 'enabled_plugins_list', 'full_name', 'group', 'html_mail', 'important_metadata_keys', 'is_archived', 'is_public', 'moderate_notifications', 'name', 'notification_timeout', 'slug', 'url', 'wait_before_notification'] def builds(self, count=DEFAULT_COUNT, **filters): filters.update({'project': self.id}) return self.__fetch__(Build, filters, count) def build(self, version): filters = {'version': version} objects = self.builds(count=1, **filters) return first(objects) def environments(self, count=DEFAULT_COUNT, **filters): filters.update({'project': self.id}) return self.__fetch__(Environment, filters, count) def environment(self, slug): filters = {'slug': slug} objects = self.environments(count=1, **filters) return first(objects) def suites(self, count=DEFAULT_COUNT, **filters): filters.update({'project': self.id}) return self.__fetch__(Suite, filters, count) def suite(self, suite_slug): filters = {'slug': suite_slug} objects = self.suites(count=1, **filters) return first(objects) def __repr__(self): return self.slug @staticmethod def compare_builds(baseline_id, build_id): try: int(baseline_id) int(build_id) except ValueError: raise ValueError("IDs must be valid integers") baseline = Build(baseline_id) to_compare = Build(build_id) if baseline.id and to_compare.id: proj_id = baseline.project.split("/")[-2] if proj_id != to_compare.project.split("/")[-2]: raise InvalidSquadLookup("Argument builds must belong to same project") url = ''.join([Project.endpoint, str(proj_id), '/compare_builds']) params = {'baseline': baseline_id, 'to_compare': build_id} return SquadApi.get(url, params).json() def pre_save(self): if not hasattr(self, 'enabled_plugins_list'): # TODO: make enabled_plugins_list optional self.enabled_plugins_list = ['linux-log-parser'] class Build(SquadObject): endpoint = '/api/builds/' attrs = ['url', 'id', 'testjobs', 'finished', 'version', 'created_at', 'datetime', 'patch_id', 'keep_data', 'project', 'patch_source', 'patch_baseline'] def testruns(self, count=ALL, bucket_suites=False, **filters): filters.update({'build': self.id}) testruns = self.__fetch__(TestRun, filters, count) if bucket_suites: for _id in testruns.keys(): testruns[_id].bucket_metric_and_test_suites() return testruns __tests__ = None def tests(self, count=ALL, **filters): if self.__tests__ is None: endpoint = '%s%d/tests/' % (self.endpoint, self.id) self.__tests__ = self.__fetch__(Test, filters, count, endpoint=endpoint) return self.__tests__ __metadata__ = None __status__ = None @property def metadata(self): if self.__metadata__ is None: endpoint = '%s%d/metadata' % (self.endpoint, self.id) response = SquadApi.get(endpoint) objects = self.__fill__(BuildMetadata, [response.json()]) self.__metadata__ = first(objects) return self.__metadata__ @property def status(self): if self.__status__ is None: endpoint = '%s%d/status' % (self.endpoint, self.id) response = SquadApi.get(endpoint) objects = self.__fill__(BuildStatus, [response.json()]) self.__status__ = first(objects) return self.__status__ def __repr__(self): return self.version class BuildMetadata(SquadObject): pass class BuildStatus(SquadObject): pass class TestJob(SquadObject): endpoint = '/api/testjobs/' attrs = ['url', 'external_url', 'definition', 'name', 'environment', 'created_at', 'submitted_at', 'fetched_at', 'submitted', 'fetched', 'fetch_attempts', 'last_fetch_attempt', 'failure', 'can_resubmit', 'resubmitted_count', 'job_id', 'job_status', 'backend', 'testrun', 'target', 'target_build', 'parent_job'] class MetricSuite: name = '' __metrics__ = {} def add_metric(self, metric): if self.__metric__ is None: self.__metric__ = {} self.__metrics__[metric.id] = metric @property def metrics(self): return self.__metrics__ class Metric(SquadObject): endpoint = '/api/metrics/' attrs = ['url', 'id', 'name', 'short_name', 'measurement_list', 'result', 'unit', 'is_outlier', 'test_run', 'suite', 'metadata'] class TestRunStatus(SquadObject): attrs = ['url', 'id', 'tests_pass', 'tests_fail', 'tests_xfail', 'tests_skip', 'metrics_summary', 'has_metrics', 'suite', 'suite_version'] class TestRun(SquadObject): endpoint = '/api/testruns/' attrs = ['url', 'id', 'metadata_file', 'log_file', 'created_at', 'completed', 'datetime', 'build_url', 'job_id', 'job_status', 'job_url', 'resubmit_url', 'data_processed', 'status_recorded', 'build', 'environment'] metadata = None attachments = None log = None __tests__ = None def add_test(self, test): if self.__tests__ is None: self.__tests__ = {} self.__tests__[test.__id__] = test def tests(self, count=ALL, **filters): if self.__tests__ is None and hasattr(self, 'id') and self.id is not None: filters.update({'test_run': self.id}) self.__tests__ = self.__fetch__(Test, filters, count) return self.__tests__ __metrics__ = None def add_metric(self, metric): if self.__metrics__ is None: self.__metrics__ = {} self.__metrics__[metric.__id__] = metric def metrics(self, count=ALL, **filters): if self.__metrics__ is None and hasattr(self, 'id') and self.id is not None: filters.update({'test_run': self.id}) self.__metrics__ = self.__fetch__(Metric, filters, count) return self.__metrics__ test_suites = [] metric_suites = [] def bucket_metric_and_test_suites(self): all_tests = self.tests() self.test_suites = [] if len(all_tests): for suite_name, tests in groupby(sorted(all_tests.values(), key=lambda t: t.name), lambda t: parse_test_name(t.name)[0]): test_suite = Suite() test_suite.name = suite_name self.test_suites.append(test_suite) for test in tests: test_suite.add_test(test) all_metrics = self.metrics() if len(all_metrics): self.metric_suites = [] for suite_name, metrics in groupby(sorted(all_metrics.values(), key=lambda m: m.name), lambda m: parse_metric_name(m.name)[0]): metric_suite = MetricSuite() metric_suite.name = suite_name [metric_suite.add_metric(m) for m in metrics] self.metric_suites.append(metric_suite) def submit_results(self): squad = Squad() return squad.submit( group=self.build.project.group, project=self.build.project, build=self.build, environment=self.environment, tests=self.tests(), metrics=self.metrics(), metadata=self.metadata, log=self.log, attachments=self.attachments) __summary__ = None def summary(self): if self.__summary__ is None: self.__summary__ = first(self.statuses(suite__isnull=True)) return self.__summary__ def statuses(self, count=ALL, **filters): TestRunStatus.endpoint = '/'.join([TestRun.endpoint[:-1], str(self.id), 'status']) return self.__fetch__(TestRunStatus, filters, count) class Test(SquadObject): endpoint = '/api/tests/' attrs = ['url', 'id', 'name', 'short_name', 'status', 'result', 'test_run', 'log', 'has_known_issues', 'suite', 'known_issues', 'build', 'environment'] def __repr__(self): return self.short_name class Suite(SquadObject): endpoint = '/api/suites/' attrs = ['url', 'id', 'slug', 'name', 'project'] __tests__ = None def add_test(self, test): if self.__tests__ is None: self.__tests__ = {} self.__tests__[test.__id__] = test def tests(self, count=ALL, **filters): if self.__tests__ is None and hasattr(self, 'id') and self.id is not None: endpoint = '%s%d/tests' % (self.endpoint, self.id) self.__tests__ = self.__fetch__(Test, filters, count, endpoint=endpoint) return self.__tests__ def __repr__(self): return self.slug class Environment(SquadObject): endpoint = '/api/environments/' attrs = ['url', 'id', 'slug', 'name', 'expected_test_runs', 'description', 'project'] def __repr__(self): return self.slug class Backend(SquadObject): endpoint = '/api/backends/' attrs = ['id', 'name', 'url', 'username', 'implementation_type', 'backend_settings', 'poll_interval', 'max_fetch_attempts', 'poll_enabled', 'listen_enabled'] class EmailTemplate(SquadObject): endpoint = '/api/emailtemplates/' attrs = ['url', 'id', 'name', 'subject', 'plain_text', 'html'] class KnownIssue(SquadObject): endpoint = '/api/knownissues/' attrs = ['url', 'id', 'title', 'test_name', 'notes', 'active', 'intermittent', 'environments'] class SuiteMetadata(SquadObject): endpoint = '/api/suitemetadata/' attrs = ['url', 'id', 'name', 'suite', 'kind', 'description', 'instructions_to_reproduce'] class Annotation(SquadObject): endpoint = '/api/annotations/' attrs = ['url', 'id', 'description', 'build'] class MetricThreshold(SquadObject): endpoint = '/api/metricthresholds/' attrs = ['url', 'id', 'name', 'value', 'is_higher_better', 'environment'] class Report(SquadObject): endpoint = '/api/reports/' attrs = ['url', 'id', 'baseline', 'output_format', 'email_recipient', 'email_recipient_notified', 'callback', 'callback_notified', 'data_retention_days', 'output_subject', 'output_text', 'output_html', 'error_message', 'status_code', 'created_at', 'build', 'template']