mirror of
https://github.com/kiviktnm/decman.git
synced 2026-09-19 20:18:28 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc668600ba | ||
|
|
d4dc676878 | ||
|
|
cc33ce97ae | ||
|
|
bb6d65428a | ||
|
|
a94b0e2db8 | ||
|
|
8c54220dbc | ||
|
|
e8d292be13 | ||
|
|
6c054681ee | ||
|
|
7bbd8a2cd8 | ||
|
|
55ad44b43c | ||
|
|
a4ab83493f | ||
|
|
71a7e867ad | ||
|
|
eafe7e7af9 | ||
|
|
5ac95facc3 | ||
|
|
37745d1730 | ||
|
|
7aa063f6b9 | ||
|
|
4203d2d439 | ||
|
|
c5898d82a3 | ||
|
|
cdbf1e8348 | ||
|
|
e4eb81bd5b | ||
|
|
97e94258a4 | ||
|
|
1214c69fba |
@@ -160,6 +160,8 @@ Decman has built-in functionality for managing files and directories. Additional
|
||||
- [aur](/docs/aur.md)
|
||||
- [flatpak](/docs/flatpak.md)
|
||||
|
||||
Additionally management of [users, groups and PGP keys](/docs/extras.md) is provided by built-in modules.
|
||||
|
||||
Plugins can be disabled if desired and flatpaks are disabled by default.
|
||||
|
||||
Please read the documentation to understand the functionality of those plugins in detail. Here are quick examples to show what the default plugins are capable of.
|
||||
@@ -408,7 +410,7 @@ import decman
|
||||
|
||||
if socket.gethostname() == "laptop":
|
||||
# add brightness controls to your laptop
|
||||
decman.pacman.packages += ["brightnessctl"]
|
||||
decman.pacman.packages |= {"brightnessctl"}
|
||||
```
|
||||
|
||||
## Alternatives
|
||||
|
||||
+8
-4
@@ -127,7 +127,7 @@ The architecture of the computer's CPU. Currently, this is only used by the AUR
|
||||
decman.config.arch = "x86_64"
|
||||
```
|
||||
|
||||
## Files and directories
|
||||
## Files, directories and symlink
|
||||
|
||||
Decman functions as a dotfile manager. It will install the defined files, directories and symlinks to their destinations. You can set file permissions, owners as well as define variables that will be substituted in the installed files. Decman keeps track of all files it creates and when a file is no longer present in your source, it will be also removed from its destination. This helps with keeping your system clean. However, decman won't remove directories as they might contain files that weren't created by decman.
|
||||
|
||||
@@ -183,7 +183,7 @@ from decman import Directory
|
||||
import decman
|
||||
|
||||
# To declare a directory, add it's target path and create a Directory object
|
||||
decman.directories["/home/me/.config/nvim"] = File(
|
||||
decman.directories["/home/me/.config/nvim"] = Directory(
|
||||
source_directory="./dotfiles/nvim",
|
||||
bin_files=False,
|
||||
encoding="utf-8",
|
||||
@@ -208,7 +208,7 @@ Ownership, permissions, and parent directories are enforced on creation. Missing
|
||||
|
||||
### Symlink
|
||||
|
||||
Declare a link to a target. Missing directories are created.
|
||||
Declare a link to a target. Missing directories are created. If you need to configure parent folder permissions, use `Symlink` objects.
|
||||
|
||||
```py
|
||||
import decman
|
||||
@@ -216,6 +216,9 @@ import decman
|
||||
# Replaces sudo with doas
|
||||
# /usr/bin/sudo -> /usr/bin/doas
|
||||
decman.symlinks["/usr/bin/sudo"] = "/usr/bin/doas"
|
||||
|
||||
# I don't know why would you ever do this but as an example
|
||||
decman.symlinks["/home/me/.bin/mydoas"] = decman.Symlink("/usr/bin/doas", owner="me", group="users")
|
||||
```
|
||||
|
||||
## Modules
|
||||
@@ -351,9 +354,10 @@ def file_variables(self) -> dict[str, str]:
|
||||
Defines symlinks fro the module.
|
||||
|
||||
```py
|
||||
def symlinks(self) -> dict[str, str]:
|
||||
def symlinks(self) -> dict[str, str | Symlink]:
|
||||
return {
|
||||
"/etc/resolv.conf": "/run/systemd/resolve/resolv.conf",
|
||||
"/home/me/.config/app/file.conf": Symlink("/home/me/.file.conf", owner="me"),
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[project]
|
||||
name = "decman-flatpak"
|
||||
version = "1.0.0"
|
||||
version = "1.2.2"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = ["decman==1.0.0"]
|
||||
dependencies = ["decman==1.2.2"]
|
||||
|
||||
[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.2.2"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"decman==1.0.0",
|
||||
"decman==1.2.2",
|
||||
"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)
|
||||
|
||||
@@ -443,6 +454,12 @@ class ForeignPackageManager:
|
||||
raise ForeignPackageManagerError("Failed to compare versions using vercmp.") from error
|
||||
|
||||
|
||||
def _last_non_empty_line(text: str) -> str:
|
||||
return next(
|
||||
(line.strip() for line in reversed(text.splitlines()) if line.strip()), ""
|
||||
)
|
||||
|
||||
|
||||
class PackageBuilder:
|
||||
"""
|
||||
Used for building packages in a chroot.
|
||||
@@ -631,7 +648,7 @@ class PackageBuilder:
|
||||
if p not in self._pkgs_in_chroot:
|
||||
cmd = self._commands.resolve_real_name_chroot(self.chroot_dir, p)
|
||||
_, cmd_output = command.check_run_result(cmd, command.run(cmd))
|
||||
real_pkgname = cmd_output.strip()
|
||||
real_pkgname = _last_non_empty_line(cmd_output)
|
||||
to_remove.add(real_pkgname)
|
||||
|
||||
if to_remove:
|
||||
|
||||
@@ -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.2.2"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = ["decman==1.0.0"]
|
||||
dependencies = ["decman==1.2.2"]
|
||||
|
||||
[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
|
||||
@@ -244,7 +248,7 @@ class Systemd(plugins.Plugin):
|
||||
"""
|
||||
|
||||
cmd = self.commands.user_daemon_reload(user)
|
||||
command.prg(cmd, pty=config.debug_output)
|
||||
command.prg(cmd, pty=config.debug_output, check=False)
|
||||
|
||||
def reload_daemon(self):
|
||||
"""
|
||||
|
||||
@@ -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.2.2"
|
||||
description = "Declarative package & configuration manager for Arch Linux."
|
||||
license = "GPL-3.0-or-later"
|
||||
license-files = ["LICENSE"]
|
||||
|
||||
@@ -3,7 +3,7 @@ import typing
|
||||
# Re-exports
|
||||
from decman.core.command import prg
|
||||
from decman.core.error import SourceError
|
||||
from decman.core.fs import Directory, File
|
||||
from decman.core.fs import Directory, File, Symlink
|
||||
from decman.core.module import Module
|
||||
from decman.core.store import Store
|
||||
from decman.plugins import Plugin, available_plugins
|
||||
@@ -51,6 +51,7 @@ __all__ = [
|
||||
"SourceError",
|
||||
"File",
|
||||
"Directory",
|
||||
"Symlink",
|
||||
"Module",
|
||||
"Store",
|
||||
"Plugin",
|
||||
@@ -63,7 +64,7 @@ __all__ = [
|
||||
# -----------------------------------------
|
||||
files: dict[str, File] = {}
|
||||
directories: dict[str, Directory] = {}
|
||||
symlinks: dict[str, str] = {}
|
||||
symlinks: dict[str, str | Symlink] = {}
|
||||
modules: list[Module] = []
|
||||
execution_order: list[str] = [
|
||||
"files",
|
||||
|
||||
@@ -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}."
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ def update_files(
|
||||
modules: list[module.Module],
|
||||
files: dict[str, fs.File],
|
||||
directories: dict[str, fs.Directory],
|
||||
symlinks: dict[str, str],
|
||||
symlinks: dict[str, str | fs.Symlink],
|
||||
dry_run: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
@@ -204,39 +204,18 @@ def _install_directories(
|
||||
return checked_files, changed_files
|
||||
|
||||
|
||||
def _is_symlink_to(path: str, target: str) -> bool:
|
||||
if not os.path.islink(path):
|
||||
return False
|
||||
return os.readlink(path) == target
|
||||
|
||||
|
||||
def _install_symlinks(
|
||||
symlinks: dict[str, str], dry_run: bool = False
|
||||
symlinks: dict[str, str | fs.Symlink], dry_run: bool = False
|
||||
) -> tuple[list[str], list[str]]:
|
||||
checked_files = []
|
||||
changed_files = []
|
||||
|
||||
for link_name, target in symlinks.items():
|
||||
output.print_debug(f"Checking symlink {link_name}.")
|
||||
try:
|
||||
checked_files.append(link_name)
|
||||
|
||||
if _is_symlink_to(link_name, target):
|
||||
continue
|
||||
checked_files.append(link_name)
|
||||
|
||||
target_link: fs.Symlink = target if type(target) is fs.Symlink else fs.Symlink(target) # type: ignore
|
||||
if target_link.link_to(link_name, dry_run):
|
||||
changed_files.append(link_name)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
if os.path.lexists(link_name):
|
||||
os.unlink(link_name)
|
||||
|
||||
os.makedirs(os.path.dirname(link_name), exist_ok=True)
|
||||
os.symlink(target, link_name)
|
||||
except OSError as error:
|
||||
raise errors.FSSymlinkFailedError(
|
||||
link_name, target, error.strerror or str(error)
|
||||
) from error
|
||||
|
||||
return checked_files, changed_files
|
||||
|
||||
+106
-15
@@ -8,6 +8,20 @@ import decman.core.error as errors
|
||||
import decman.core.output as output
|
||||
|
||||
|
||||
def create_missing_dirs(dirct: str, uid: typing.Optional[int], gid: typing.Optional[int]):
|
||||
if not os.path.isdir(dirct):
|
||||
parent_dir = os.path.dirname(dirct)
|
||||
if not os.path.isdir(parent_dir):
|
||||
create_missing_dirs(parent_dir, uid, gid)
|
||||
|
||||
output.print_debug(f"Creating directory '{dirct}'.")
|
||||
os.mkdir(dirct)
|
||||
|
||||
if uid is not None:
|
||||
assert gid is not None, "If uid is set, then gid is set."
|
||||
os.chown(dirct, uid, gid)
|
||||
|
||||
|
||||
class File:
|
||||
"""
|
||||
Declarative file specification describing how a file should be materialized at a target path.
|
||||
@@ -129,21 +143,6 @@ class File:
|
||||
|
||||
target_directory = os.path.dirname(target)
|
||||
|
||||
def create_missing_dirs(dirct: str, uid: typing.Optional[int], gid: typing.Optional[int]):
|
||||
if not os.path.isdir(dirct):
|
||||
parent_dir = os.path.dirname(dirct)
|
||||
if not os.path.isdir(parent_dir):
|
||||
create_missing_dirs(parent_dir, uid, gid)
|
||||
|
||||
output.print_debug(
|
||||
f"While installing file '{target}' creating directory '{dirct}'."
|
||||
)
|
||||
os.mkdir(dirct)
|
||||
|
||||
if uid is not None:
|
||||
assert gid is not None, "If uid is set, then gid is set."
|
||||
os.chown(dirct, uid, gid)
|
||||
|
||||
if not dry_run:
|
||||
create_missing_dirs(target_directory, self.uid, self.gid)
|
||||
|
||||
@@ -219,6 +218,98 @@ class File:
|
||||
return True
|
||||
|
||||
|
||||
class Symlink:
|
||||
"""
|
||||
Declarative specification for linking a source to a destination.
|
||||
|
||||
Parameters:
|
||||
``target``:
|
||||
Path to an existing file to serve as the target of the symlink.
|
||||
|
||||
``owner``:
|
||||
User name to own created parent directories.
|
||||
|
||||
``group``:
|
||||
Group name to own created parent directories.
|
||||
|
||||
Raises:
|
||||
``UserNotFoundError``
|
||||
If ``owner`` does not exist on the system.
|
||||
|
||||
``GroupNotFoundError``
|
||||
If ``group`` does not exist on the system.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target: str,
|
||||
owner: typing.Optional[str] = None,
|
||||
group: typing.Optional[str] = None,
|
||||
):
|
||||
self.target = target
|
||||
self.owner = owner
|
||||
self.group = group
|
||||
self.uid = None
|
||||
self.gid = None
|
||||
|
||||
if owner is not None:
|
||||
self.uid, self.gid = command.get_user_info(owner)
|
||||
|
||||
if group is not None:
|
||||
try:
|
||||
self.gid = grp.getgrnam(group).gr_gid
|
||||
except KeyError as error:
|
||||
raise errors.GroupNotFoundError(group) from error
|
||||
|
||||
def link_to(self, link_name: str, dry_run: bool = False) -> bool:
|
||||
"""
|
||||
Creates a symlink ``link_name`` -> ``target``.
|
||||
|
||||
Parameters:
|
||||
``link_name``:
|
||||
Path to the target file on disk.
|
||||
|
||||
Returns:
|
||||
True if a new link was/would be created or modified.
|
||||
False if the existing link already contained the desired target.
|
||||
|
||||
Raises:
|
||||
FSSymlinkFailedError
|
||||
If creating the symlink failed due to directory creation, file I/O, permission
|
||||
changes, or ownership changes fail (e.g. permission denied, missing parent path
|
||||
components, I/O errors).
|
||||
"""
|
||||
|
||||
def _is_symlink_to(path: str, target: str) -> bool:
|
||||
if not os.path.islink(path):
|
||||
return False
|
||||
return os.readlink(path) == target
|
||||
|
||||
output.print_debug(f"Checking symlink {link_name}.")
|
||||
try:
|
||||
if _is_symlink_to(link_name, self.target):
|
||||
return False
|
||||
|
||||
if dry_run:
|
||||
return True
|
||||
|
||||
target_directory = os.path.dirname(link_name)
|
||||
create_missing_dirs(target_directory, self.uid, self.gid)
|
||||
|
||||
if os.path.lexists(link_name):
|
||||
os.unlink(link_name)
|
||||
|
||||
os.symlink(self.target, link_name)
|
||||
if self.uid is not None:
|
||||
assert self.gid is not None, "If uid is set, then gid is set."
|
||||
os.chown(link_name, self.uid, self.gid, follow_symlinks=False)
|
||||
return True
|
||||
except OSError as error:
|
||||
raise errors.FSSymlinkFailedError(
|
||||
link_name, self.target, error.strerror or str(error)
|
||||
) from error
|
||||
|
||||
|
||||
class Directory:
|
||||
"""
|
||||
Declarative specification for copying the contents of a source directory into a target
|
||||
|
||||
@@ -105,7 +105,7 @@ class Module:
|
||||
"""
|
||||
return {}
|
||||
|
||||
def symlinks(self) -> dict[str, str]:
|
||||
def symlinks(self) -> dict[str, str | fs.Symlink]:
|
||||
"""
|
||||
Override this method to return symlinks that should be created as a part of this
|
||||
module.
|
||||
|
||||
@@ -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.2.2"
|
||||
source = { editable = "." }
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -107,7 +107,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "decman-flatpak"
|
||||
version = "1.0.0"
|
||||
version = "1.2.2"
|
||||
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.2.2"
|
||||
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.2.2"
|
||||
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