Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
"_",
Expand All @@ -47,4 +51,5 @@
"validate_package_name",
"get_command_output",
"pacman_query_installed",
"pacman_required_by",
]
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
TEMP_FILES,
load_json_file,
pacman_query_installed,
pacman_required_by,
validate_package_name,
write_text_file,
)
Expand Down Expand Up @@ -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
53 changes: 51 additions & 2 deletions tests/test_calamares_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
Loading