#!/usr/bin/env python3 from collections import defaultdict, namedtuple from enum import IntEnum import re from subprocess import run Update = namedtuple('Update', ('package', 'old', 'new')) ZYPPER_PATTERN = re.compile( r' +\| +'.join(( '^v', '[^|]+', '(?P[^ ]+)', '(?P[^ ]+)', '(?P[^ ]+)' )), re.MULTILINE ) # Using http://ftp.rpm.org/max-rpm/ch-rpm-file-format.html to make a # few assumptions, e.g. versions can't contain hyphens. SOURCERPM_PATTERN = re.compile( r'\.'.join(( '-'.join(('(?P.+)', '(?P[^-]+)', '(?P[^-]+)')), '(?:no)?src', 'rpm' )) ) def execute(command): return run(command, check=True, text=True, capture_output=True).stdout def zypper_list_updates(): zypp_output = execute(('zypper', 'list-updates')) return tuple( Update(**match.groupdict()) for match in ZYPPER_PATTERN.finditer(zypp_output) ) def source_package_name(package): if (match := SOURCERPM_PATTERN.fullmatch(package)) is None: raise Exception(f'{package} does not match "{SOURCERPM_PATTERN}".') return match.group('name') def sort_by_source_package(updates): sorted_updates = defaultdict(list) for u in updates: source_pkgs = execute( ('rpm', '--query', '--queryformat', r'%{SOURCERPM}\n', u.package) ) # Some packages, e.g. kernel-default and kernel-devel, may be # provided by multiple version of a source package. Assume # the last one is one we want. *_, last_source_pkg = source_pkgs.splitlines() name = source_package_name(last_source_pkg) sorted_updates[name].append(u) return sorted_updates class Sgr(IntEnum): RESET = 0 BOLD = 1 RED_FG = 31 GREEN_FG = 32 def colorize(text, *params): prefix = '\N{ESCAPE}[' suffix = 'm' reset = f'{prefix}{Sgr.RESET}{suffix}' param_list = ';'.join(map(format, params)) return f'{prefix}{param_list}{suffix}{text}{reset}' def highlight_diff(old, new): for i, (o, n) in enumerate(zip(old, new)): if o != n: break old = old[:i]+colorize(old[i:], Sgr.BOLD, Sgr.RED_FG) new = new[:i]+colorize(new[i:], Sgr.BOLD, Sgr.GREEN_FG) return old, new def padding(string, width): # Python's str.format does not skip over control sequences when # computing how long a string is. Compute padding manually before # adding these sequences return ' '*(width-len(string)) COLUMN = ' │ ' def print_header(widths, name): if len(name) > widths['package']: name = name[:widths['package']-1]+'…' print(COLUMN.join(( colorize(name, Sgr.BOLD)+padding(name, widths['package']), ' '*widths['old'], ' '*widths['new'], ))) def print_footer(i, n, widths): if i < n: print('─┼─'.join('─'*widths[f] for f in Update._fields)) def main(): print('Querying zypper list-updates… ', end='', flush=True) updates = zypper_list_updates() print(f'{len(updates)} updates.') if not updates: return widths = { field: max(len(u._asdict()[field]) for u in updates) for field in Update._fields } print('Sorting by source package… ', end='', flush=True) updates = sort_by_source_package(updates) print('Done') for i, src in enumerate(sorted(updates), 1): print_header(widths, src) for pkg, old, new in sorted(updates[src]): old_padding = padding(old, widths['old']) new_padding = padding(new, widths['new']) old, new = highlight_diff(old, new) print(COLUMN.join(( f'{pkg:<{widths["package"]}}', old+old_padding, new+new_padding ))) print_footer(i, len(updates), widths) if __name__ == '__main__': main()