from datetime import timedelta import json import logging import os import os.path from osc import cmdln from osc import core from osc import oscerr from osc.core import get_request_list from osclib.cache import Cache from osclib.cache_manager import CacheManager from osclib.core import entity_exists from osclib.core import package_kind from osclib.core import package_list from osclib.core import package_list_kind_filtered from osclib.core import project_attribute_list from osclib.core import project_locked from osclib.origin import config_load from osclib.origin import config_origin_list from osclib.origin import origin_find from osclib.origin import origin_history from osclib.origin import origin_potentials from osclib.origin import origin_revision_state from osclib.origin import origin_updatable from osclib.origin import origin_updatable_initial from osclib.origin import origin_update from osclib.util import mail_send from shutil import copyfile import sys import time import yaml OSRT_ORIGIN_LOOKUP_TTL = 60 * 60 * 24 * 7 @cmdln.option('--debug', action='store_true', help='output debug information') @cmdln.option('--diff', action='store_true', help='diff against previous report') @cmdln.option('--dry', action='store_true', help='perform a dry-run where applicable') @cmdln.option('--force-refresh', action='store_true', help='force refresh of data') @cmdln.option('--format', default='plain', help='output format') @cmdln.option('--listen', action='store_true', help='listen to events') @cmdln.option('--listen-seconds', help='number of seconds to listen to events') @cmdln.option('--mail', action='store_true', help='mail report to ') @cmdln.option('--origins-only', action='store_true', help='list origins instead of expanded config') @cmdln.option('-p', '--project', help='project on which to operate') def do_origin(self, subcmd, opts, *args): """${cmd_name}: tools for working with origin information ${cmd_option_list} config: print expanded OSRT:OriginConfig cron: update the lookup for all projects with an OSRT:OriginConfig attribute history: list requests containing an origin annotation list: print all packages and their origin package: print the origin of package potentials: list potential origins of a package projects: list all projects with an OSRT:OriginConfig attribute report: print origin summary report update: handle package source changes as either delete or submit requests Usage: osc origin config [--origins-only] osc origin cron osc origin history [--format json|yaml] PACKAGE osc origin list [--force-refresh] [--format json|yaml] osc origin package [--debug] PACKAGE osc origin potentials [--format json|yaml] PACKAGE osc origin projects [--format json|yaml] osc origin report [--diff] [--force-refresh] [--mail] osc origin update [--listen] [--listen-seconds] [PACKAGE...] """ if len(args) == 0: raise oscerr.WrongArgs('A command must be indicated.') command = args[0] if command not in ['config', 'cron', 'history', 'list', 'package', 'potentials', 'projects', 'report', 'update']: raise oscerr.WrongArgs(f'Unknown command: {command}') if command == 'package' and len(args) < 2: raise oscerr.WrongArgs('A package must be indicated.') level = logging.DEBUG if opts.debug else None logging.basicConfig(level=level, format='[%(levelname).1s] %(message)s') # Allow for determining project from osc store. if not opts.project and core.is_project_dir('.'): opts.project = core.store_read_project('.') Cache.init() apiurl = self.get_api_url() if command not in ['cron', 'projects', 'update']: if not opts.project: raise oscerr.WrongArgs('A project must be indicated.') config = config_load(apiurl, opts.project) if not config: raise oscerr.WrongArgs(f'OSRT:OriginConfig attribute missing from {opts.project}') function = f'osrt_origin_{command}' globals()[function](apiurl, opts, *args[1:]) def osrt_origin_config(apiurl, opts, *args): config = config_load(apiurl, opts.project) if opts.origins_only: print('\n'.join(config_origin_list(config))) else: yaml.Dumper.ignore_aliases = lambda *args: True print(yaml.dump(config)) def osrt_origin_cron(apiurl, opts, *args): projects = project_attribute_list(apiurl, 'OSRT:OriginConfig') for project in projects: # Preserve cache for locked projects, but create if missing. if project_locked(apiurl, project): lookup_path = osrt_origin_lookup_file(project) if os.path.exists(lookup_path): # Update the last accessed time to avoid cache manager culling. os.utime(lookup_path, (time.time(), os.stat(lookup_path).st_mtime)) print(f'{project} lookup preserved') continue # Force update lookup information. lookup = osrt_origin_lookup(apiurl, project, force_refresh=True, quiet=True) print(f'{project} lookup updated for {len(lookup)} package(s)') def osrt_origin_dump(format, data): if format == 'json': print(json.dumps(data)) elif format == 'yaml': print(yaml.dump(data)) else: if format != 'plain': print(f'unknown format: {format}', file=sys.stderr) return False return True def osrt_origin_history(apiurl, opts, *packages): config = config_load(apiurl, opts.project) history = origin_history(apiurl, opts.project, packages[0], config['review-user']) if osrt_origin_dump(opts.format, history): return line_format = '{:<50} {:<10} {:>7}' print(line_format.format('origin', 'state', 'request')) for record in history: print(line_format.format(record['origin'], record['state'], record['request'])) def osrt_origin_lookup_file(project, previous=False): parts = [project, 'yaml'] if previous: parts.insert(1, 'previous') lookup_name = '.'.join(parts) cache_dir = CacheManager.directory('origin-manager') return os.path.join(cache_dir, lookup_name) def osrt_origin_lookup(apiurl, project, force_refresh=False, previous=False, quiet=False): locked = project_locked(apiurl, project) if locked: force_refresh = False lookup_path = osrt_origin_lookup_file(project, previous) if not force_refresh and os.path.exists(lookup_path): if not locked and not previous: # Force refresh of lookup information if expried. if time.time() - os.stat(lookup_path).st_mtime > OSRT_ORIGIN_LOOKUP_TTL: return osrt_origin_lookup(apiurl, project, True) with open(lookup_path, 'r') as lookup_stream: lookup = yaml.safe_load(lookup_stream) if not isinstance(next(iter(lookup.values())), dict): # Convert flat format to dictionary. for package, origin in lookup.items(): lookup[package] = {'origin': origin} else: if previous: return None packages = package_list_kind_filtered(apiurl, project) lookup = {} for package in packages: origin_info = origin_find(apiurl, project, package) lookup[str(package)] = { 'origin': str(origin_info), 'revisions': origin_revision_state(apiurl, project, package, origin_info), } if os.path.exists(lookup_path): lookup_path_previous = osrt_origin_lookup_file(project, True) copyfile(lookup_path, lookup_path_previous) with open(lookup_path, 'w+') as lookup_stream: yaml.dump(lookup, lookup_stream, default_flow_style=False) if not previous and not quiet: dt = timedelta(seconds=time.time() - os.stat(lookup_path).st_mtime) print(f'# generated {dt} ago', file=sys.stderr) return lookup def osrt_origin_max_key(dictionary, minimum): return max(len(max(dictionary.keys(), key=len)), minimum) def osrt_origin_list(apiurl, opts, *args): lookup = osrt_origin_lookup(apiurl, opts.project, opts.force_refresh, quiet=opts.format != 'plain') if opts.format != 'plain': # Suppliment data with request information. requests = get_request_list(apiurl, opts.project, None, None, ['new', 'review'], 'submit') requests.extend(get_request_list(apiurl, opts.project, None, None, ['new', 'review'], 'delete')) requests_map = {} for request in requests: for action in request.actions: requests_map[action.tgt_package] = request.reqid # Convert data from lookup to list. out = [] for package, details in sorted(lookup.items()): out.append({ 'package': package, 'origin': details['origin'], 'revisions': details.get('revisions', []), 'request': requests_map.get(package), }) osrt_origin_dump(opts.format, out) return line_format = '{:<' + str(osrt_origin_max_key(lookup, 7)) + '} {}' print(line_format.format('package', 'origin')) for package, details in sorted(lookup.items()): print(line_format.format(package, details['origin'])) def osrt_origin_package(apiurl, opts, *packages): origin_info = origin_find(apiurl, opts.project, packages[0]) print(origin_info) def osrt_origin_potentials(apiurl, opts, *packages): potentials = origin_potentials(apiurl, opts.project, packages[0]) if opts.format != 'plain': out = [] for origin, version in potentials.items(): out.append({'origin': origin, 'version': version}) osrt_origin_dump(opts.format, out) return line_format = '{:<50} {}' print(line_format.format('origin', 'version')) for origin, version in potentials.items(): print(line_format.format(origin, version)) def osrt_origin_projects(apiurl, opts, *args): projects = list(project_attribute_list(apiurl, 'OSRT:OriginConfig')) if osrt_origin_dump(opts.format, projects): return for project in sorted(projects): print(project) def osrt_origin_report_count(lookup): origin_count = {} for package, details in lookup.items(): origin_count.setdefault(details['origin'], 0) origin_count[details['origin']] += 1 return origin_count def osrt_origin_report_count_diff(origin_count, origin_count_previous): origin_count_change = {} for origin, count in origin_count.items(): delta = count - origin_count_previous.get(origin, 0) delta = '+' + str(delta) if delta > 0 else str(delta) origin_count_change[origin] = delta return origin_count_change def osrt_origin_report_diff(lookup, lookup_previous): diff = {} for package, details in lookup.items(): origin_previous = lookup_previous.get(package, {}).get('origin') if details['origin'] != origin_previous: diff[package] = (details['origin'], origin_previous) return diff def osrt_origin_report(apiurl, opts, *args): lookup = osrt_origin_lookup(apiurl, opts.project, opts.force_refresh) origin_count = osrt_origin_report_count(lookup) columns = ['origin', 'count', 'percent'] column_formats = [ '{:<' + str(osrt_origin_max_key(origin_count, 6)) + '}', '{:>5}', '{:>7}', ] if opts.diff: columns.insert(2, 'change') column_formats.insert(2, '{:>6}') lookup_previous = osrt_origin_lookup(apiurl, opts.project, previous=True) if lookup_previous is not None: origin_count_previous = osrt_origin_report_count(lookup_previous) origin_count_change = osrt_origin_report_count_diff(origin_count, origin_count_previous) package_diff = osrt_origin_report_diff(lookup, lookup_previous) else: origin_count_change = {} package_diff = [] line_format = ' '.join(column_formats) report = [line_format.format(*columns)] total = len(lookup) for origin, count in sorted(origin_count.items(), key=lambda x: x[1], reverse=True): values = [origin, count, round(float(count) / total * 100, 2)] if opts.diff: values.insert(2, origin_count_change.get(origin, 0)) report.append(line_format.format(*values)) if opts.diff and len(package_diff): line_format = '{:<' + str(osrt_origin_max_key(package_diff, 7)) + '} ' + \ ' '.join([column_formats[0]] * 2) report.append('') report.append(line_format.format('package', 'origin', 'origin previous')) for package, origins in sorted(package_diff.items()): report.append(line_format.format(package, *origins)) body = '\n'.join(report) print(body) if opts.mail: mail_send(apiurl, opts.project, 'release-list', f'{opts.project} origin report', body, None, dry=opts.dry) def osrt_origin_update(apiurl, opts, *packages): if not opts.project: for project in origin_updatable(apiurl): opts.project = project osrt_origin_update(apiurl, opts, *packages) return if len(packages) == 0: packages = osrt_origin_update_packages(apiurl, opts.project) for package in packages: print(f'checking for updates to {opts.project}/{package}...') request_future = origin_update(apiurl, opts.project, package) if request_future: request_future.print_and_create(opts.dry) def osrt_origin_update_packages(apiurl, project): packages = set(package_list_kind_filtered(apiurl, project)) # Include packages from origins with initial update enabled to allow for # potential new package submissions. for origin in origin_updatable_initial(apiurl, project): for package in package_list(apiurl, origin): # Only add missing package if it does not exist in target # project. If it exists in target then it is not a source # package (since origin list is filtered to source) and should # not be updated. This also properly avoids submitting a package # that is a subpackage in target, but is a source package in an # origin project. if package in packages or entity_exists(apiurl, project, package): continue # No sense submitting a non-source package (most expensive). if package_kind(apiurl, origin, package) == 'source': packages.add(package) return packages