diff --git a/src/pkgmgr/actions/install/__init__.py b/src/pkgmgr/actions/install/__init__.py index 7d530e0..aaa6977 100644 --- a/src/pkgmgr/actions/install/__init__.py +++ b/src/pkgmgr/actions/install/__init__.py @@ -16,7 +16,7 @@ Responsibilities: from __future__ import annotations import os -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from pkgmgr.core.repository.identifier import get_repo_identifier from pkgmgr.core.repository.dir import get_repo_dir @@ -93,6 +93,7 @@ def _verify_repo( repo_dir: str, no_verification: bool, identifier: str, + silent: bool, ) -> bool: """ Verify a repository using the configured verification data. @@ -111,10 +112,15 @@ def _verify_repo( print(f"Warning: Verification failed for {identifier}:") for err in errors: print(f" - {err}") - choice = input("Continue anyway? [y/N]: ").strip().lower() - if choice != "y": - print(f"Skipping installation for {identifier}.") - return False + + if silent: + # Non-interactive mode: continue with a warning. + print(f"[Warning] Continuing despite verification failure for {identifier} (--silent).") + else: + choice = input("Continue anyway? [y/N]: ").strip().lower() + if choice != "y": + print(f"Skipping installation for {identifier}.") + return False return True @@ -163,6 +169,8 @@ def install_repos( clone_mode: str, update_dependencies: bool, force_update: bool = False, + silent: bool = False, + emit_summary: bool = True, ) -> None: """ Install one or more repositories according to the configured installers @@ -170,45 +178,72 @@ def install_repos( If force_update=True, installers of the currently active layer are allowed to run again (upgrade/refresh), even if that layer is already loaded. + + If silent=True, repository failures are downgraded to warnings and the + overall command never exits non-zero because of per-repository failures. """ pipeline = InstallationPipeline(INSTALLERS) + failures: List[Tuple[str, str]] = [] for repo in selected_repos: identifier = get_repo_identifier(repo, all_repos) - repo_dir = _ensure_repo_dir( - repo=repo, - repositories_base_dir=repositories_base_dir, - all_repos=all_repos, - preview=preview, - no_verification=no_verification, - clone_mode=clone_mode, - identifier=identifier, - ) - if not repo_dir: + try: + repo_dir = _ensure_repo_dir( + repo=repo, + repositories_base_dir=repositories_base_dir, + all_repos=all_repos, + preview=preview, + no_verification=no_verification, + clone_mode=clone_mode, + identifier=identifier, + ) + if not repo_dir: + failures.append((identifier, "clone/ensure repo directory failed")) + continue + + if not _verify_repo( + repo=repo, + repo_dir=repo_dir, + no_verification=no_verification, + identifier=identifier, + silent=silent, + ): + continue + + ctx = _create_context( + repo=repo, + identifier=identifier, + repo_dir=repo_dir, + repositories_base_dir=repositories_base_dir, + bin_dir=bin_dir, + all_repos=all_repos, + no_verification=no_verification, + preview=preview, + quiet=quiet, + clone_mode=clone_mode, + update_dependencies=update_dependencies, + force_update=force_update, + ) + + pipeline.run(ctx) + + except SystemExit as exc: + code = exc.code if isinstance(exc.code, int) else str(exc.code) + failures.append((identifier, f"installer failed (exit={code})")) + if not quiet: + print(f"[Warning] install: repository {identifier} failed (exit={code}). Continuing...") + continue + except Exception as exc: + failures.append((identifier, f"unexpected error: {exc}")) + if not quiet: + print(f"[Warning] install: repository {identifier} hit an unexpected error: {exc}. Continuing...") continue - if not _verify_repo( - repo=repo, - repo_dir=repo_dir, - no_verification=no_verification, - identifier=identifier, - ): - continue + if failures and emit_summary and not quiet: + print("\n[pkgmgr] Installation finished with warnings:") + for ident, msg in failures: + print(f" - {ident}: {msg}") - ctx = _create_context( - repo=repo, - identifier=identifier, - repo_dir=repo_dir, - repositories_base_dir=repositories_base_dir, - bin_dir=bin_dir, - all_repos=all_repos, - no_verification=no_verification, - preview=preview, - quiet=quiet, - clone_mode=clone_mode, - update_dependencies=update_dependencies, - force_update=force_update, - ) - - pipeline.run(ctx) + if failures and not silent: + raise SystemExit(1) diff --git a/src/pkgmgr/actions/update/manager.py b/src/pkgmgr/actions/update/manager.py index e2b5e80..016d6ea 100644 --- a/src/pkgmgr/actions/update/manager.py +++ b/src/pkgmgr/actions/update/manager.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Any, Iterable +from typing import Any, Iterable, List, Tuple from pkgmgr.actions.update.system_updater import SystemUpdater @@ -30,32 +30,73 @@ class UpdateManager: quiet: bool, update_dependencies: bool, clone_mode: str, + silent: bool = False, force_update: bool = True, ) -> None: from pkgmgr.actions.install import install_repos from pkgmgr.actions.repository.pull import pull_with_verification + from pkgmgr.core.repository.identifier import get_repo_identifier - pull_with_verification( - selected_repos, - repositories_base_dir, - all_repos, - [], - no_verification, - preview, - ) + failures: List[Tuple[str, str]] = [] - install_repos( - selected_repos, - repositories_base_dir, - bin_dir, - all_repos, - no_verification, - preview, - quiet, - clone_mode, - update_dependencies, - force_update=force_update, - ) + for repo in list(selected_repos): + identifier = get_repo_identifier(repo, all_repos) + + try: + pull_with_verification( + [repo], + repositories_base_dir, + all_repos, + [], + no_verification, + preview, + ) + except SystemExit as exc: + code = exc.code if isinstance(exc.code, int) else str(exc.code) + failures.append((identifier, f"pull failed (exit={code})")) + if not quiet: + print(f"[Warning] update: pull failed for {identifier} (exit={code}). Continuing...") + continue + except Exception as exc: + failures.append((identifier, f"pull failed: {exc}")) + if not quiet: + print(f"[Warning] update: pull failed for {identifier}: {exc}. Continuing...") + continue + + try: + install_repos( + [repo], + repositories_base_dir, + bin_dir, + all_repos, + no_verification, + preview, + quiet, + clone_mode, + update_dependencies, + force_update=force_update, + silent=silent, + emit_summary=False, + ) + except SystemExit as exc: + code = exc.code if isinstance(exc.code, int) else str(exc.code) + failures.append((identifier, f"install failed (exit={code})")) + if not quiet: + print(f"[Warning] update: install failed for {identifier} (exit={code}). Continuing...") + continue + except Exception as exc: + failures.append((identifier, f"install failed: {exc}")) + if not quiet: + print(f"[Warning] update: install failed for {identifier}: {exc}. Continuing...") + continue + + if failures and not quiet: + print("\n[pkgmgr] Update finished with warnings:") + for ident, msg in failures: + print(f" - {ident}: {msg}") + + if failures and not silent: + raise SystemExit(1) if system_update: self._system_updater.run(preview=preview) diff --git a/src/pkgmgr/cli/commands/repos.py b/src/pkgmgr/cli/commands/repos.py index ab34406..37f187e 100644 --- a/src/pkgmgr/cli/commands/repos.py +++ b/src/pkgmgr/cli/commands/repos.py @@ -68,6 +68,7 @@ def handle_repos_command( args.clone_mode, args.dependencies, force_update=getattr(args, "update", False), + silent=getattr(args, "silent", False), ) return diff --git a/src/pkgmgr/cli/dispatch.py b/src/pkgmgr/cli/dispatch.py index 20a6381..2f92308 100644 --- a/src/pkgmgr/cli/dispatch.py +++ b/src/pkgmgr/cli/dispatch.py @@ -105,6 +105,7 @@ def dispatch_command(args, ctx: CLIContext) -> None: if args.command == "update": from pkgmgr.actions.update import UpdateManager + UpdateManager().run( selected_repos=selected, repositories_base_dir=ctx.repositories_base_dir, @@ -116,6 +117,7 @@ def dispatch_command(args, ctx: CLIContext) -> None: quiet=args.quiet, update_dependencies=args.dependencies, clone_mode=args.clone_mode, + silent=getattr(args, "silent", False), force_update=True, ) return diff --git a/src/pkgmgr/cli/parser/common.py b/src/pkgmgr/cli/parser/common.py index 16612a7..2255ed1 100644 --- a/src/pkgmgr/cli/parser/common.py +++ b/src/pkgmgr/cli/parser/common.py @@ -168,3 +168,10 @@ def add_install_update_arguments(subparser: argparse.ArgumentParser) -> None: default="ssh", help="Specify clone mode (default: ssh).", ) + + _add_option_if_missing( + subparser, + "--silent", + action="store_true", + help="Continue with other repositories if one fails; downgrade errors to warnings.", + ) diff --git a/tests/e2e/test_update_all_no_system.py b/tests/e2e/test_update_all_no_system.py index 2424202..75f58d6 100644 --- a/tests/e2e/test_update_all_no_system.py +++ b/tests/e2e/test_update_all_no_system.py @@ -96,6 +96,7 @@ class TestIntegrationUpdateAllshallowNoSystem(unittest.TestCase): "--clone-mode", "shallow", "--no-verification", + "--silent", ] self._run_cmd(["pkgmgr", *args], label="pkgmgr", env=env) pkgmgr_help_debug() @@ -110,6 +111,7 @@ class TestIntegrationUpdateAllshallowNoSystem(unittest.TestCase): "--clone-mode", "shallow", "--no-verification", + "--silent", ] self._run_cmd( ["nix", "run", ".#pkgmgr", "--", *args], diff --git a/tests/integration/test_update_silent_continues.py b/tests/integration/test_update_silent_continues.py new file mode 100644 index 0000000..0fd52ae --- /dev/null +++ b/tests/integration/test_update_silent_continues.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from pkgmgr.actions.update.manager import UpdateManager + + +class TestUpdateSilentContinues(unittest.TestCase): + def test_update_continues_on_failures_and_silent_controls_exit_code(self) -> None: + """ + Integration test for UpdateManager: + - pull failure on repo A should not stop repo B/C + - install failure on repo B should not stop repo C + - without silent -> SystemExit(1) at end if any failures + - with silent -> no SystemExit even if there are failures + """ + + repos = [ + {"provider": "github", "account": "example", "repository": "repo-a"}, + {"provider": "github", "account": "example", "repository": "repo-b"}, + {"provider": "github", "account": "example", "repository": "repo-c"}, + ] + + # We patch the internal calls used by UpdateManager: + # - pull_with_verification is called once per repo + # - install_repos is called once per repo that successfully pulled + # + # We simulate: + # repo-a: pull fails + # repo-b: pull ok, install fails + # repo-c: pull ok, install ok + pull_calls = [] + install_calls = [] + + def pull_side_effect(selected_repos, *_args, **_kwargs): + # selected_repos is a list with exactly one repo in our implementation. + repo = selected_repos[0] + pull_calls.append(repo["repository"]) + if repo["repository"] == "repo-a": + raise SystemExit(2) + return None + + def install_side_effect(selected_repos, *_args, **kwargs): + repo = selected_repos[0] + install_calls.append((repo["repository"], kwargs.get("silent"), kwargs.get("emit_summary"))) + if repo["repository"] == "repo-b": + raise SystemExit(3) + return None + + # Patch at the exact import locations used inside UpdateManager.run() + with patch("pkgmgr.actions.repository.pull.pull_with_verification", side_effect=pull_side_effect), patch( + "pkgmgr.actions.install.install_repos", side_effect=install_side_effect + ): + # 1) silent=True: should NOT raise (even though failures happened) + UpdateManager().run( + selected_repos=repos, + repositories_base_dir="/tmp/repos", + bin_dir="/tmp/bin", + all_repos=repos, + no_verification=True, + system_update=False, + preview=True, + quiet=True, + update_dependencies=False, + clone_mode="shallow", + silent=True, + force_update=True, + ) + + # Ensure it tried all pulls, and installs happened for B and C only. + self.assertEqual(pull_calls, ["repo-a", "repo-b", "repo-c"]) + self.assertEqual([r for r, _silent, _emit in install_calls], ["repo-b", "repo-c"]) + + # Ensure UpdateManager suppressed install summary spam by passing emit_summary=False. + for _repo_name, _silent, emit_summary in install_calls: + self.assertFalse(emit_summary) + + # Reset tracking for the non-silent run + pull_calls.clear() + install_calls.clear() + + # 2) silent=False: should raise SystemExit(1) at end due to failures + with self.assertRaises(SystemExit) as cm: + UpdateManager().run( + selected_repos=repos, + repositories_base_dir="/tmp/repos", + bin_dir="/tmp/bin", + all_repos=repos, + no_verification=True, + system_update=False, + preview=True, + quiet=True, + update_dependencies=False, + clone_mode="shallow", + silent=False, + force_update=True, + ) + self.assertEqual(cm.exception.code, 1) + + # Still must have processed all repos (continue-on-failure behavior). + self.assertEqual(pull_calls, ["repo-a", "repo-b", "repo-c"]) + self.assertEqual([r for r, _silent, _emit in install_calls], ["repo-b", "repo-c"]) + + +if __name__ == "__main__": + unittest.main()