mirror of
https://github.com/kiviktnm/decman.git
synced 2026-09-19 20:18:28 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eafe7e7af9 | ||
|
|
5ac95facc3 | ||
|
|
37745d1730 | ||
|
|
7aa063f6b9 | ||
|
|
4203d2d439 | ||
|
|
c5898d82a3 | ||
|
|
cdbf1e8348 | ||
|
|
e4eb81bd5b | ||
|
|
97e94258a4 | ||
|
|
1214c69fba |
@@ -1,8 +1,8 @@
|
||||
[project]
|
||||
name = "decman-flatpak"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = ["decman==1.0.0"]
|
||||
dependencies = ["decman==1.1.0"]
|
||||
|
||||
[project.entry-points."decman.plugins"]
|
||||
flatpak = "decman.plugins.flatpak:Flatpak"
|
||||
|
||||
@@ -56,10 +56,14 @@ class Flatpak(plugins.Plugin):
|
||||
store["flatpaks_for_module"].setdefault(mod.name, set())
|
||||
store["user_flatpaks_for_module"].setdefault(mod.name, {})
|
||||
|
||||
packages = plugins.run_method_with_attribute(mod, "__flatpak__packages__") or set()
|
||||
user_packages = (
|
||||
plugins.run_method_with_attribute(mod, "__flatpak__user__packages__") or {}
|
||||
packages = set().union(
|
||||
*plugins.run_methods_with_attribute(mod, "__flatpak__packages__")
|
||||
)
|
||||
user_packages = {
|
||||
k: v
|
||||
for d in plugins.run_methods_with_attribute(mod, "__flatpak__user__packages__")
|
||||
for k, v in d.items()
|
||||
}
|
||||
|
||||
if store["flatpaks_for_module"][mod.name] != packages:
|
||||
mod._changed = True
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
[project]
|
||||
name = "decman-pacman"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"decman==1.0.0",
|
||||
"decman==1.1.0",
|
||||
"pyalpm",
|
||||
"requests",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.4.2",
|
||||
"pytest-mock>=3.15.1",
|
||||
]
|
||||
|
||||
[project.entry-points."decman.plugins"]
|
||||
pacman = "decman.plugins.pacman:Pacman"
|
||||
aur = "decman.plugins.aur:AUR"
|
||||
|
||||
@@ -99,9 +99,11 @@ class AUR(plugins.Plugin):
|
||||
store["aur_packages_for_module"].setdefault(mod.name, set())
|
||||
store["custom_packages_for_module"].setdefault(mod.name, set())
|
||||
|
||||
aur_packages = plugins.run_method_with_attribute(mod, "__aur__packages__") or set()
|
||||
custom_packages = (
|
||||
plugins.run_method_with_attribute(mod, "__custom__packages__") or set()
|
||||
aur_packages = set().union(
|
||||
*plugins.run_methods_with_attribute(mod, "__aur__packages__")
|
||||
)
|
||||
custom_packages = set().union(
|
||||
*plugins.run_methods_with_attribute(mod, "__custom__packages__")
|
||||
)
|
||||
custom_package_strs = set(map(str, custom_packages))
|
||||
|
||||
@@ -208,7 +210,8 @@ class AUR(plugins.Plugin):
|
||||
|
||||
output.print_summary("Upgrading foreign packages.")
|
||||
if not dry_run:
|
||||
fpm.upgrade(upgrade_devel, force, self.ignored_packages)
|
||||
# don't try to upgrade removed packages
|
||||
fpm.upgrade(upgrade_devel, force, self.ignored_packages | actually_to_remove)
|
||||
|
||||
to_install = (
|
||||
(self.packages | custom_package_names)
|
||||
|
||||
@@ -143,6 +143,16 @@ class AurPacmanInterface(pacman.PacmanInterface):
|
||||
"""
|
||||
return self._get_orphans(pacman.PacmanInterface._is_foreign)
|
||||
|
||||
def is_provided_by_installed(self, dependency: str) -> bool:
|
||||
return pacman.strip_dependency(dependency) in self._local_provides_index
|
||||
|
||||
def filter_installed_packages(self, deps: set[str]) -> set[str]:
|
||||
out = set()
|
||||
for d in deps:
|
||||
if not self.is_provided_by_installed(d) and d not in self.get_all_packages():
|
||||
out.add(d)
|
||||
return out
|
||||
|
||||
def is_installable(self, pkg: str) -> bool:
|
||||
"""
|
||||
Returns True if a package can be installed using pacman.
|
||||
|
||||
@@ -135,6 +135,9 @@ class ResolvedDependencies:
|
||||
self.foreign_build_dep_pkgs: set[str] = set()
|
||||
self.build_order: list[str] = []
|
||||
self.packages: dict[str, ForeignPackage] = {}
|
||||
# maps dependency names to package names
|
||||
self.providers: dict[str, list[str]] = {}
|
||||
self.all_provided: set[str] = set()
|
||||
self._pkgbases_to_pkgs: dict[str, set[str]] = {}
|
||||
self._pkgs_to_pkgbases: dict[str, str] = {}
|
||||
|
||||
@@ -277,8 +280,11 @@ class ForeignPackageManager:
|
||||
if not output.prompt_confirm("Proceed?", default=True):
|
||||
raise ForeignPackageManagerError("Installing aborted by the user.")
|
||||
|
||||
needed_pacman_deps = self._pacman.filter_installed_packages(
|
||||
resolved_dependencies.pacman_deps - resolved_dependencies.all_provided
|
||||
)
|
||||
output.print_summary("Installing foreign package dependencies from pacman.")
|
||||
self._pacman.install_dependencies(resolved_dependencies.pacman_deps)
|
||||
self._pacman.install_dependencies(needed_pacman_deps)
|
||||
|
||||
try:
|
||||
with PackageBuilder(
|
||||
@@ -319,7 +325,8 @@ class ForeignPackageManager:
|
||||
output.print_summary("Installing foreign packages.")
|
||||
self._pacman.install_files(
|
||||
package_files_to_install,
|
||||
as_explicit=resolved_dependencies.foreign_pkgs,
|
||||
as_explicit=resolved_dependencies.foreign_pkgs
|
||||
- resolved_dependencies.foreign_dep_pkgs,
|
||||
)
|
||||
else:
|
||||
output.print_summary("No packages to install.")
|
||||
@@ -379,6 +386,10 @@ class ForeignPackageManager:
|
||||
f"Failed to find '{pkgname}' from AUR or user provided packages."
|
||||
)
|
||||
|
||||
for provided in info.provides:
|
||||
result.providers.setdefault(provided, []).append(pkgname)
|
||||
result.all_provided.add(provided)
|
||||
|
||||
result.pacman_deps.update(info.native_dependencies(self._pacman))
|
||||
result.add_pkgbase_info(pkgname, info.pkgbase)
|
||||
|
||||
|
||||
@@ -269,15 +269,20 @@ class CustomPackage:
|
||||
self.git_url, self.pkgbuild_directory, f"No PKGBUILD found in '{path}'."
|
||||
)
|
||||
|
||||
# Since makepkg cannot run as root even when just printing the SRCINFO,
|
||||
# use a tmpdir and the user 'nobody'
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="decman-pkgbuild-") as tmpdir:
|
||||
tmp_path = pathlib.Path(tmpdir)
|
||||
# Allow the user 'nobody' to use this directory
|
||||
os.chmod(tmpdir, 0o777)
|
||||
shutil.copy(path / "PKGBUILD", tmp_path / "PKGBUILD")
|
||||
os.chmod(tmp_path / "PKGBUILD", 0o644)
|
||||
shutil.copytree(path, tmpdir, dirs_exist_ok=True)
|
||||
|
||||
return self._run_makepkg_printsrcinfo(tmp_path, commands)
|
||||
# Allow the user 'nobody' to use this directory
|
||||
mode = 0o777
|
||||
for root, dirs, files in os.walk(tmpdir):
|
||||
for name in dirs + files:
|
||||
os.chmod(os.path.join(root, name), mode)
|
||||
os.chmod(tmpdir, 0o777)
|
||||
|
||||
return self._run_makepkg_printsrcinfo(pathlib.Path(tmpdir), commands)
|
||||
except OSError as error:
|
||||
raise PKGBUILDParseError(
|
||||
self.git_url,
|
||||
|
||||
@@ -65,7 +65,7 @@ class Pacman(plugins.Plugin):
|
||||
for mod in modules:
|
||||
store["packages_for_module"].setdefault(mod.name, set())
|
||||
|
||||
packages = plugins.run_method_with_attribute(mod, "__pacman__packages__") or set()
|
||||
packages = set().union(*plugins.run_methods_with_attribute(mod, "__pacman__packages__"))
|
||||
|
||||
if store["packages_for_module"][mod.name] != packages:
|
||||
mod._changed = True
|
||||
@@ -208,6 +208,7 @@ class PacmanInterface:
|
||||
self._dbpath = dbpath
|
||||
self._handle = self._create_pyalpm_handle()
|
||||
self._name_index = self._create_name_index()
|
||||
self._local_provides_index = self._create_local_provides_index()
|
||||
self._provides_index = self._create_provides_index()
|
||||
self._requiredby_index = self._create_requiredby_index()
|
||||
|
||||
@@ -231,6 +232,14 @@ class PacmanInterface:
|
||||
def _create_name_index(self) -> dict[str, pyalpm.Package]:
|
||||
return {pkg.name: pkg for db in self._handle.get_syncdbs() for pkg in db.pkgcache}
|
||||
|
||||
def _create_local_provides_index(self) -> dict[str, set[str]]:
|
||||
out: dict[str, set[str]] = {}
|
||||
for pkg in self._handle.get_localdb().pkgcache:
|
||||
for p in pkg.provides:
|
||||
out.setdefault(strip_dependency(p), set()).add(pkg.name)
|
||||
out.setdefault(p, set()).add(pkg.name)
|
||||
return out
|
||||
|
||||
def _create_provides_index(self) -> dict[str, set[str]]:
|
||||
out: dict[str, set[str]] = {}
|
||||
for db in self._handle.get_syncdbs():
|
||||
@@ -249,6 +258,12 @@ class PacmanInterface:
|
||||
def _is_foreign(self, package: str) -> bool:
|
||||
return not self._is_native(package)
|
||||
|
||||
def get_all_packages(self) -> set[str]:
|
||||
"""
|
||||
Returns a set of all installed packages.
|
||||
"""
|
||||
return {pkg for pkg in self._handle.get_localdb().pkgcache}
|
||||
|
||||
def get_native_explicit(self) -> set[str]:
|
||||
"""
|
||||
Returns a set of explicitly installed native packages.
|
||||
|
||||
@@ -46,15 +46,15 @@ def test_process_modules_collects_aur_and_custom_packages_and_marks_changed(
|
||||
mod1 = FakeModule("mod1", {"aur1", "aur2"}, {cp1})
|
||||
mod2 = FakeModule("mod2", {"aur3"}, {cp2})
|
||||
|
||||
def fake_run_method_with_attribute(mod: FakeModule, attr: str):
|
||||
def fake_run_methods_with_attribute(mod: FakeModule, attr: str):
|
||||
if attr == "__aur__packages__":
|
||||
return mod._aur_pkgs
|
||||
return [mod._aur_pkgs]
|
||||
if attr == "__custom__packages__":
|
||||
return mod._custom_pkgs
|
||||
return None
|
||||
return [mod._custom_pkgs]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
aur_plugin.plugins, "run_method_with_attribute", fake_run_method_with_attribute
|
||||
aur_plugin.plugins, "run_methods_with_attribute", fake_run_methods_with_attribute
|
||||
)
|
||||
|
||||
aur.process_modules(store, {mod1, mod2})
|
||||
@@ -245,7 +245,11 @@ def test_apply_respects_ignored_packages_and_protects_their_dependencies(
|
||||
assert "ignored-aur" not in (fake_pm.remove_called_with or set())
|
||||
|
||||
# Upgrade called with flags and ignored set
|
||||
assert fake_fpm.upgrade_args == (True, True, aur.ignored_packages)
|
||||
assert fake_fpm.upgrade_args == (
|
||||
True,
|
||||
True,
|
||||
aur.ignored_packages | (fake_pm.remove_called_with or set()),
|
||||
)
|
||||
|
||||
# to_install = (packages | custom_names) - installed_foreign - ignored
|
||||
# = {"desired-aur", "custom-aur"} - {"ignored-aur", "dep-of-ignored", "orphan-foreign"}
|
||||
|
||||
@@ -40,14 +40,14 @@ def test_process_modules_collects_packages_and_marks_changed(
|
||||
mod1 = FakeModule("mod1", {"pkg1", "pkg2"})
|
||||
mod2 = FakeModule("mod2", {"pkg3"})
|
||||
|
||||
def fake_run_method_with_attribute(mod: FakeModule, attr: str) -> set[str]:
|
||||
def fake_run_methods_with_attribute(mod: FakeModule, attr: str) -> set[str]:
|
||||
assert attr == "__pacman__packages__"
|
||||
return mod._packages
|
||||
return [mod._packages]
|
||||
|
||||
monkeypatch.setattr(
|
||||
pacman_plugin.plugins,
|
||||
"run_method_with_attribute",
|
||||
fake_run_method_with_attribute,
|
||||
"run_methods_with_attribute",
|
||||
fake_run_methods_with_attribute,
|
||||
)
|
||||
|
||||
pacman.process_modules(store, {mod1, mod2})
|
||||
@@ -195,7 +195,7 @@ def test_apply_returns_false_on_command_failure(monkeypatch: pytest.MonkeyPatch)
|
||||
pass
|
||||
|
||||
def get_native_explicit(self) -> set[str]:
|
||||
raise pacman_plugin.errors.CommandFailedError(["get_native_explicit"], "boom")
|
||||
raise pacman_plugin.errors.CommandFailedError(["get_native_explicit"], 10, "boom")
|
||||
|
||||
monkeypatch.setattr(pacman_plugin, "PacmanInterface", FailingPM)
|
||||
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import typing
|
||||
from unittest.mock import MagicMock
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
import pytest
|
||||
from decman.plugins.aur.commands import AurCommands
|
||||
from decman.plugins.aur.fpm import ForeignPackageManager
|
||||
from decman.plugins.aur.package import PackageInfo, PackageSearch
|
||||
|
||||
|
||||
class FakeAurPacmanInterface:
|
||||
def __init__(self) -> None:
|
||||
self.installed_native: set[str] = set()
|
||||
self.installed_foreign: dict[str, str] = {}
|
||||
self.explicitly_installed: set[str] = set()
|
||||
self.not_installable: set[str] = set()
|
||||
self.installed_files: list[str] = [] # To track what install_files() actually does
|
||||
self.provided_pkgs: set[str] = set()
|
||||
|
||||
def get_native_explicit(self) -> set[str]:
|
||||
return self.installed_native.intersection(self.explicitly_installed)
|
||||
|
||||
def get_native_orphans(self) -> set[str]:
|
||||
return set()
|
||||
|
||||
def get_foreign_explicit(self) -> set[str]:
|
||||
return set(self.installed_foreign.keys()).intersection(self.explicitly_installed)
|
||||
|
||||
def get_dependants(self, package: str) -> set[str]:
|
||||
return set()
|
||||
|
||||
def set_as_dependencies(self, packages: set[str]):
|
||||
self.explicitly_installed.difference_update(packages)
|
||||
|
||||
def install(self, packages: set[str]):
|
||||
self.installed_native.update(packages)
|
||||
self.explicitly_installed.update(packages)
|
||||
|
||||
def upgrade(self):
|
||||
pass
|
||||
|
||||
def is_provided_by_installed(self, dependency: str) -> bool:
|
||||
return dependency in self.provided_pkgs
|
||||
|
||||
def get_all_packages(self) -> set[str]:
|
||||
return self.installed_native | self.installed_foreign.keys()
|
||||
|
||||
def filter_installed_packages(self, deps: set[str]) -> set[str]:
|
||||
out = set()
|
||||
for d in deps:
|
||||
if not self.is_provided_by_installed(d) and d not in self.get_all_packages():
|
||||
out.add(d)
|
||||
return out
|
||||
|
||||
def remove(self, packages: set[str]):
|
||||
self.installed_native.difference_update(packages)
|
||||
for p in packages:
|
||||
self.installed_foreign.pop(p, None)
|
||||
self.explicitly_installed.difference_update(packages)
|
||||
|
||||
def get_foreign_orphans(self) -> set[str]:
|
||||
return set()
|
||||
|
||||
def is_installable(self, pkg: str) -> bool:
|
||||
return pkg not in self.not_installable
|
||||
|
||||
def get_versioned_foreign_packages(self) -> list[tuple[str, str]]:
|
||||
return list(self.installed_foreign.items())
|
||||
|
||||
def install_dependencies(self, deps: set[str]):
|
||||
self.installed_native.update(deps)
|
||||
|
||||
def install_files(self, files: list[str], as_explicit: set[str]):
|
||||
self.installed_files.extend(files)
|
||||
|
||||
for file in files:
|
||||
self.installed_foreign[file] = "file"
|
||||
|
||||
for pkg in as_explicit:
|
||||
self.explicitly_installed.add(pkg)
|
||||
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[str, typing.Any] = {}
|
||||
|
||||
def __getitem__(self, key: str) -> typing.Any:
|
||||
return self._store[key]
|
||||
|
||||
def __setitem__(self, key: str, value: typing.Any) -> None:
|
||||
self._store[key] = value
|
||||
|
||||
def get(self, key: str, default: typing.Any = None) -> typing.Any:
|
||||
return self._store.get(key, default)
|
||||
|
||||
def ensure(self, key: str, default: typing.Any = None):
|
||||
if key not in self._store:
|
||||
self._store[key] = default
|
||||
|
||||
def __enter__(self) -> "FakeStore":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def save(self) -> None:
|
||||
pass
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return repr(self._store)
|
||||
|
||||
|
||||
class MockAurServer:
|
||||
def __init__(self) -> None:
|
||||
self.db: dict[str, dict] = {} # Maps pkgname -> raw JSON result dict
|
||||
|
||||
def seed(self, packages: list[PackageInfo]):
|
||||
for pkg in packages:
|
||||
# Reconstruct the raw JSON structure expected by PackageSearch
|
||||
entry = {
|
||||
"Name": pkg.pkgname,
|
||||
"PackageBase": pkg.pkgbase or pkg.pkgname,
|
||||
"Version": pkg.version,
|
||||
"Description": "Mock Description",
|
||||
"URL": "https://example.com",
|
||||
"Depends": pkg.dependencies,
|
||||
"MakeDepends": pkg.make_dependencies,
|
||||
"CheckDepends": pkg.check_dependencies,
|
||||
"Provides": pkg.provides,
|
||||
# Add other fields if your class relies on them
|
||||
}
|
||||
self.db[pkg.pkgname] = entry
|
||||
|
||||
def handle_request(self, url, *args, **kwargs):
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path
|
||||
query = parse_qs(parsed.query)
|
||||
|
||||
results = []
|
||||
|
||||
# --- Handle: Multi-info query (.../info?arg[]=pkg1&arg[]=pkg2) ---
|
||||
if "/rpc/v5/info" in path and "arg[]" in query:
|
||||
requested_names = query["arg[]"]
|
||||
for name in requested_names:
|
||||
if name in self.db:
|
||||
results.append(self.db[name])
|
||||
|
||||
# --- Handle: Single info query (.../rpc/v5/info/pkgname) ---
|
||||
elif "/rpc/v5/info/" in path:
|
||||
# Extract package name from end of path
|
||||
pkg_name = path.split("/")[-1]
|
||||
if pkg_name in self.db:
|
||||
results.append(self.db[pkg_name])
|
||||
|
||||
# --- Handle: Search providers (.../rpc/v5/search/dep?by=provides) ---
|
||||
elif "/rpc/v5/search/" in path and query.get("by") == ["provides"]:
|
||||
search_term = path.split("/")[-1]
|
||||
search_term = unquote(search_term)
|
||||
|
||||
# Linear search through DB for 'Provides'
|
||||
for entry in self.db.values():
|
||||
if search_term in entry.get("Provides", []):
|
||||
results.append(entry)
|
||||
# Also match if the package name itself matches the provider request
|
||||
elif entry["Name"] == search_term:
|
||||
results.append(entry)
|
||||
|
||||
# Construct the response object
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"version": 5,
|
||||
"type": "multiinfo",
|
||||
"resultcount": len(results),
|
||||
"results": results,
|
||||
}
|
||||
|
||||
return mock_response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_aur(mocker):
|
||||
server = MockAurServer()
|
||||
mocker.patch("requests.get", side_effect=server.handle_request)
|
||||
return server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pacman(mocker):
|
||||
pacman = FakeAurPacmanInterface()
|
||||
return pacman
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_fpm(mocker, mock_aur, mock_pacman):
|
||||
mock_builder_cls = mocker.patch("decman.plugins.aur.fpm.PackageBuilder")
|
||||
mock_builder_instance = mock_builder_cls.return_value
|
||||
mock_builder_instance.__enter__.return_value = mock_builder_instance
|
||||
mock_builder_instance.__exit__.return_value = None
|
||||
|
||||
# NOTE: find_latest_cached_package must return a tuple, otherwise
|
||||
# the 'assert built_pkg is not None' line in install() will fail.
|
||||
def mock_find_cached(store, package):
|
||||
# return just the package, so that mock pacman can get the package name from the 'file' name
|
||||
return ("1.0.0", package)
|
||||
|
||||
mocker.patch("decman.plugins.aur.fpm.find_latest_cached_package", side_effect=mock_find_cached)
|
||||
mocker.patch("decman.plugins.aur.fpm.add_package_to_cache", return_value=None)
|
||||
|
||||
# handle prompts automatically
|
||||
mocker.patch("decman.core.output.prompt_confirm", return_value=True)
|
||||
|
||||
store = FakeStore()
|
||||
search = PackageSearch()
|
||||
commands = AurCommands()
|
||||
mgr = ForeignPackageManager(
|
||||
store=store, # type: ignore
|
||||
pacman=mock_pacman,
|
||||
search=search,
|
||||
commands=commands,
|
||||
pkg_cache_dir="/tmp/cache",
|
||||
build_dir="/tmp/build",
|
||||
makepkg_user="nobody",
|
||||
)
|
||||
|
||||
return mgr
|
||||
|
||||
|
||||
def test_remove_pacman_deps_provided_by_foreign_packages(
|
||||
mock_fpm, mock_aur, mock_pacman: FakeAurPacmanInterface
|
||||
):
|
||||
mock_pacman.not_installable |= {"kwin-hifps", "qt6-base-hifps", "syncthingtray-qt6"}
|
||||
pkgs = [
|
||||
PackageInfo(
|
||||
pkgbase="kwin-hifps",
|
||||
pkgname="kwin-hifps",
|
||||
version="1",
|
||||
git_url="...",
|
||||
dependencies=("qt6-base-hifps",),
|
||||
),
|
||||
PackageInfo(
|
||||
pkgbase="qt6-base-hifps",
|
||||
pkgname="qt6-base-hifps",
|
||||
version="1",
|
||||
git_url="...",
|
||||
provides=("qt6-base",),
|
||||
),
|
||||
PackageInfo(
|
||||
pkgbase="syncthingtray-qt6",
|
||||
pkgname="syncthingtray-qt6",
|
||||
version="1",
|
||||
git_url="...",
|
||||
dependencies=("qt6-base",),
|
||||
),
|
||||
]
|
||||
mock_aur.seed(pkgs)
|
||||
|
||||
mock_fpm.install(["kwin-hifps", "syncthingtray-qt6"])
|
||||
|
||||
assert len(mock_pacman.installed_files) == 3
|
||||
assert mock_pacman.explicitly_installed == {"kwin-hifps", "syncthingtray-qt6"}
|
||||
assert "qt6-base" not in mock_pacman.installed_native
|
||||
|
||||
|
||||
def test_remove_pacman_deps_provided_by_already_installed_foreign_packages(
|
||||
mock_fpm, mock_aur, mock_pacman: FakeAurPacmanInterface
|
||||
):
|
||||
mock_pacman.not_installable |= {"kwin-hifps", "qt6-base-hifps", "syncthingtray-qt6"}
|
||||
pkgs = [
|
||||
PackageInfo(
|
||||
pkgbase="kwin-hifps",
|
||||
pkgname="kwin-hifps",
|
||||
version="1",
|
||||
git_url="...",
|
||||
dependencies=("qt6-base-hifps",),
|
||||
),
|
||||
PackageInfo(
|
||||
pkgbase="qt6-base-hifps",
|
||||
pkgname="qt6-base-hifps",
|
||||
version="1",
|
||||
git_url="...",
|
||||
provides=("qt6-base",),
|
||||
),
|
||||
PackageInfo(
|
||||
pkgbase="syncthingtray-qt6",
|
||||
pkgname="syncthingtray-qt6",
|
||||
version="1",
|
||||
git_url="...",
|
||||
dependencies=("qt6-base",),
|
||||
),
|
||||
]
|
||||
mock_pacman.installed_foreign = {
|
||||
"kwin-hifps": "1",
|
||||
"qt6-base-hifps": "1",
|
||||
}
|
||||
mock_pacman.explicitly_installed.add("kwin-hifps")
|
||||
mock_pacman.provided_pkgs.add("qt6-base")
|
||||
mock_aur.seed(pkgs)
|
||||
|
||||
mock_fpm.install(["syncthingtray-qt6"])
|
||||
|
||||
assert len(mock_pacman.installed_files) == 1
|
||||
assert mock_pacman.explicitly_installed == {"kwin-hifps", "syncthingtray-qt6"}
|
||||
assert "qt6-base" not in mock_pacman.installed_native
|
||||
|
||||
|
||||
def test_install_simple_package(
|
||||
mock_fpm, mock_pacman: FakeAurPacmanInterface, mock_aur: MockAurServer
|
||||
):
|
||||
mock_pacman.not_installable.add("foo")
|
||||
pkg = PackageInfo(
|
||||
pkgbase="foo",
|
||||
pkgname="foo",
|
||||
version="100.0.0",
|
||||
git_url="...",
|
||||
)
|
||||
mock_aur.seed([pkg])
|
||||
|
||||
mock_fpm.install(["foo"])
|
||||
|
||||
assert len(mock_pacman.installed_files) == 1
|
||||
assert "foo" in mock_pacman.installed_files[0]
|
||||
assert "foo" in mock_pacman.explicitly_installed
|
||||
assert "foo" in mock_pacman.installed_foreign
|
||||
|
||||
|
||||
def test_upgrade_foreign_package(mock_fpm, mock_pacman, mock_aur):
|
||||
mock_pacman.not_installable.add("my-app")
|
||||
mock_pacman.installed_foreign = {"my-app": "1.0"}
|
||||
mock_pacman.explicitly_installed = {"my-app"}
|
||||
|
||||
pkg = PackageInfo(
|
||||
pkgbase="my-app",
|
||||
pkgname="my-app",
|
||||
version="2.0",
|
||||
git_url="...",
|
||||
)
|
||||
mock_aur.seed([pkg])
|
||||
|
||||
mock_fpm.upgrade()
|
||||
|
||||
assert len(mock_pacman.installed_files) == 1
|
||||
assert "my-app" in mock_pacman.installed_foreign
|
||||
assert "my-app" in mock_pacman.installed_files[0]
|
||||
|
||||
|
||||
def test_upgrade_skips_current_package(mock_fpm, mock_pacman, mock_aur):
|
||||
mock_pacman.not_installable.add("stable-app")
|
||||
mock_pacman.installed_foreign = {"stable-app": "5.0"}
|
||||
mock_pacman.explicitly_installed = {"stable-app"}
|
||||
|
||||
pkg = PackageInfo(
|
||||
pkgbase="stable-app",
|
||||
pkgname="stable-app",
|
||||
version="5.0",
|
||||
git_url="...",
|
||||
)
|
||||
mock_aur.seed([pkg])
|
||||
|
||||
mock_fpm.upgrade()
|
||||
|
||||
assert len(mock_pacman.installed_files) == 0
|
||||
|
||||
|
||||
def test_install_resolves_dependencies(mock_fpm, mock_pacman, mock_aur):
|
||||
mock_pacman.not_installable |= {"lib-helper", "main-app"}
|
||||
pkg_dep = PackageInfo(pkgbase="lib-helper", pkgname="lib-helper", version="1.5", git_url="...")
|
||||
pkg_main = PackageInfo(
|
||||
pkgbase="main-app",
|
||||
pkgname="main-app",
|
||||
version="2.0",
|
||||
dependencies=("lib-helper",),
|
||||
git_url="...",
|
||||
)
|
||||
|
||||
mock_aur.seed([pkg_dep, pkg_main])
|
||||
|
||||
mock_fpm.install(["main-app"])
|
||||
|
||||
assert len(mock_pacman.installed_files) == 2
|
||||
assert "main-app" in mock_pacman.explicitly_installed
|
||||
assert "main-app" in mock_pacman.installed_files
|
||||
assert "lib-helper" in mock_pacman.installed_files
|
||||
@@ -1,8 +1,8 @@
|
||||
[project]
|
||||
name = "decman-systemd"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = ["decman==1.0.0"]
|
||||
dependencies = ["decman==1.1.0"]
|
||||
|
||||
[project.entry-points."decman.plugins"]
|
||||
systemd = "decman.plugins.systemd:Systemd"
|
||||
|
||||
@@ -93,8 +93,12 @@ class Systemd(plugins.Plugin):
|
||||
store["systemd_units_for_module"].setdefault(mod.name, set())
|
||||
store["systemd_user_units_for_module"].setdefault(mod.name, {})
|
||||
|
||||
units = plugins.run_method_with_attribute(mod, "__systemd__units__") or set()
|
||||
user_units = plugins.run_method_with_attribute(mod, "__systemd__user__units__") or {}
|
||||
units = set().union(*plugins.run_methods_with_attribute(mod, "__systemd__units__"))
|
||||
user_units = {
|
||||
k: v
|
||||
for d in plugins.run_methods_with_attribute(mod, "__systemd__user__units__")
|
||||
for k, v in d.items()
|
||||
}
|
||||
|
||||
if store["systemd_units_for_module"][mod.name] != units:
|
||||
mod._changed = True
|
||||
|
||||
@@ -65,13 +65,13 @@ def test_process_modules_marks_changed_and_updates_store(monkeypatch, store, sys
|
||||
|
||||
def fake_run_method(mod, attr):
|
||||
if mod is m1 and attr == "__systemd__units__":
|
||||
return {"a.service"}
|
||||
return [{"a.service"}]
|
||||
if mod is m1 and attr == "__systemd__user__units__":
|
||||
return {"alice": {"u1.service"}}
|
||||
return [{"alice": {"u1.service"}}]
|
||||
# m2 has no units
|
||||
return None
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(systemd_mod.plugins, "run_method_with_attribute", fake_run_method)
|
||||
monkeypatch.setattr(systemd_mod.plugins, "run_methods_with_attribute", fake_run_method)
|
||||
|
||||
systemd.process_modules(store, {m1, m2})
|
||||
|
||||
@@ -96,12 +96,12 @@ def test_process_modules_no_change_second_run(monkeypatch, store, systemd):
|
||||
|
||||
def fake_run_method(mod, attr):
|
||||
if attr == "__systemd__units__":
|
||||
return {"a.service"}
|
||||
return [{"a.service"}]
|
||||
if attr == "__systemd__user__units__":
|
||||
return {"alice": {"u1.service"}}
|
||||
return None
|
||||
return [{"alice": {"u1.service"}}]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(systemd_mod.plugins, "run_method_with_attribute", fake_run_method)
|
||||
monkeypatch.setattr(systemd_mod.plugins, "run_methods_with_attribute", fake_run_method)
|
||||
|
||||
# first run populates store
|
||||
systemd.process_modules(store, {m1})
|
||||
@@ -109,7 +109,7 @@ def test_process_modules_no_change_second_run(monkeypatch, store, systemd):
|
||||
|
||||
# new instance (fresh per-process in real usage)
|
||||
systemd2 = systemd_mod.Systemd()
|
||||
monkeypatch.setattr(systemd_mod.plugins, "run_method_with_attribute", fake_run_method)
|
||||
monkeypatch.setattr(systemd_mod.plugins, "run_methods_with_attribute", fake_run_method)
|
||||
|
||||
systemd2.process_modules(store, {m1})
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "decman"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
description = "Declarative package & configuration manager for Arch Linux."
|
||||
license = "GPL-3.0-or-later"
|
||||
license-files = ["LICENSE"]
|
||||
|
||||
@@ -218,9 +218,9 @@ def check_run_result(
|
||||
code, output = result
|
||||
if code != 0:
|
||||
if include_output:
|
||||
raise errors.CommandFailedError(command, output)
|
||||
raise errors.CommandFailedError(command, code, output)
|
||||
else:
|
||||
raise errors.CommandFailedError(command, None)
|
||||
raise errors.CommandFailedError(command, code, None)
|
||||
return code, output
|
||||
|
||||
|
||||
|
||||
@@ -74,13 +74,17 @@ class CommandFailedError(Exception):
|
||||
|
||||
Attributes:
|
||||
command (list[str]): The command that caused the exception.
|
||||
exit_code (int): The exit code of the command
|
||||
output (str|None): Output of the command.
|
||||
"""
|
||||
|
||||
def __init__(self, command: list[str], output: str | None) -> None:
|
||||
def __init__(self, command: list[str], exit_code: int, output: str | None) -> None:
|
||||
self.command = shlex.join(command)
|
||||
self.exit_code = exit_code
|
||||
if output:
|
||||
self.output: str | None = output.strip()
|
||||
else:
|
||||
self.output = None
|
||||
super().__init__(f"Command '{self.command}' returned with a non-zero exit code.")
|
||||
super().__init__(
|
||||
f"Command '{self.command}' returned with a non-zero exit code {self.exit_code}."
|
||||
)
|
||||
|
||||
@@ -100,6 +100,7 @@ class _GPGInterface:
|
||||
"--no-tty",
|
||||
"--import-ownertrust",
|
||||
]
|
||||
# use subprocess manually since decman exposed functions don't allow setting input
|
||||
p = subprocess.run(
|
||||
cmd,
|
||||
input=data,
|
||||
@@ -110,7 +111,7 @@ class _GPGInterface:
|
||||
check=False,
|
||||
)
|
||||
if p.returncode != 0:
|
||||
raise CommandFailedError(cmd, p.stdout)
|
||||
raise CommandFailedError(cmd, p.returncode, p.stdout)
|
||||
|
||||
def delete_keys(self, fingerprints: list[str]):
|
||||
decman.prg(
|
||||
|
||||
@@ -48,8 +48,8 @@ class Plugin:
|
||||
|
||||
def run_method_with_attribute(mod: module.Module, attribute: str) -> typing.Any:
|
||||
"""
|
||||
Runs the method with the given attribute in the module and returns its returned value.
|
||||
Returns none if no such method is found.
|
||||
Runs the first method with the given attribute in the module and returns its returned value.
|
||||
Returns ``None`` if no such method is found.
|
||||
|
||||
Only the first found method with the attribute is ran.
|
||||
"""
|
||||
@@ -64,6 +64,23 @@ def run_method_with_attribute(mod: module.Module, attribute: str) -> typing.Any:
|
||||
return None
|
||||
|
||||
|
||||
def run_methods_with_attribute(mod: module.Module, attribute: str) -> list[typing.Any]:
|
||||
"""
|
||||
Runs all methods with the given attribute in the module and returns their returned values.
|
||||
Returns an empty list if no such methods are found.
|
||||
"""
|
||||
values = []
|
||||
for name in dir(mod):
|
||||
attr = getattr(mod, name)
|
||||
if not callable(attr):
|
||||
continue
|
||||
func = getattr(attr, "__func__", attr)
|
||||
if getattr(func, attribute, False):
|
||||
values.append(attr())
|
||||
|
||||
return values
|
||||
|
||||
|
||||
def available_plugins() -> dict[str, Plugin]:
|
||||
"""
|
||||
Returns all available plugins.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from decman.core.module import Module
|
||||
from decman.plugins import run_method_with_attribute
|
||||
from decman.plugins import run_methods_with_attribute
|
||||
|
||||
|
||||
def mark(attr):
|
||||
@@ -14,7 +14,21 @@ def test_runs_marked_method_and_returns_value():
|
||||
return 123
|
||||
|
||||
m = M("m")
|
||||
assert run_method_with_attribute(m, "__flag__") == 123
|
||||
assert run_methods_with_attribute(m, "__flag__") == [123]
|
||||
|
||||
|
||||
def test_runs_marked_methods_and_returns_value():
|
||||
class M(Module):
|
||||
@mark
|
||||
def foo(self):
|
||||
return 123
|
||||
|
||||
@mark
|
||||
def bar(self):
|
||||
return 321
|
||||
|
||||
m = M("m")
|
||||
assert run_methods_with_attribute(m, "__flag__") == [321, 123]
|
||||
|
||||
|
||||
def test_returns_none_if_no_method_has_attribute():
|
||||
@@ -23,4 +37,4 @@ def test_returns_none_if_no_method_has_attribute():
|
||||
return 1
|
||||
|
||||
m = M("m")
|
||||
assert run_method_with_attribute(m, "__flag__") is None
|
||||
assert run_methods_with_attribute(m, "__flag__") == []
|
||||
|
||||
@@ -71,7 +71,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "decman"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
source = { editable = "." }
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -107,7 +107,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "decman-flatpak"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
source = { editable = "plugins/decman-flatpak" }
|
||||
dependencies = [
|
||||
{ name = "decman" },
|
||||
@@ -118,7 +118,7 @@ requires-dist = [{ name = "decman", editable = "." }]
|
||||
|
||||
[[package]]
|
||||
name = "decman-pacman"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
source = { editable = "plugins/decman-pacman" }
|
||||
dependencies = [
|
||||
{ name = "decman" },
|
||||
@@ -126,6 +126,12 @@ dependencies = [
|
||||
{ name = "requests" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-mock" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "decman", editable = "." },
|
||||
@@ -133,9 +139,15 @@ requires-dist = [
|
||||
{ name = "requests" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=8.4.2" },
|
||||
{ name = "pytest-mock", specifier = ">=3.15.1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "decman-systemd"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
source = { editable = "plugins/decman-systemd" }
|
||||
dependencies = [
|
||||
{ name = "decman" },
|
||||
@@ -211,6 +223,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-mock"
|
||||
version = "3.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
|
||||
Reference in New Issue
Block a user