dotfiles

🎜 Clone'em, tweak'em, stick'em in your $HOME 🎝
git clone https://git.kevinlegouguec.net/dotfiles
Log | Files | Refs | README

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()