From 55f4a1e9410d816353ec539279df305ea291e9b2 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sun, 14 Dec 2025 09:35:52 +0100 Subject: [PATCH] refactor(update): move update logic to unified UpdateManager and extend system support - Move update orchestration from repository scope to actions/update - Introduce UpdateManager and SystemUpdater with distro detection - Add Arch, Debian/Ubuntu, and Fedora/RHEL system update handling - Rename CLI flag from --system-update to --system - Route update as a top-level command in CLI dispatch - Remove legacy update_repos implementation - Add E2E tests for: - update all without system updates - update single repo (pkgmgr) with system updates https://chatgpt.com/share/693e76ec-5ee4-800f-9623-3983f56d5430 --- src/pkgmgr/actions/repository/update.py | 58 -------- src/pkgmgr/actions/update/__init__.py | 10 ++ src/pkgmgr/actions/update/manager.py | 61 +++++++++ src/pkgmgr/actions/update/os_release.py | 66 ++++++++++ src/pkgmgr/actions/update/system_updater.py | 96 ++++++++++++++ src/pkgmgr/cli/commands/repos.py | 20 --- src/pkgmgr/cli/dispatch.py | 21 +++ src/pkgmgr/cli/parser/install_update.py | 4 +- ...te_all.py => test_update_all_no_system.py} | 22 ++-- tests/e2e/test_update_pkgmgr_system.py | 124 ++++++++++++++++++ 10 files changed, 390 insertions(+), 92 deletions(-) delete mode 100644 src/pkgmgr/actions/repository/update.py create mode 100644 src/pkgmgr/actions/update/__init__.py create mode 100644 src/pkgmgr/actions/update/manager.py create mode 100644 src/pkgmgr/actions/update/os_release.py create mode 100644 src/pkgmgr/actions/update/system_updater.py rename tests/e2e/{test_update_all.py => test_update_all_no_system.py} (85%) create mode 100644 tests/e2e/test_update_pkgmgr_system.py diff --git a/src/pkgmgr/actions/repository/update.py b/src/pkgmgr/actions/repository/update.py deleted file mode 100644 index 76afd91..0000000 --- a/src/pkgmgr/actions/repository/update.py +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import shutil - -from pkgmgr.actions.install import install_repos -from pkgmgr.actions.repository.pull import pull_with_verification - - -def update_repos( - selected_repos, - repositories_base_dir, - bin_dir, - all_repos, - no_verification, - system_update, - preview: bool, - quiet: bool, - update_dependencies: bool, - clone_mode: str, - force_update: bool = True, -) -> None: - """ - Update repositories by pulling latest changes and installing them. - """ - pull_with_verification( - selected_repos, - repositories_base_dir, - all_repos, - [], - no_verification, - preview, - ) - - install_repos( - selected_repos, - repositories_base_dir, - bin_dir, - all_repos, - no_verification, - preview, - quiet, - clone_mode, - update_dependencies, - force_update=force_update, - ) - - if system_update: - from pkgmgr.core.command.run import run_command - - if shutil.which("nix") is not None: - try: - run_command("nix profile upgrade '.*'", preview=preview) - except SystemExit as e: - print(f"[Warning] 'nix profile upgrade' failed: {e}") - - run_command("sudo -u aur_builder yay -Syu --noconfirm", preview=preview) - run_command("sudo pacman -Syyu --noconfirm", preview=preview) diff --git a/src/pkgmgr/actions/update/__init__.py b/src/pkgmgr/actions/update/__init__.py new file mode 100644 index 0000000..d0a5b7d --- /dev/null +++ b/src/pkgmgr/actions/update/__init__.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +from pkgmgr.actions.update.manager import UpdateManager + +__all__ = [ + "UpdateManager", +] diff --git a/src/pkgmgr/actions/update/manager.py b/src/pkgmgr/actions/update/manager.py new file mode 100644 index 0000000..e2b5e80 --- /dev/null +++ b/src/pkgmgr/actions/update/manager.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +from typing import Any, Iterable + +from pkgmgr.actions.update.system_updater import SystemUpdater + + +class UpdateManager: + """ + Orchestrates: + - repository pull + installation + - optional system update + """ + + def __init__(self) -> None: + self._system_updater = SystemUpdater() + + def run( + self, + selected_repos: Iterable[Any], + repositories_base_dir: str, + bin_dir: str, + all_repos: Any, + no_verification: bool, + system_update: bool, + preview: bool, + quiet: bool, + update_dependencies: bool, + clone_mode: str, + force_update: bool = True, + ) -> None: + from pkgmgr.actions.install import install_repos + from pkgmgr.actions.repository.pull import pull_with_verification + + pull_with_verification( + selected_repos, + repositories_base_dir, + all_repos, + [], + no_verification, + preview, + ) + + install_repos( + selected_repos, + repositories_base_dir, + bin_dir, + all_repos, + no_verification, + preview, + quiet, + clone_mode, + update_dependencies, + force_update=force_update, + ) + + if system_update: + self._system_updater.run(preview=preview) diff --git a/src/pkgmgr/actions/update/os_release.py b/src/pkgmgr/actions/update/os_release.py new file mode 100644 index 0000000..83d3a6b --- /dev/null +++ b/src/pkgmgr/actions/update/os_release.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Dict + + +def read_os_release(path: str = "/etc/os-release") -> Dict[str, str]: + """ + Parse /etc/os-release into a dict. Returns empty dict if missing. + """ + if not os.path.exists(path): + return {} + + result: Dict[str, str] = {} + with open(path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + result[key.strip()] = value.strip().strip('"') + return result + + +@dataclass(frozen=True) +class OSReleaseInfo: + """ + Minimal /etc/os-release representation for distro detection. + """ + id: str = "" + id_like: str = "" + pretty_name: str = "" + + @staticmethod + def load() -> "OSReleaseInfo": + data = read_os_release() + return OSReleaseInfo( + id=(data.get("ID") or "").lower(), + id_like=(data.get("ID_LIKE") or "").lower(), + pretty_name=(data.get("PRETTY_NAME") or ""), + ) + + def ids(self) -> set[str]: + ids: set[str] = set() + if self.id: + ids.add(self.id) + if self.id_like: + for part in self.id_like.split(): + ids.add(part.strip()) + return ids + + def is_arch_family(self) -> bool: + ids = self.ids() + return ("arch" in ids) or ("archlinux" in ids) + + def is_debian_family(self) -> bool: + ids = self.ids() + return bool(ids.intersection({"debian", "ubuntu"})) + + def is_fedora_family(self) -> bool: + ids = self.ids() + return bool(ids.intersection({"fedora", "rhel", "centos", "rocky", "almalinux"})) diff --git a/src/pkgmgr/actions/update/system_updater.py b/src/pkgmgr/actions/update/system_updater.py new file mode 100644 index 0000000..e215a29 --- /dev/null +++ b/src/pkgmgr/actions/update/system_updater.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import platform +import shutil + +from pkgmgr.actions.update.os_release import OSReleaseInfo + + +class SystemUpdater: + """ + Executes distro-specific system update commands, plus Nix profile upgrades if available. + """ + + def run(self, *, preview: bool) -> None: + from pkgmgr.core.command.run import run_command + + # Distro-agnostic: Nix profile upgrades (if Nix is present). + if shutil.which("nix") is not None: + try: + run_command("nix profile upgrade '.*'", preview=preview) + except SystemExit as e: + print(f"[Warning] 'nix profile upgrade' failed: {e}") + + osr = OSReleaseInfo.load() + + if osr.is_arch_family(): + self._update_arch(preview=preview) + return + + if osr.is_debian_family(): + self._update_debian(preview=preview) + return + + if osr.is_fedora_family(): + self._update_fedora(preview=preview) + return + + distro = osr.pretty_name or platform.platform() + print(f"[Warning] Unsupported distribution for system update: {distro}") + + def _update_arch(self, *, preview: bool) -> None: + from pkgmgr.core.command.run import run_command + + yay = shutil.which("yay") + pacman = shutil.which("pacman") + sudo = shutil.which("sudo") + + # Prefer yay if available (repo + AUR in one pass). + # Avoid running yay and pacman afterwards to prevent double update passes. + if yay and sudo: + run_command("sudo -u aur_builder yay -Syu --noconfirm", preview=preview) + return + + if pacman and sudo: + run_command("sudo pacman -Syu --noconfirm", preview=preview) + return + + print("[Warning] Cannot update Arch system: missing required tools (sudo/yay/pacman).") + + def _update_debian(self, *, preview: bool) -> None: + from pkgmgr.core.command.run import run_command + + sudo = shutil.which("sudo") + apt_get = shutil.which("apt-get") + + if not (sudo and apt_get): + print("[Warning] Cannot update Debian/Ubuntu system: missing required tools (sudo/apt-get).") + return + + env = "DEBIAN_FRONTEND=noninteractive" + run_command(f"sudo {env} apt-get update -y", preview=preview) + run_command(f"sudo {env} apt-get -y dist-upgrade", preview=preview) + + def _update_fedora(self, *, preview: bool) -> None: + from pkgmgr.core.command.run import run_command + + sudo = shutil.which("sudo") + dnf = shutil.which("dnf") + microdnf = shutil.which("microdnf") + + if not sudo: + print("[Warning] Cannot update Fedora/RHEL-like system: missing sudo.") + return + + if dnf: + run_command("sudo dnf -y upgrade", preview=preview) + return + + if microdnf: + run_command("sudo microdnf -y upgrade", preview=preview) + return + + print("[Warning] Cannot update Fedora/RHEL-like system: missing dnf/microdnf.") diff --git a/src/pkgmgr/cli/commands/repos.py b/src/pkgmgr/cli/commands/repos.py index 700e52f..ab34406 100644 --- a/src/pkgmgr/cli/commands/repos.py +++ b/src/pkgmgr/cli/commands/repos.py @@ -8,7 +8,6 @@ from typing import Any, Dict, List from pkgmgr.cli.context import CLIContext from pkgmgr.actions.install import install_repos -from pkgmgr.actions.repository.update import update_repos from pkgmgr.actions.repository.deinstall import deinstall_repos from pkgmgr.actions.repository.delete import delete_repos from pkgmgr.actions.repository.status import status_repos @@ -72,25 +71,6 @@ def handle_repos_command( ) return - # ------------------------------------------------------------ - # update - # ------------------------------------------------------------ - if args.command == "update": - update_repos( - selected, - ctx.repositories_base_dir, - ctx.binaries_dir, - ctx.all_repositories, - args.no_verification, - args.system_update, - args.preview, - args.quiet, - args.dependencies, - args.clone_mode, - force_update=True, - ) - return - # ------------------------------------------------------------ # deinstall # ------------------------------------------------------------ diff --git a/src/pkgmgr/cli/dispatch.py b/src/pkgmgr/cli/dispatch.py index 2c5e88c..f42c555 100644 --- a/src/pkgmgr/cli/dispatch.py +++ b/src/pkgmgr/cli/dispatch.py @@ -141,6 +141,27 @@ def dispatch_command(args, ctx: CLIContext) -> None: handle_repos_command(args, ctx, selected) return + # ------------------------------------------------------------ + # update + # ------------------------------------------------------------ + if args.command == "update": + from pkgmgr.actions.update import UpdateManager + UpdateManager().run( + selected_repos=selected, + repositories_base_dir=ctx.repositories_base_dir, + bin_dir=ctx.binaries_dir, + all_repos=ctx.all_repositories, + no_verification=args.no_verification, + system_update=args.system, + preview=args.preview, + quiet=args.quiet, + update_dependencies=args.dependencies, + clone_mode=args.clone_mode, + force_update=True, + ) + return + + # ------------------------------------------------------------------ # # Tools (explore / terminal / code) # ------------------------------------------------------------------ # diff --git a/src/pkgmgr/cli/parser/install_update.py b/src/pkgmgr/cli/parser/install_update.py index 6c0d620..5993a5e 100644 --- a/src/pkgmgr/cli/parser/install_update.py +++ b/src/pkgmgr/cli/parser/install_update.py @@ -33,8 +33,8 @@ def add_install_update_subparsers( ) add_install_update_arguments(update_parser) update_parser.add_argument( - "--system-update", - dest="system_update", + "--system", + dest="system", action="store_true", help="Include system update commands", ) diff --git a/tests/e2e/test_update_all.py b/tests/e2e/test_update_all_no_system.py similarity index 85% rename from tests/e2e/test_update_all.py rename to tests/e2e/test_update_all_no_system.py index 1332ec9..2424202 100644 --- a/tests/e2e/test_update_all.py +++ b/tests/e2e/test_update_all_no_system.py @@ -1,6 +1,6 @@ """ Integration test: update all configured repositories using ---clone-mode https and --no-verification. +--clone-mode shallow and --no-verification, WITHOUT system updates. This test is intended to be run inside the Docker container where: - network access is available, @@ -8,8 +8,8 @@ This test is intended to be run inside the Docker container where: - and it is safe to perform real git operations. It passes if BOTH commands complete successfully (in separate tests): - 1) pkgmgr update --all --clone-mode https --no-verification --system-update - 2) nix run .#pkgmgr -- update --all --clone-mode https --no-verification --system-update + 1) pkgmgr update --all --clone-mode shallow --no-verification + 2) nix run .#pkgmgr -- update --all --clone-mode shallow --no-verification """ from __future__ import annotations @@ -38,7 +38,7 @@ def _make_temp_gitconfig_with_safe_dirs(home: Path) -> Path: return gitconfig -class TestIntegrationUpdateAllHttps(unittest.TestCase): +class TestIntegrationUpdateAllshallowNoSystem(unittest.TestCase): def _common_env(self, home_dir: str) -> dict[str, str]: env = os.environ.copy() env["HOME"] = home_dir @@ -86,32 +86,30 @@ class TestIntegrationUpdateAllHttps(unittest.TestCase): remove_pkgmgr_from_nix_profile() nix_profile_list_debug("AFTER CLEANUP") - def test_update_all_repositories_https_pkgmgr(self) -> None: + def test_update_all_repositories_shallow_pkgmgr_no_system(self) -> None: self._common_setup() - with tempfile.TemporaryDirectory(prefix="pkgmgr-updateall-") as tmp: + with tempfile.TemporaryDirectory(prefix="pkgmgr-updateall-nosys-") as tmp: env = self._common_env(tmp) args = [ "update", "--all", "--clone-mode", - "https", + "shallow", "--no-verification", - "--system-update", ] self._run_cmd(["pkgmgr", *args], label="pkgmgr", env=env) pkgmgr_help_debug() - def test_update_all_repositories_https_nix_pkgmgr(self) -> None: + def test_update_all_repositories_shallow_nix_pkgmgr_no_system(self) -> None: self._common_setup() - with tempfile.TemporaryDirectory(prefix="pkgmgr-updateall-nix-") as tmp: + with tempfile.TemporaryDirectory(prefix="pkgmgr-updateall-nosys-nix-") as tmp: env = self._common_env(tmp) args = [ "update", "--all", "--clone-mode", - "https", + "shallow", "--no-verification", - "--system-update", ] self._run_cmd( ["nix", "run", ".#pkgmgr", "--", *args], diff --git a/tests/e2e/test_update_pkgmgr_system.py b/tests/e2e/test_update_pkgmgr_system.py new file mode 100644 index 0000000..67ad80a --- /dev/null +++ b/tests/e2e/test_update_pkgmgr_system.py @@ -0,0 +1,124 @@ +""" +Integration test: update ONLY the 'pkgmgr' repository with system updates enabled. + +This test is intended to be run inside the Docker container where: + - network access is available, + - the config/config.yaml is present, + - and it is safe to perform real git operations. + +It passes if BOTH commands complete successfully (in separate tests): + 1) pkgmgr update pkgmgr --clone-mode shallow --no-verification --system + 2) nix run .#pkgmgr -- update pkgmgr --clone-mode shallow --no-verification --system +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +from test_install_pkgmgr_shallow import ( + nix_profile_list_debug, + remove_pkgmgr_from_nix_profile, + pkgmgr_help_debug, +) + + +def _make_temp_gitconfig_with_safe_dirs(home: Path) -> Path: + gitconfig = home / ".gitconfig" + gitconfig.write_text( + "[safe]\n" + "\tdirectory = /src\n" + "\tdirectory = /src/.git\n" + "\tdirectory = *\n" + ) + return gitconfig + + +class TestIntegrationUpdatePkgmgrWithSystem(unittest.TestCase): + def _common_env(self, home_dir: str) -> dict[str, str]: + env = os.environ.copy() + env["HOME"] = home_dir + + home = Path(home_dir) + home.mkdir(parents=True, exist_ok=True) + + env["GIT_CONFIG_GLOBAL"] = str(_make_temp_gitconfig_with_safe_dirs(home)) + + # Ensure nix is discoverable if the container has it + env["PATH"] = "/nix/var/nix/profiles/default/bin:" + env.get("PATH", "") + + return env + + def _run_cmd(self, cmd: list[str], label: str, env: dict[str, str]) -> None: + cmd_repr = " ".join(cmd) + print(f"\n[TEST] Running ({label}): {cmd_repr}") + + proc = subprocess.run( + cmd, + check=False, + cwd=os.getcwd(), + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + + print(proc.stdout.rstrip()) + + if proc.returncode != 0: + print(f"\n[TEST] Command failed ({label})") + print(f"[TEST] Command : {cmd_repr}") + print(f"[TEST] Exit code: {proc.returncode}") + + nix_profile_list_debug(f"ON FAILURE ({label})") + + raise AssertionError( + f"({label}) {cmd_repr!r} failed with exit code {proc.returncode}.\n\n" + f"--- output ---\n{proc.stdout}\n" + ) + + def _common_setup(self) -> None: + nix_profile_list_debug("BEFORE CLEANUP") + remove_pkgmgr_from_nix_profile() + nix_profile_list_debug("AFTER CLEANUP") + + def test_update_pkgmgr_shallow_pkgmgr_with_system(self) -> None: + self._common_setup() + with tempfile.TemporaryDirectory(prefix="pkgmgr-update-pkgmgr-sys-") as tmp: + env = self._common_env(tmp) + args = [ + "update", + "pkgmgr", + "--clone-mode", + "shallow", + "--no-verification", + "--system", + ] + self._run_cmd(["pkgmgr", *args], label="pkgmgr", env=env) + pkgmgr_help_debug() + + def test_update_pkgmgr_shallow_nix_pkgmgr_with_system(self) -> None: + self._common_setup() + with tempfile.TemporaryDirectory(prefix="pkgmgr-update-pkgmgr-sys-nix-") as tmp: + env = self._common_env(tmp) + args = [ + "update", + "pkgmgr", + "--clone-mode", + "shallow", + "--no-verification", + "--system", + ] + self._run_cmd( + ["nix", "run", ".#pkgmgr", "--", *args], + label="nix run .#pkgmgr", + env=env, + ) + pkgmgr_help_debug() + + +if __name__ == "__main__": + unittest.main()