diff --git a/biglinux-livecd/usr/share/biglinux/calamares/src/infrastructure/__init__.py b/biglinux-livecd/usr/share/biglinux/calamares/src/infrastructure/__init__.py index cd9e6b2..61aaf3a 100644 --- a/biglinux-livecd/usr/share/biglinux/calamares/src/infrastructure/__init__.py +++ b/biglinux-livecd/usr/share/biglinux/calamares/src/infrastructure/__init__.py @@ -23,7 +23,11 @@ write_text_file, ) from .i18n import _ -from .subprocesses import get_command_output, pacman_query_installed +from .subprocesses import ( + get_command_output, + pacman_query_installed, + pacman_required_by, +) __all__ = [ "_", @@ -47,4 +51,5 @@ "validate_package_name", "get_command_output", "pacman_query_installed", + "pacman_required_by", ] diff --git a/biglinux-livecd/usr/share/biglinux/calamares/src/infrastructure/subprocesses.py b/biglinux-livecd/usr/share/biglinux/calamares/src/infrastructure/subprocesses.py index 291b2f2..1969fd2 100644 --- a/biglinux-livecd/usr/share/biglinux/calamares/src/infrastructure/subprocesses.py +++ b/biglinux-livecd/usr/share/biglinux/calamares/src/infrastructure/subprocesses.py @@ -26,3 +26,32 @@ def pacman_query_installed() -> list[str]: return [] output = get_command_output(["pacman", "-Qq"]) return output.splitlines() if output else [] + + +def pacman_required_by(packages: Sequence[str]) -> dict[str, list[str]]: + """Map each installed package to the installed packages that depend on it.""" + if not packages or shutil.which("pacman") is None: + return {} + # "Required By" is a translated label, so pin the locale to parse it. + output = get_command_output( + ["env", "LC_ALL=C", "pacman", "-Qi", "--", *packages], timeout=60 + ) + if not output: + return {} + required_by: dict[str, list[str]] = {} + name = "" + field = "" + for line in output.splitlines(): + if line[:1].isspace(): + # A value wrapped onto the next line belongs to the field above. + key, value = field, line + else: + key, _sep, value = line.partition(":") + key = key.strip() + field = key + if key == "Name": + name = value.strip() + elif key == "Required By" and name: + values = [item for item in value.split() if item != "None"] + required_by.setdefault(name, []).extend(values) + return required_by diff --git a/biglinux-livecd/usr/share/biglinux/calamares/src/pages/minimal_page.py b/biglinux-livecd/usr/share/biglinux/calamares/src/pages/minimal_page.py index a2fb115..db4ee6f 100644 --- a/biglinux-livecd/usr/share/biglinux/calamares/src/pages/minimal_page.py +++ b/biglinux-livecd/usr/share/biglinux/calamares/src/pages/minimal_page.py @@ -205,7 +205,9 @@ def set_all_selected(self, selected: bool): def do_continue_action(self, button): """This method is called by the main window's continue button.""" self.logger.info("Continue with minimal installation") - packages_to_remove = [pkg.name for pkg in self.packages if not pkg.selected] + packages_to_remove = self.package_service.with_dependents( + [pkg.name for pkg in self.packages if not pkg.selected] + ) if not packages_to_remove: self.logger.info( diff --git a/biglinux-livecd/usr/share/biglinux/calamares/src/services/package_service.py b/biglinux-livecd/usr/share/biglinux/calamares/src/services/package_service.py index 09eec5d..693891b 100644 --- a/biglinux-livecd/usr/share/biglinux/calamares/src/services/package_service.py +++ b/biglinux-livecd/usr/share/biglinux/calamares/src/services/package_service.py @@ -11,6 +11,7 @@ TEMP_FILES, load_json_file, pacman_query_installed, + pacman_required_by, validate_package_name, write_text_file, ) @@ -88,3 +89,32 @@ def get_packages_for_removal(self, selected_packages: list[str]) -> list[str]: ] write_text_file("\n".join(packages), TEMP_FILES["packages_to_remove"]) return packages + + def with_dependents(self, packages: list[str]) -> list[str]: + """Add every installed package that depends on one being removed. + + Calamares removes with ``pacman -Rs``, which refuses to take away a + package that something still installed requires. Unchecking + LibreOffice left its language packs behind, so the whole + installation failed with "Package Manager error". + """ + requested = [name for name in packages if name in self._installed_packages] + seen = set(requested) + added: list[str] = [] + pending = requested + while pending: + dependents = pacman_required_by(pending) + pending = [] + for names in dependents.values(): + for name in names: + if ( + name not in seen + and name in self._installed_packages + and validate_package_name(name) + ): + seen.add(name) + added.append(name) + pending.append(name) + if added: + self.logger.info("Also removing dependent packages: %s", " ".join(added)) + return requested + added diff --git a/tests/test_calamares_configuration.py b/tests/test_calamares_configuration.py index d4a173c..dad28d6 100644 --- a/tests/test_calamares_configuration.py +++ b/tests/test_calamares_configuration.py @@ -15,9 +15,9 @@ CALAMARES = REPOSITORY / "biglinux-livecd/usr/share/biglinux/calamares" sys.path.insert(0, str(CALAMARES)) -from src.infrastructure import file_operations # noqa: E402 +from src.infrastructure import file_operations, subprocesses # noqa: E402 from src.profile import load_profile # noqa: E402 -from src.services import install_service, system_service # noqa: E402 +from src.services import install_service, package_service, system_service # noqa: E402 def test_atomic_text_write_preserves_old_file_on_replace_failure( @@ -166,6 +166,55 @@ def test_packages_configuration_yaml_and_invalid_name( assert not install_service.InstallService._configure_package_settings(owner) +def test_pacman_required_by_parses_wrapped_values(monkeypatch) -> None: + output = ( + "Name : libreoffice-fresh\n" + "Required By : libreoffice-fresh-pt-br\n" + " kde-servicemenus-officeconverter\n" + "Optional For : None\n" + "\n" + "Name : jamesdsp\n" + "Required By : None\n" + ) + monkeypatch.setattr(subprocesses.shutil, "which", lambda _name: "/usr/bin/pacman") + monkeypatch.setattr(subprocesses, "get_command_output", lambda *_a, **_k: output) + assert subprocesses.pacman_required_by(["libreoffice-fresh", "jamesdsp"]) == { + "libreoffice-fresh": [ + "libreoffice-fresh-pt-br", + "kde-servicemenus-officeconverter", + ], + "jamesdsp": [], + } + + +def test_minimal_removal_includes_installed_dependents(monkeypatch) -> None: + # pacman -Rs aborts the whole installation when a package that stays + # installed still requires one being removed. + graph = { + "libreoffice-fresh": ["libreoffice-fresh-pt-br", "not-installed"], + "libreoffice-fresh-pt-br": ["pt-br-addon"], + "steam": [], + } + monkeypatch.setattr( + package_service, + "pacman_required_by", + lambda names: {name: graph.get(name, []) for name in names}, + ) + service = package_service.PackageService() + service._installed_packages = { + "libreoffice-fresh", + "libreoffice-fresh-pt-br", + "pt-br-addon", + "steam", + } + assert service.with_dependents(["libreoffice-fresh", "steam", "missing"]) == [ + "libreoffice-fresh", + "steam", + "libreoffice-fresh-pt-br", + "pt-br-addon", + ] + + def test_install_only_journey_generates_packages_configuration( monkeypatch, tmp_path: Path ) -> None: