zypper-wassup (4525B)
1 #!/usr/bin/env python3 2 3 from collections import defaultdict, namedtuple 4 from collections.abc import Iterable 5 from enum import IntEnum 6 import re 7 from subprocess import run 8 9 10 Update = namedtuple('Update', ('package', 'old', 'new')) 11 12 ZYPPER_PATTERN = re.compile( 13 r' +\| +'.join(( 14 '^v', 15 '[^|]+', 16 '(?P<package>[^ ]+)', 17 '(?P<old>[^ ]+)', 18 '(?P<new>[^ ]+)' 19 )), 20 re.MULTILINE 21 ) 22 23 # Using http://ftp.rpm.org/max-rpm/ch-rpm-file-format.html to make a 24 # few assumptions, e.g. versions can't contain hyphens. 25 SOURCERPM_PATTERN = re.compile( 26 r'\.'.join(( 27 '-'.join(('(?P<name>.+)', '(?P<version>[^-]+)', '(?P<release>[^-]+)')), 28 '(?:no)?src', 29 'rpm' 30 )) 31 ) 32 33 34 def execute(command): 35 return run(command, check=True, text=True, capture_output=True).stdout 36 37 38 def zypper_list_updates(): 39 zypp_output = execute(('zypper', 'list-updates')) 40 41 return tuple( 42 Update(**match.groupdict()) 43 for match in ZYPPER_PATTERN.finditer(zypp_output) 44 ) 45 46 47 def source_package_name(package): 48 if (match := SOURCERPM_PATTERN.fullmatch(package)) is None: 49 raise Exception(f'{package} does not match "{SOURCERPM_PATTERN}".') 50 51 return match.group('name') 52 53 54 def sort_by_source_package(updates): 55 sorted_updates = defaultdict(list) 56 57 for u in updates: 58 source_pkgs = execute( 59 ('rpm', '--query', '--queryformat', r'%{SOURCERPM}\n', u.package) 60 ) 61 62 # Some packages, e.g. kernel-default and kernel-devel, may be 63 # provided by multiple version of a source package. Assume 64 # the last one is one we want. 65 66 *_, last_source_pkg = source_pkgs.splitlines() 67 name = source_package_name(last_source_pkg) 68 69 sorted_updates[name].append(u) 70 71 return sorted_updates 72 73 74 class Sgr(IntEnum): 75 RESET = 0 76 BOLD = 1 77 RED_FG = 31 78 GREEN_FG = 32 79 80 81 def colorize(text: str, params: Iterable[Sgr]) -> str: 82 prefix = '\N{ESCAPE}[' 83 suffix = 'm' 84 reset = f'{prefix}{Sgr.RESET}{suffix}' 85 param_list = ';'.join(map(str, params)) 86 87 return f'{prefix}{param_list}{suffix}{text}{reset}' 88 89 90 def highlight_diff_part(old: str, new: str, codes: Iterable[Sgr]=()) -> (str, str): 91 for i, (o, n) in enumerate(zip(old, new)): 92 if o != n: 93 break 94 else: 95 # old == new, or new == old + suffix. 96 i += 1 97 98 old = old[:i]+colorize(old[i:], (Sgr.RED_FG,)+tuple(codes)) 99 new = new[:i]+colorize(new[i:], (Sgr.GREEN_FG,)+tuple(codes)) 100 101 return old, new 102 103 104 def highlight_diff(old: str, new: str) -> (str, str): 105 old_pkgv, old_distv = old.split("-", maxsplit=1) 106 new_pkgv, new_distv = new.split("-", maxsplit=1) 107 108 old_pkgv, new_pkgv = highlight_diff_part(old_pkgv, new_pkgv, (Sgr.BOLD,)) 109 old_distv, new_distv = highlight_diff_part(old_distv, new_distv) 110 111 return f"{old_pkgv}-{old_distv}", f"{new_pkgv}-{new_distv}" 112 113 def padding(string, width): 114 # Python's str.format does not skip over control sequences when 115 # computing how long a string is. Compute padding manually before 116 # adding these sequences 117 return ' '*(width-len(string)) 118 119 120 COLUMN = ' │ ' 121 122 123 def print_header(widths, name): 124 if len(name) > widths['package']: 125 name = name[:widths['package']-1]+'…' 126 127 print(COLUMN.join(( 128 colorize(name, (Sgr.BOLD,))+padding(name, widths['package']), 129 ' '*widths['old'], 130 ' '*widths['new'], 131 ))) 132 133 134 def print_footer(i, n, widths): 135 if i < n: 136 print('─┼─'.join('─'*widths[f] for f in Update._fields)) 137 138 139 def main(): 140 print('Querying zypper list-updates… ', end='', flush=True) 141 updates = zypper_list_updates() 142 print(f'{len(updates)} updates.') 143 144 if not updates: 145 return 146 147 widths = { 148 field: max(len(u._asdict()[field]) for u in updates) 149 for field in Update._fields 150 } 151 152 print('Sorting by source package… ', end='', flush=True) 153 updates = sort_by_source_package(updates) 154 print('Done') 155 156 for i, src in enumerate(sorted(updates), 1): 157 print_header(widths, src) 158 159 for pkg, old, new in sorted(updates[src]): 160 old_padding = padding(old, widths['old']) 161 new_padding = padding(new, widths['new']) 162 163 old, new = highlight_diff(old, new) 164 165 print(COLUMN.join(( 166 f'{pkg:<{widths["package"]}}', 167 old+old_padding, 168 new+new_padding 169 ))) 170 171 print_footer(i, len(updates), widths) 172 173 174 if __name__ == '__main__': 175 main()