Last active
July 1, 2026 15:04
-
-
Save jwodder/19317d3e4b9a58f2355e7643040d483a to your computer and use it in GitHub Desktop.
Display a table of available APT package updates
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/python3 | |
| """ | |
| This script lists all APT package updates currently available for your system | |
| along with the version numbers of the old & new packages. It is derived from | |
| ``/usr/lib/update-notifier/apt-check`` in the ``update-notifier-common`` | |
| package on Ubuntu 14.04 (Trusty Tahr) and is made available under the same | |
| license (the GNU GPL v2). | |
| This script is know to work on Ubuntu Trusty and Xenial, and it should work on | |
| any recent version of Ubuntu with the ``python3-apt`` package installed. | |
| Options: | |
| - ``--csv`` - output table as CSV | |
| - ``--plain`` - output a plain table (default) | |
| - ``--pretty`` - output an ASCII table | |
| Output columns: | |
| - package name | |
| - installed version | |
| - update candidate version | |
| - 'S' if the update is a security update, otherwise '-' (or empty in CSV) | |
| - 'P' if the update is a `phased update | |
| <https://wiki.ubuntu.com/PhasedUpdates>`_, otherwise '-' (or empty in CSV) | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| from collections.abc import Iterator | |
| import csv | |
| from dataclasses import dataclass, field | |
| from itertools import zip_longest | |
| from operator import attrgetter | |
| import re | |
| import sys | |
| import apt | |
| import apt_pkg | |
| __author__ = "John Thorvald Wodder II" | |
| __author_email__ = "apt-check@varonathe.org" | |
| @dataclass | |
| class Upgrade: | |
| package: str | |
| installed: str | None | |
| candidate: str | |
| security: bool | |
| phased: bool | |
| @property | |
| def flags(self) -> str: | |
| return ("S" if self.security else "-") + ("P" if self.phased else "-") | |
| class AptChecker: | |
| def __init__(self) -> None: | |
| self.distro = None | |
| with open("/etc/os-release") as fp: | |
| for line in fp: | |
| if m := re.fullmatch(r"VERSION_CODENAME=(\w+)", line.strip()): | |
| self.distro = m[1] | |
| if self.distro is None: | |
| raise RuntimeError("Could not determine version codename") | |
| self.security_pockets = [ | |
| ("Debian", self.distro + "-security"), # TODO: Is this accurate? | |
| ("Ubuntu", self.distro + "-security"), | |
| ("UbuntuESM", self.distro + "-infra-security"), | |
| ("UbuntuESMApps", self.distro + "-apps-security"), | |
| ("gNewSense", self.distro + "-security"), | |
| ("Debian", self.distro + "-updates"), | |
| ] | |
| def isSecurityUpgrade(self, ver) -> bool: | |
| """Check if the given version is a security update (or masks one)""" | |
| return any( | |
| (f.origin, f.archive) in self.security_pockets for f, _ in ver.file_list | |
| ) | |
| def apt_check(self) -> Iterator[Upgrade]: | |
| apt_pkg.init() | |
| cache = apt_pkg.Cache(apt.progress.base.OpProgress()) | |
| depcache = apt_pkg.DepCache(cache) | |
| if depcache.broken_count > 0: | |
| raise SystemExit("Error: BrokenCount > 0") | |
| try: | |
| from UpdateManager.Core.UpdateList import UpdateList | |
| ul = UpdateList(None) | |
| except ImportError: | |
| ul = None | |
| # This mimics an upgrade but will never remove anything | |
| depcache.upgrade(True) | |
| if depcache.del_count > 0: | |
| # Unmark (clean) all changes from the given depcache | |
| depcache.init() | |
| depcache.upgrade() | |
| with apt.Cache() as aptcache: | |
| for pkg in cache.packages: | |
| if not depcache.marked_install(pkg) and not depcache.marked_upgrade( | |
| pkg | |
| ): | |
| continue | |
| inst_ver = pkg.current_ver | |
| cand_ver = depcache.get_candidate_ver(pkg) | |
| if cand_ver == inst_ver: | |
| continue | |
| security = False | |
| phased = False | |
| if self.isSecurityUpgrade(cand_ver): | |
| security = True | |
| elif inst_ver: | |
| # Check for security updates that are masked by a candidate | |
| # version from another repo (-proposed or -updates) | |
| for ver in pkg.version_list: | |
| if apt_pkg.version_compare( | |
| ver.ver_str, inst_ver.ver_str | |
| ) > 0 and self.isSecurityUpgrade(ver): | |
| security = True | |
| break | |
| if ul is not None and ul._is_ignored_phased_update(aptcache[pkg.name]): | |
| phased = True | |
| yield Upgrade( | |
| package=pkg.name, | |
| installed=inst_ver.ver_str if inst_ver else None, | |
| candidate=cand_ver.ver_str, | |
| security=security, | |
| phased=phased, | |
| ) | |
| @dataclass | |
| class Table: | |
| headers: list[str] | |
| rows: list[list[str]] = field(init=False, default_factory=list) | |
| colwidths: list[int] = field(init=False) | |
| def __post_init__(self) -> None: | |
| self.colwidths = [len(s) for s in self.headers] | |
| def add_row(self, row: list[str]) -> None: | |
| for i, cell in enumerate(row): | |
| if i < len(self.colwidths): | |
| self.colwidths[i] = max(self.colwidths[i], len(cell)) | |
| else: | |
| self.colwidths.append(len(cell)) | |
| self.rows.append(row) | |
| def render(self) -> str: | |
| hrule = "+-" + "-+-".join("-" * w for w in self.colwidths) + "-+\n" | |
| s = hrule | |
| s += ( | |
| "| " | |
| + " | ".join( | |
| h.ljust(w) | |
| for (h, w) in zip_longest(self.headers, self.colwidths, fillvalue="") | |
| ) | |
| + " |\n" | |
| ) | |
| s += hrule | |
| for row in self.rows: | |
| s += ( | |
| "| " | |
| + " | ".join( | |
| h.ljust(w) | |
| for (h, w) in zip_longest(row, self.colwidths, fillvalue="") | |
| ) | |
| + " |\n" | |
| ) | |
| s += hrule | |
| return s | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description="List available APT package updates") | |
| parser.add_argument( | |
| "--csv", | |
| action="store_const", | |
| dest="format", | |
| const="csv", | |
| help="Output table as comma-separated values", | |
| ) | |
| parser.add_argument( | |
| "--plain", | |
| action="store_const", | |
| dest="format", | |
| const="plain", | |
| help="Output a plain table (default)", | |
| ) | |
| parser.add_argument( | |
| "--pretty", | |
| action="store_const", | |
| dest="format", | |
| const="pretty", | |
| help="Output a pretty table", | |
| ) | |
| args = parser.parse_args() | |
| updates = AptChecker().apt_check() | |
| if args.format is None or args.format == "plain": | |
| for upd in updates: | |
| print( | |
| f"{upd.package:20} {upd.installed!s:20} {upd.candidate:20} {upd.flags}" | |
| ) | |
| elif args.format == "pretty": | |
| tbl = Table(["Package", "Installed", "Candidate", "SP"]) | |
| for upd in sorted(updates, key=attrgetter("package")): | |
| tbl.add_row([upd.package, upd.installed or "", upd.candidate, upd.flags]) | |
| print(tbl.render()) | |
| elif args.format == "csv": | |
| out = csv.writer(sys.stdout) | |
| out.writerow(["package", "installed", "candidate", "security", "phased"]) | |
| for upd in updates: | |
| out.writerow( | |
| [ | |
| upd.package, | |
| upd.installed, | |
| upd.candidate, | |
| "S" if upd.security else "", | |
| "P" if upd.phased else "", | |
| ] | |
| ) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment