38 Commits
Author SHA1 Message Date
Kivi Kaitaniemi a566adf546 Release v0.4.1 2025-12-12 14:37:18 +02:00
Kivi Kaitaniemi 616efa9629 Remove accidentally commited wheel 2025-12-12 14:34:30 +02:00
Kivi Kaitaniemi 409146cf14 Release v0.4.0 2025-12-10 00:34:57 +02:00
Kivi Kaitaniemi 70306ce2ac Merge branch 'gro-david-main' 2025-12-10 00:30:09 +02:00
Kivi Kaitaniemi 2378f49676 Run flatpak commands on users with UID>=1000 2025-12-10 00:29:15 +02:00
Kivi Kaitaniemi a54fad6570 Merge branch 'main' of https://github.com/gro-david/decman into gro-david-main 2025-12-09 11:54:45 +02:00
Dávid Groniewsky 6ae8c78959 Switched the variable type of user defined flatpak packages to match the type of the user systemd services 2025-10-13 09:36:46 +02:00
David Groniewsky 69bcaadf90 Forgot to set the HOME variable so set that as suggested in the change request 2025-10-09 22:47:35 +02:00
David Groniewsky 7ea1ef2d81 Fixed the management of flatpak packages installed to the user installation as requested in the PR 2025-10-09 22:42:19 +02:00
David Groniewsky d765eea3f4 Simple fixes 2025-10-08 19:13:08 +02:00
Kivi Kaitaniemi 37e85a0814 Fix test that occasionally fails due to set ordering being indeterminate 2025-10-02 00:38:13 +03:00
Dávid Groniewsky 7320c88940 Updated the README and the examples to include user wide installation of flatpak packages. 2025-10-01 11:19:04 +02:00
Dávid Groniewsky 613beaa917 Added the option for installing flatpaks to the user installation. 2025-10-01 11:15:26 +02:00
Dávid Groniewsky 515a27cb7d Added the check for missing flatpak installation while the flatpaks are enabled. An error will be printed and decman will exit. 2025-09-30 22:45:54 +02:00
Dávid Groniewsky dbeed0b7d8 Updated the README with the announcement that flatpaks can now be managed. Added the required documentation to the example. 2025-09-30 22:40:19 +02:00
Dávid Groniewsky 9ce42ccf3e Added the config option to disable flatpaks (disabled by default), and the flag --no-flatpaks which will skip flatpak packages. 2025-09-30 22:30:24 +02:00
Dávid Groniewsky 0ecd835090 Extended the flatpak functionality to modules. All works as expected. 2025-09-29 15:19:42 +02:00
Dávid Groniewsky 27a8c279c4 Removed the build and dist directories 2025-09-29 14:58:21 +02:00
Dávid Groniewsky 401233a352 Added support for declaring installed and ignored flatpaks in the source file. For this the variables 'flatpak_packages' and 'ignored_flatpak_packages' are used. These work like the pacman equivalents. There were test cases appended for flatpak testing. I have installed the modified version on my system and tested it extensively. No errors were found. 2025-09-29 14:57:34 +02:00
Kivi Kaitaniemi f3910a6bc9 Release v0.3.4 2025-05-01 17:13:02 +03:00
Kivi Kaitaniemi bce9be5ebc Use pty for capturing and echoing cmd output 2025-05-01 17:10:16 +03:00
Kivi Kaitaniemi ec19b21244 Mention package groups in README.md 2025-05-01 16:49:06 +03:00
Kivi Kaitaniemi 47ea816d3f Update README.md & examples 2025-03-08 19:07:04 +02:00
Kivi Kaitaniemi 23620e86f5 Release v0.3.3 2025-03-08 17:30:45 +02:00
Kivi Kaitaniemi ef8423d128 Update README.md 2025-03-08 17:27:47 +02:00
Kivi Kaitaniemi db46ac339c Merge pull request #24 from kiviktnm/cache-aur-providers
Cache AUR package providers #23
2025-03-08 17:20:14 +02:00
Kivi Kaitaniemi 8e3646a5f0 Cache AUR package providers #23 2025-03-08 17:19:39 +02:00
Kivi Kaitaniemi cad1c2682c Merge pull request #22 from kiviktnm/fix-remove-chroot-virtual-pkgs
Fix removing virtual packages from chroots #7
2025-03-08 15:11:32 +02:00
Kivi Kaitaniemi 5ede2a355a Fix removing virtual packages from chroots #7 2025-03-08 15:09:39 +02:00
Kivi Kaitaniemi c6f180fb94 Merge pull request #15 from oatmealraisin/formatters
Add ruff and isort
2025-01-25 12:42:14 +02:00
Kivi Kaitaniemi 82a1fbdcb9 Merge pull request #16 from oatmealraisin/user_services
Fix bug in module user systemd unit handling
2025-01-25 12:21:03 +02:00
user e153b8c400 Add test for multiple module user services 2025-01-23 18:37:38 -05:00
user bde9d9c70d Fix bug in module user systemd unit handling 2025-01-23 18:10:36 -05:00
user f7567890da Add ruff and isort 2025-01-23 15:29:12 -05:00
Kivi Kaitaniemi 53b227859c Remind the user that systemd services won't be started/stopped 2025-01-19 11:23:06 +02:00
Kivi Kaitaniemi c091d18ee1 Prevent BlockingOIError from being raised when echonig large amounts of output 2024-07-28 16:01:46 +03:00
Kivi Kaitaniemi 7dacad6be2 Release v0.3.1 2024-07-28 11:19:28 +03:00
Kivi Kaitaniemi 5fe7df23db Show full PKGBUILD if a commit id is not found
If a git commit id is not found in a PKGBUILD repository, show the full
PKGBUILD instead of trying to show a git diff.

Fixes #5
2024-07-28 11:10:46 +03:00
11 changed files with 1162 additions and 477 deletions
+1
View File
@@ -4,3 +4,4 @@ build/
*.egg-info/ *.egg-info/
venv/ venv/
dist/
+35 -8
View File
@@ -1,5 +1,18 @@
# Decman # Decman
> 🎉 Early support for Flatpaks was just added! 🎉
> By default flatpak management is disabled. Support is in early stages so expect bugs.
> ```py
> import decman
> import decman.config
> decman.config.enable_flatpak = True
> # You can add system wide packages as well as user packages
> decman.flatpak_packages += ["org.signal.Signal"]
> decman.flatpak_user_packages["user"] = decman.flatpak_user_packages.get("user", [])
> decman.flatpak_user_packages["user"].append("dev.zed.Zed")
> ```
Decman is a declarative package & configuration manager for Arch Linux. It allows you to manage installed packages, your dotfiles, enabled systemd units, and run commands automatically. Your system is configured using python so your configuration can be very adaptive. Decman is a declarative package & configuration manager for Arch Linux. It allows you to manage installed packages, your dotfiles, enabled systemd units, and run commands automatically. Your system is configured using python so your configuration can be very adaptive.
If you want, you can also use decman with other configuration languages. See the [example with TOML later in this README](#decman-with-other-configuration-languages). If you want, you can also use decman with other configuration languages. See the [example with TOML later in this README](#decman-with-other-configuration-languages).
@@ -80,6 +93,8 @@ Then import your module in your main source file.
import decman import decman
from syncthing import Syncthing from syncthing import Syncthing
# NOTE! Removing a enabled module from decman.module means that on_disable will not run.
# Instead disable the module.
decman.modules += [Syncthing()] decman.modules += [Syncthing()]
``` ```
@@ -109,17 +124,22 @@ decman --help
Clone the decman PKGBUILD: Clone the decman PKGBUILD:
```sh ```sh
git clone https://github.com/kiviktnm/decman-pkgbuild.git git clone https://aur.archlinux.org/decman.git
``` ```
Review the PKGBUILD and install it. Review the PKGBUILD and install it.
```sh ```sh
cd decman-pkgbuild cd decman
makepkg -si makepkg -si
``` ```
So far I have not created an AUR package for decman, because I'm not sure if other people would find decman useful. Remember to add decman to its own configuration.
```py
import decman
decman.aur_packages += ["decman"]
```
## What decman manages? ## What decman manages?
@@ -127,6 +147,8 @@ So far I have not created an AUR package for decman, because I'm not sure if oth
Decman can be used to install pacman packages. Decman will install all packages defined in the source and **remove** all explicitly installed packages not defined in the source. You don't need to list dependencies as those will be handeled by pacman. You can set packages to be ignored by decman, so that it won't install them nor remove them. Decman can be used to install pacman packages. Decman will install all packages defined in the source and **remove** all explicitly installed packages not defined in the source. You don't need to list dependencies as those will be handeled by pacman. You can set packages to be ignored by decman, so that it won't install them nor remove them.
Please keep in mind that decman doesn't play well with package groups, since all packages part of that group will be installed explicitly. After the initial run decman will now try to remove those packages since it only knows that the group itself should be explicitly installed. Instead of package groups, use meta packages.
```py ```py
# Include both foreign and pacman packages here. # Include both foreign and pacman packages here.
decman.ignored_packages += ["yay", "opendoas"] decman.ignored_packages += ["yay", "opendoas"]
@@ -143,12 +165,13 @@ Decman can install AUR packages as well as user defined packages. Foreign packag
Here is an example of a user package. Managing user packages is somewhat cumbersome as you have to declare their versions, dependencies and make dependencies manually. However, you probably won't install many user packages anyway. Here is an example of a user package. Managing user packages is somewhat cumbersome as you have to declare their versions, dependencies and make dependencies manually. However, you probably won't install many user packages anyway.
```py ```py
# Note, decman now has a aur package, I recommend using that instead.
# Also, this example may be out of date
decman.user_packages.append( decman.user_packages.append(
decman.UserPackage( decman.UserPackage(
pkgname="decman-git", pkgname="decman-git",
# Note, this example may not be up to date
provides=["decman"], provides=["decman"],
version="0.3.0", version="0.4.1",
dependencies=["python", "python-requests", "devtools", "pacman", "systemd", "git"], dependencies=["python", "python-requests", "devtools", "pacman", "systemd", "git"],
make_dependencies=[ make_dependencies=[
"python-setuptools", "python-build", "python-installer", "python-wheel" "python-setuptools", "python-build", "python-installer", "python-wheel"
@@ -157,12 +180,15 @@ decman.user_packages.append(
)) ))
``` ```
Building of foreign packages happens in a chroot. This creates some overhead, but ensures clean builds. By default the chroot is created to `/tmp/decman/build`. I recommend to use a tempfs for the `/tmp/` directory to speed up builds. Also make sure that the tempfs-partition is large enough. I recommend at least 6 GB. Building of foreign packages happens in a chroot. This creates some overhead, but ensures clean builds. By default the chroot is created to `/tmp/decman/build`. I recommend to use a tmpfs for the `/tmp/` directory to speed up builds. Also make sure that the tmpfs-partition is large enough. I recommend at least 6 GB.
Build packages are stored in a cache `/var/cache/decman`. By default decman keeps 3 most recent versions of all packages. Build packages are stored in a cache `/var/cache/decman`. By default decman keeps 3 most recent versions of all packages.
### Systemd units ### Systemd units
> [!NOTE]
> Decman will only enable and disable systemd services. It will not start or stop them.
Decman can enable systemd services, system wide or for a specific user. Decman will enable all units defined in the source, and disable them when they are removed from the source. If a unit is not defined in the source, decman will not touch it. Decman can enable systemd services, system wide or for a specific user. Decman will enable all units defined in the source, and disable them when they are removed from the source. If a unit is not defined in the source, decman will not touch it.
### Files ### Files
@@ -273,10 +299,11 @@ aur_packages = ["protonvpn"]
enabled_systemd_units = ["NetworkManager.service"] enabled_systemd_units = ["NetworkManager.service"]
ignored_packages = ["yay"] ignored_packages = ["yay"]
user_packages = [{ user_packages = [{
# Note, decman now has a aur package, I recommend using that instead.
# Also, this example may be out of date
pkgname="decman-git", pkgname="decman-git",
# Note, this example may not be up to date
provides=["decman"], provides=["decman"],
version="0.3.0", version="0.4.1",
dependencies=["python", "python-requests", "devtools", "pacman", "systemd", "git"], dependencies=["python", "python-requests", "devtools", "pacman", "systemd", "git"],
make_dependencies=[ make_dependencies=[
"python-setuptools", "python-build", "python-installer", "python-wheel" "python-setuptools", "python-build", "python-installer", "python-wheel"
+22 -9
View File
@@ -1,10 +1,10 @@
# from import is ok for importing classes and functions # from import is ok for importing classes and functions
# just remember to not import variables this way # just remember to not import variables this way
from decman import Module, File, Directory, UserPackage, sh, prg
from decman import Directory, File, Module, UserPackage, prg, sh
class MyModule(Module): class MyModule(Module):
def __init__(self): def __init__(self):
self.pkgs = ["rust"] self.pkgs = ["rust"]
self.update_rustup = False self.update_rustup = False
@@ -28,6 +28,8 @@ class MyModule(Module):
# or run a program with arguments. # or run a program with arguments.
prg(["usermod", "--append", "--groups", "mygroup", "kk"]) prg(["usermod", "--append", "--groups", "mygroup", "kk"])
# NOTE! Removing a enabled module from decman.module means that on_disable will not run.
# Instead disable the module.
def on_disable(self): def on_disable(self):
# You can run commands as any user # You can run commands as any user
sh("whoami", user="kk") sh("whoami", user="kk")
@@ -58,18 +60,21 @@ class MyModule(Module):
def files(self) -> dict[str, File]: def files(self) -> dict[str, File]:
# Variables are substituted in text files automatically. # Variables are substituted in text files automatically.
return { return {
"/usr/local/bin/say-hello": "/usr/local/bin/say-hello": File(
File(content="#!/usr/bin/env bash\necho %msg%", permissions=0o755), content="#!/usr/bin/env bash\necho %msg%", permissions=0o755
),
# Variables are not substituted in binary files. # Variables are not substituted in binary files.
"/usr/local/share/say-hello/image.png": "/usr/local/share/say-hello/image.png": File(
File(source_file="files/i-dont-exist.png", bin_file=True), source_file="files/i-dont-exist.png", bin_file=True
),
} }
def directories(self) -> dict[str, Directory]: def directories(self) -> dict[str, Directory]:
# Directories are handeled the same way. Variables are substituted in text files. # Directories are handeled the same way. Variables are substituted in text files.
return { return {
"/home/kk/.config/mod-app/": "/home/kk/.config/mod-app/": Directory(
Directory(source_directory="files/app-config", owner="kk") source_directory="files/app-config", owner="kk"
)
} }
# Packages and systemd units are basically the same with modules as without modules. # Packages and systemd units are basically the same with modules as without modules.
@@ -79,10 +84,12 @@ class MyModule(Module):
return self.pkgs return self.pkgs
def user_packages(self) -> list[UserPackage]: def user_packages(self) -> list[UserPackage]:
# Note, decman now has a aur package, I recommend using that instead.
# Also, this example may be out of date
return [ return [
UserPackage( UserPackage(
pkgname="decman-git", pkgname="decman-git",
version="0.3.0", version="0.4.1",
provides=["decman"], provides=["decman"],
dependencies=[ dependencies=[
"python", "python",
@@ -106,6 +113,12 @@ class MyModule(Module):
def aur_packages(self) -> list[str]: def aur_packages(self) -> list[str]:
return ["protonvpn"] return ["protonvpn"]
def flatpak_packages(self) -> list[str]:
return ["org.mozilla.firefox"]
def flatpak_user_packages(self) -> dict[str, list[str]]:
return {"username": ["io.github.kolunmi.Bazaar"]}
def systemd_units(self) -> list[str]: def systemd_units(self) -> list[str]:
return ["reflector.timer"] return ["reflector.timer"]
+64 -32
View File
@@ -1,8 +1,8 @@
# This example covers all decman features and many useful ways of configuring a system. # This example covers all decman features and many useful ways of configuring a system.
# Configuration can be as simple or as complex as is needed. # Configuration can be as simple or as complex as is needed.
import socket
import os import os
import socket
# Note: Do NOT use from imports for global variables # Note: Do NOT use from imports for global variables
# BAD: from decman import packages/modules/etc # BAD: from decman import packages/modules/etc
@@ -10,7 +10,10 @@ import decman
import decman.config import decman.config
# This is fine since the thing being imported is a class and not a global variable. # This is fine since the thing being imported is a class and not a global variable.
from decman import UserPackage, File, Directory, UserRaisedError from decman import Directory, File, UserPackage, UserRaisedError
# Flatpaks are disabled by default. This way no already installed flatpaks will get suddenly deleted.
decman.config.enable_flatpak = True
# Configuring what packages are installed is easy. # Configuring what packages are installed is easy.
# Duplicates are OK, so if you have multiple modules that want to ensure a package is installed, # Duplicates are OK, so if you have multiple modules that want to ensure a package is installed,
@@ -26,7 +29,19 @@ decman.packages += ["python", "python", "devtools", "git", "networkmanager"]
decman.ignored_packages += ["rustup", "yay"] decman.ignored_packages += ["rustup", "yay"]
# Installing AUR packages is easy. # Installing AUR packages is easy.
decman.aur_packages += ["protonvpn"] decman.aur_packages += ["decman", "protonvpn"]
# Flatpaks work the same way as all other packages.
decman.flatpak_packages += ["dev.qwery.AddWater"]
# They too can be ignored of course
decman.ignored_flatpak_packages += ["org.signal.Signal"]
# You can also install them to your user installation instead of the system installation
# Ensure that previous user installed flatpak declarations aren't overwritten and they are initialized.
decman.flatpak_user_packages["kk"] = decman.flatpak_user_packages.get("kk", [])
# Now add the package.
decman.flatpak_user_packages["kk"].append("dev.zed.Zed")
# To import GPG keys, set the GNUPGHOME environment variable. # To import GPG keys, set the GNUPGHOME environment variable.
# It can easily be done with python as well. # It can easily be done with python as well.
@@ -36,11 +51,13 @@ decman.config.makepkg_user = "kk"
# You can also install packages from anywhere, but then you must include some # You can also install packages from anywhere, but then you must include some
# information about the package. The git_url is the url to the PKGBUILD, # information about the package. The git_url is the url to the PKGBUILD,
# This example may not be up to date, but you should keep these up to date with the PKGBUILD. #
# Note, decman now has a aur package, I recommend using that instead.
# Also, this example may be out of date
decman.user_packages.append( decman.user_packages.append(
UserPackage( UserPackage(
pkgname="decman-git", pkgname="decman-git",
version="0.3.0", version="0.4.1",
provides=["decman"], provides=["decman"],
dependencies=[ dependencies=[
"python", "python",
@@ -58,7 +75,8 @@ decman.user_packages.append(
"python-wheel", "python-wheel",
], ],
git_url="https://github.com/kiviktnm/decman-pkgbuild.git", git_url="https://github.com/kiviktnm/decman-pkgbuild.git",
)) )
)
# Managing only packages with decman is not that interesting. # Managing only packages with decman is not that interesting.
# Decman also has really powerful ways of managing config files, scripts etc. # Decman also has really powerful ways of managing config files, scripts etc.
@@ -68,23 +86,25 @@ decman.user_packages.append(
# Define file content inline. # Define file content inline.
# Default text file encoding is utf-8 but it can be changed. # Default text file encoding is utf-8 but it can be changed.
decman.files["/etc/vconsole.conf"] = File(content="KEYMAP=us", decman.files["/etc/vconsole.conf"] = File(content="KEYMAP=us", encoding="utf-8")
encoding="utf-8")
# Include file content from another file, set the file owner and permissions. # Include file content from another file, set the file owner and permissions.
# The source_file is relative to the directory where the main decman source.py is located. # The source_file is relative to the directory where the main decman source.py is located.
# By default, the file group is set to the group of the owner, but it can be overridden with the group argument. # By default, the file group is set to the group of the owner, but it can be overridden with the group argument.
decman.files["/home/kk/.bin/user-script.sh"] = File( decman.files["/home/kk/.bin/user-script.sh"] = File(
source_file="files/user-script.sh", owner="kk", permissions=0o744) source_file="files/user-script.sh", owner="kk", permissions=0o744
)
# Non-text files such as images can also be managed. # Non-text files such as images can also be managed.
decman.files["/home/kk/.background.png"] = File( decman.files["/home/kk/.background.png"] = File(
source_file="files/i-dont-actually-exist.png", bin_file=True, owner="kk") source_file="files/i-dont-actually-exist.png", bin_file=True, owner="kk"
)
# If you need to install multiple files at once, use directories. # If you need to install multiple files at once, use directories.
# All files from the source directory will be copied recursively to the target. # All files from the source directory will be copied recursively to the target.
decman.directories["/home/kk/.config/app/"] = Directory( decman.directories["/home/kk/.config/app/"] = Directory(
source_directory="files/app-config", owner="kk") source_directory="files/app-config", owner="kk"
)
# Decman has built in support for managing systemd units as well. # Decman has built in support for managing systemd units as well.
# Decman will enable services declared here, and disable services removed from here. # Decman will enable services declared here, and disable services removed from here.
@@ -95,8 +115,9 @@ decman.enabled_systemd_units += ["NetworkManager.service"]
# You can manage units for users as well. # You can manage units for users as well.
# Ensure that previous user unit declarations aren't overwritten and they are initialized. # Ensure that previous user unit declarations aren't overwritten and they are initialized.
decman.enabled_systemd_user_units[ decman.enabled_systemd_user_units["kk"] = decman.enabled_systemd_user_units.get(
"kk"] = decman.enabled_systemd_user_units.get("kk", []) "kk", []
)
# Add user unit. # Add user unit.
decman.enabled_systemd_user_units["kk"].append("syncthing.service") decman.enabled_systemd_user_units["kk"].append("syncthing.service")
@@ -141,9 +162,9 @@ decman.config.pacman_output_keywords = [
"pacsave", "pacsave",
"pacnew", "pacnew",
# Additional keywords can be: # Additional keywords can be:
#"warning", # "warning",
#"error", # "error",
#"note", # "note",
# They might cause too many highlights however. # They might cause too many highlights however.
] ]
# If you don't want to print lines that contain keywords, set this to False # If you don't want to print lines that contain keywords, set this to False
@@ -173,7 +194,6 @@ decman.config.number_of_packages_stored_in_cache = 3
# Create a child class of the decman.config.Commands class and override methods. # Create a child class of the decman.config.Commands class and override methods.
# These are the defaults. # These are the defaults.
class MyCommands(decman.config.Commands): class MyCommands(decman.config.Commands):
def list_pkgs(self) -> list[str]: def list_pkgs(self) -> list[str]:
return ["pacman", "-Qeq", "--color=never"] return ["pacman", "-Qeq", "--color=never"]
@@ -193,8 +213,7 @@ class MyCommands(decman.config.Commands):
return ["pacman", "-D", "--asexplicit"] + pkgs return ["pacman", "-D", "--asexplicit"] + pkgs
def install_deps(self, deps: list[str]) -> list[str]: def install_deps(self, deps: list[str]) -> list[str]:
return ["pacman", "-S", "--color=always", "--needed", "--asdeps" return ["pacman", "-S", "--color=always", "--needed", "--asdeps"] + deps
] + deps
def is_installable(self, pkg: str) -> list[str]: def is_installable(self, pkg: str) -> list[str]:
return ["pacman", "-Sddp", pkg] return ["pacman", "-Sddp", pkg]
@@ -217,8 +236,7 @@ class MyCommands(decman.config.Commands):
def disable_user_units(self, units: list[str], user: str) -> list[str]: def disable_user_units(self, units: list[str], user: str) -> list[str]:
return ["systemctl", "--user", "-M", f"{user}@", "disable"] + units return ["systemctl", "--user", "-M", f"{user}@", "disable"] + units
def compare_versions(self, installed_version: str, def compare_versions(self, installed_version: str, new_version: str) -> list[str]:
new_version: str) -> list[str]:
return ["vercmp", installed_version, new_version] return ["vercmp", installed_version, new_version]
def git_clone(self, repo: str, dest: str) -> list[str]: def git_clone(self, repo: str, dest: str) -> list[str]:
@@ -230,6 +248,9 @@ class MyCommands(decman.config.Commands):
def git_get_commit_id(self) -> list[str]: def git_get_commit_id(self) -> list[str]:
return ["git", "rev-parse", "HEAD"] return ["git", "rev-parse", "HEAD"]
def git_log_commit_ids(self) -> list[str]:
return ["git", "log", "--format=format:%H"]
def review_file(self, file: str) -> list[str]: def review_file(self, file: str) -> list[str]:
return ["less", file] return ["less", file]
@@ -238,20 +259,32 @@ class MyCommands(decman.config.Commands):
def install_chroot_packages(self, chroot_dir: str, packages: list[str]): def install_chroot_packages(self, chroot_dir: str, packages: list[str]):
return [ return [
"arch-nspawn", chroot_dir, "pacman", "-S", "--needed", "arch-nspawn",
"--noconfirm" chroot_dir,
"pacman",
"-S",
"--needed",
"--noconfirm",
] + packages ] + packages
def remove_chroot_packages(self, chroot_dir: str, packages: list[str]): def resolve_real_name(self, chroot_dir: str, pkg: str) -> list[str]:
return ["arch-nspawn", chroot_dir, "pacman", "-Rsu", "--noconfirm" return [
] + packages "arch-nspawn",
chroot_dir,
def make_chroot_pkg(self, chroot_wd_dir: str, user: str, "pacman",
pkgfiles_to_install: list[str]) -> list[str]: "-Sddp",
makechrootpkg_cmd = [ "--print-format=%n",
"makechrootpkg", "-c", "-r", chroot_wd_dir, "-U", user pkg,
] ]
def remove_chroot_packages(self, chroot_dir: str, packages: list[str]):
return ["arch-nspawn", chroot_dir, "pacman", "-Rsu", "--noconfirm"] + packages
def make_chroot_pkg(
self, chroot_wd_dir: str, user: str, pkgfiles_to_install: list[str]
) -> list[str]:
makechrootpkg_cmd = ["makechrootpkg", "-c", "-r", chroot_wd_dir, "-U", user]
for pkgfile in pkgfiles_to_install: for pkgfile in pkgfiles_to_install:
makechrootpkg_cmd += ["-I", pkgfile] makechrootpkg_cmd += ["-I", pkgfile]
@@ -267,7 +300,6 @@ decman.config.commands = MyCommands()
class PikaurWrapperCommands(decman.config.Commands): class PikaurWrapperCommands(decman.config.Commands):
def list_pkgs(self) -> list[str]: def list_pkgs(self) -> list[str]:
return ["pikaur", "-Qeq"] return ["pikaur", "-Qeq"]
+8 -2
View File
@@ -4,15 +4,21 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "decman" name = "decman"
version = "0.3.0" version = "0.4.1"
description = "Declarative package & configuration manager for Arch Linux." description = "Declarative package & configuration manager for Arch Linux."
license = {file = "LICENSE"} license = {file = "LICENSE"}
authors = [ authors = [
{name = "Kivi Kaitaniemi"} {name = "Kivi Kaitaniemi"}
] ]
dependencies = [ dependencies = [
"requests" "requests",
] ]
[project.scripts] [project.scripts]
decman = "decman.app:main" decman = "decman.app:main"
[dependency-groups]
dev = [
"isort>=5.13.2",
"ruff>=0.9.3",
]
+64 -36
View File
@@ -2,12 +2,13 @@
Module for writing system configurations for decman. Module for writing system configurations for decman.
""" """
import typing
import pwd
import grp import grp
import shutil
import os import os
import pwd
import shutil
import subprocess import subprocess
import typing
import decman.error import decman.error
@@ -20,9 +21,11 @@ class UserRaisedError(Exception):
super().__init__(message) super().__init__(message)
def sh(sh_cmd: str, def sh(
user: typing.Optional[str] = None, sh_cmd: str,
env_overrides: typing.Optional[dict[str, str]] = None): user: typing.Optional[str] = None,
env_overrides: typing.Optional[dict[str, str]] = None,
):
""" """
Shortcut for running a shell command. Shortcut for running a shell command.
""" """
@@ -49,16 +52,20 @@ def sh(sh_cmd: str,
f"Running user defined shell command failed because the user {user} doesn't exist." f"Running user defined shell command failed because the user {user} doesn't exist."
) from e ) from e
with subprocess.Popen(sh_cmd, shell=True, group=gid, user=uid, with subprocess.Popen(
env=env) as process: sh_cmd, shell=True, group=gid, user=uid, env=env
) as process:
if process.wait() != 0: if process.wait() != 0:
raise decman.error.UserFacingError( raise decman.error.UserFacingError(
f"Running user shell command '{sh_cmd}' as {user} failed.") f"Running user shell command '{sh_cmd}' as {user} failed."
)
def prg(command: list[str], def prg(
user: typing.Optional[str] = None, command: list[str],
env_overrides: typing.Optional[dict[str, str]] = None): user: typing.Optional[str] = None,
env_overrides: typing.Optional[dict[str, str]] = None,
):
""" """
Shortcut for running a program. Shortcut for running a program.
""" """
@@ -74,7 +81,8 @@ def prg(command: list[str],
subprocess.run(command, check=True, env=env) subprocess.run(command, check=True, env=env)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
raise decman.error.UserFacingError( raise decman.error.UserFacingError(
f"Running user defined program '{command}' failed.") from e f"Running user defined program '{command}' failed."
) from e
else: else:
try: try:
uid = pwd.getpwnam(user).pw_uid uid = pwd.getpwnam(user).pw_uid
@@ -84,11 +92,11 @@ def prg(command: list[str],
f"Running user defined program failed because the user {user} doesn't exist." f"Running user defined program failed because the user {user} doesn't exist."
) from e ) from e
with subprocess.Popen(command, group=gid, user=uid, with subprocess.Popen(command, group=gid, user=uid, env=env) as process:
env=env) as process:
if process.wait() != 0: if process.wait() != 0:
raise decman.error.UserFacingError( raise decman.error.UserFacingError(
f"Running user program '{command}' as {user} failed.") f"Running user program '{command}' as {user} failed."
)
class File: class File:
@@ -127,9 +135,7 @@ class File:
if group is not None: if group is not None:
self.gid = grp.getgrnam(group).gr_gid self.gid = grp.getgrnam(group).gr_gid
def copy_to(self, def copy_to(self, target: str, variables: typing.Optional[dict[str, str]] = None):
target: str,
variables: typing.Optional[dict[str, str]] = None):
""" """
Copies the contents of this file to the target file. Copies the contents of this file to the target file.
""" """
@@ -138,8 +144,9 @@ class File:
target_directory = os.path.dirname(target) target_directory = os.path.dirname(target)
def create_missing_dirs(dirct: str, uid: typing.Optional[int], def create_missing_dirs(
gid: typing.Optional[int]): dirct: str, uid: typing.Optional[int], gid: typing.Optional[int]
):
if not os.path.isdir(dirct): if not os.path.isdir(dirct):
parent_dir = os.path.dirname(dirct) parent_dir = os.path.dirname(dirct)
if not os.path.isdir(parent_dir): if not os.path.isdir(parent_dir):
@@ -161,8 +168,7 @@ class File:
os.chmod(target, self.permissions) os.chmod(target, self.permissions)
def _write_content(self, target: str, variables: dict[str, str]): def _write_content(self, target: str, variables: dict[str, str]):
if self.source_file is not None and (self.bin_file if self.source_file is not None and (self.bin_file or len(variables) == 0):
or len(variables) == 0):
shutil.copy(self.source_file, target) shutil.copy(self.source_file, target)
elif self.bin_file and self.content is not None: elif self.bin_file and self.content is not None:
with open(target, "wb") as file: with open(target, "wb") as file:
@@ -177,7 +183,9 @@ class File:
with open(target, "wt", encoding=self.encoding) as file: with open(target, "wt", encoding=self.encoding) as file:
file.write(content) file.write(content)
else: else:
assert self.content is not None, "Content should be set since source_file was not set." assert self.content is not None, (
"Content should be set since source_file was not set."
)
content = self.content content = self.content
for var, value in variables.items(): for var, value in variables.items():
content = content.replace(var, value) content = content.replace(var, value)
@@ -217,10 +225,12 @@ class Directory:
if group is not None: if group is not None:
self.gid = grp.getgrnam(group).gr_gid self.gid = grp.getgrnam(group).gr_gid
def copy_to(self, def copy_to(
target_directory: str, self,
variables: typing.Optional[dict[str, str]] = None, target_directory: str,
only_print: bool = False) -> list[str]: variables: typing.Optional[dict[str, str]] = None,
only_print: bool = False,
) -> list[str]:
""" """
Copies the files in this directory to the target directory. Copies the files in this directory to the target directory.
@@ -233,14 +243,15 @@ class Directory:
for src_dir, _, src_files in os.walk("."): for src_dir, _, src_files in os.walk("."):
for src_file in src_files: for src_file in src_files:
src_path = os.path.join(src_dir, src_file) src_path = os.path.join(src_dir, src_file)
file = File(source_file=src_path, file = File(
bin_file=self.bin_files, source_file=src_path,
encoding=self.encoding, bin_file=self.bin_files,
owner=self.owner, encoding=self.encoding,
group=self.group, owner=self.owner,
permissions=self.permissions) group=self.group,
target = os.path.normpath( permissions=self.permissions,
os.path.join(target_directory, src_path)) )
target = os.path.normpath(os.path.join(target_directory, src_path))
created.append(target) created.append(target)
if not only_print: if not only_print:
@@ -369,6 +380,20 @@ class Module:
""" """
return [] return []
def flatpak_packages(self) -> list[str]:
"""
Override this method to return flatpak packages that should be installed to the system installation as a part of this
Module.
"""
return []
def flatpak_user_packages(self) -> dict[str, list[str]]:
"""
Override this method to return flatpak packages that should be installed to the user installation as a part of this
Module.
"""
return {}
def systemd_units(self) -> list[str]: def systemd_units(self) -> list[str]:
""" """
Override this method to return systemd units that should be enabled as a part of this Override this method to return systemd units that should be enabled as a part of this
@@ -401,3 +426,6 @@ enabled_systemd_user_units: dict[str, list[str]] = {}
files: dict[str, File] = {} files: dict[str, File] = {}
directories: dict[str, Directory] = {} directories: dict[str, Directory] = {}
modules: list[Module] = [] modules: list[Module] = []
flatpak_packages: list[str] = []
flatpak_user_packages: dict[str, list[str]] = {}
ignored_flatpak_packages: list[str] = []
+233 -61
View File
@@ -1,16 +1,19 @@
# pyright: reportUnusedCallResult=false
""" """
Module containing the CLI Application. Module containing the CLI Application.
""" """
import argparse import argparse
import os import os
import pwd
import shutil
import sys import sys
import traceback import traceback
import decman import decman
import decman.config as conf
import decman.error as err import decman.error as err
import decman.lib as l import decman.lib as l
import decman.config as conf
from decman.lib import fpm from decman.lib import fpm
@@ -23,53 +26,68 @@ def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="decman", prog="decman",
description= description="Declarative package & configuration manager for Arch Linux",
"Declarative package & configuration manager for Arch Linux", epilog="See more help at: https://github.com/kiviktnm/decman",
epilog="See more help at: https://github.com/kiviktnm/decman") )
parser.add_argument("--source", parser.add_argument(
action="store", "--source", action="store", help="python file containing configuration"
help="python file containing configuration") )
parser.add_argument( parser.add_argument(
"--print", "--print",
"--dry-run", "--dry-run",
action="store_true", action="store_true",
default=False, default=False,
help="print what would happen as a result of running decman") help="print what would happen as a result of running decman",
parser.add_argument("--debug", )
action="store_true", parser.add_argument(
default=False, "--debug", action="store_true", default=False, help="show debug output"
help="show debug output") )
parser.add_argument( parser.add_argument(
"--no-packages", "--no-packages",
action="store_true", action="store_true",
default=False, default=False,
help="don't upgrade any packages (including foreign packages)") help="don't upgrade any packages (including foreign packages)",
parser.add_argument("--no-foreign-packages", )
action="store_true", parser.add_argument(
default=False, "--no-foreign-packages",
help="don't upgrade foreign packages") action="store_true",
parser.add_argument("--no-files", default=False,
action="store_true", help="don't upgrade foreign packages",
default=False, )
help="don't install any files") parser.add_argument(
parser.add_argument("--no-systemd-units", "--no-flatpaks",
action="store_true", action="store_true",
default=False, default=False,
help="don't enable/disable systemd units") help="don't upgrade flatpak packages",
parser.add_argument("--no-commands", )
action="store_true", parser.add_argument(
default=False, "--no-files", action="store_true", default=False, help="don't install any files"
help="don't run user specified commands") )
parser.add_argument("--upgrade-devel", parser.add_argument(
action="store_true", "--no-systemd-units",
default=False, action="store_true",
help="upgrade devel packages") default=False,
help="don't enable/disable systemd units",
)
parser.add_argument(
"--no-commands",
action="store_true",
default=False,
help="don't run user specified commands",
)
parser.add_argument(
"--upgrade-devel",
action="store_true",
default=False,
help="upgrade devel packages",
)
parser.add_argument( parser.add_argument(
"--force-build", "--force-build",
action="store_true", action="store_true",
default=False, default=False,
help="force building of packages that are already cached") help="force building of packages that are already cached",
)
args = parser.parse_args() args = parser.parse_args()
@@ -105,8 +123,7 @@ def main():
l.print_debug(line) l.print_debug(line)
errored = True errored = True
except decman.UserRaisedError as user_error: except decman.UserRaisedError as user_error:
l.print_error( l.print_error(f"Error encountered while running the source: {user_error}")
f"Error encountered while running the source: {user_error}")
errored = True errored = True
# Save even when an error has occurred, since this avoids repeating steps like building pkgs. # Save even when an error has occurred, since this avoids repeating steps like building pkgs.
@@ -158,13 +175,24 @@ def _set_up(store: l.Store, args):
content = file.read() content = file.read()
except OSError as e: except OSError as e:
raise err.UserFacingError( raise err.UserFacingError(
f"Failed to read source file '{store.source_file}'.") from e f"Failed to read source file '{store.source_file}'."
) from e
os.chdir(source_dir) os.chdir(source_dir)
sys.path.append(".") sys.path.append(".")
exec(content) exec(content)
return args.print, not args.no_packages, not args.no_foreign_packages, not args.no_files, not args.no_systemd_units, not args.no_commands, args.upgrade_devel, args.force_build return (
args.print,
not args.no_packages,
not args.no_foreign_packages,
not args.no_flatpaks,
not args.no_files,
not args.no_systemd_units,
not args.no_commands,
args.upgrade_devel,
args.force_build,
)
class Core: class Core:
@@ -173,25 +201,43 @@ class Core:
""" """
def __init__(self, store: l.Store, opts): def __init__(self, store: l.Store, opts):
self.only_print, self.update_packages, self.update_foreign_packages, self.update_files, self.update_units, self.run_commands, self.upgrade_devel, self.force_build = opts (
self.only_print,
self.update_packages,
self.update_foreign_packages,
self.update_flatpaks,
self.update_files,
self.update_units,
self.run_commands,
self.upgrade_devel,
self.force_build,
) = opts
if conf.enable_flatpak and not shutil.which("flatpak"):
l.print_error(
"Flatpaks have been enabled in the source file, but the flatpak command could not be found. Either disable flatpaks or make sure that flatpak is installed and can be accessed by decman. Exiting."
)
sys.exit(1)
self.store = store self.store = store
self.source = _resolve_source() self.source = _resolve_source()
self.pacman = l.Pacman() self.pacman = l.Pacman()
self.flatpak = l.Flatpak()
self.systemctl = l.Systemd(store) self.systemctl = l.Systemd(store)
self.fpkg_search = fpm.ExtendedPackageSearch(self.pacman) self.fpkg_search = fpm.ExtendedPackageSearch(self.pacman)
for upkg in self.source.all_user_pkgs(): for upkg in self.source.all_user_pkgs():
self.fpkg_search.add_user_pkg( self.fpkg_search.add_user_pkg(
fpm.PackageInfo.from_user_package(upkg, self.pacman)) fpm.PackageInfo.from_user_package(upkg, self.pacman)
)
self.fpm = fpm.ForeignPackageManager(store, self.pacman, self.fpm = fpm.ForeignPackageManager(store, self.pacman, self.fpkg_search)
self.fpkg_search)
def run(self): def run(self):
""" """
Run the main logic of decman. Run the main logic of decman.
""" """
if self.update_units: if self.update_units:
self._disable_units() self._disable_units()
@@ -218,6 +264,8 @@ class Core:
def _disable_units(self): def _disable_units(self):
to_disable = self.source.units_to_disable(self.store) to_disable = self.source.units_to_disable(self.store)
l.print_list("Disabling systemd units:", to_disable) l.print_list("Disabling systemd units:", to_disable)
if to_disable:
l.print_info("Disabled systemd units won't be stopped automatically.")
if not self.only_print: if not self.only_print:
self.systemctl.disable_units(to_disable) self.systemctl.disable_units(to_disable)
@@ -228,26 +276,105 @@ class Core:
self.systemctl.disable_user_units(units, user) self.systemctl.disable_user_units(units, user)
def _remove_pkgs(self): def _remove_pkgs(self):
"""
Remove pacman and flatpak packages
"""
# pacman
currently_installed = self.pacman.get_installed() currently_installed = self.pacman.get_installed()
to_remove = self.source.packages_to_remove(currently_installed) to_remove = self.source.packages_to_remove(currently_installed)
l.print_list("Removing packages:", to_remove)
if not self.only_print: currently_installed_flatpak = self.flatpak.get_installed()
self.pacman.remove(to_remove) to_remove_flatpak = self.source.flatpak_packages_to_remove(
currently_installed_flatpak
)
l.print_list("Removing pacman packages:", to_remove)
if conf.enable_flatpak and self.update_flatpaks:
l.print_list("Removing flatpak packages:", to_remove_flatpak)
self._remove_user_flatpaks(only_print=True)
if self.only_print:
return
self.pacman.remove(to_remove)
# flatpak
if conf.enable_flatpak and self.update_flatpaks:
self.flatpak.remove(to_remove_flatpak)
self._remove_user_flatpaks()
def _remove_user_flatpaks(self, only_print: bool = False):
# Get all non system users (users that have uid >= 1000), also ignore nobody
users = [
u.pw_name
for u in pwd.getpwall()
if u.pw_uid >= 1000 and u.pw_name not in ("nobody",)
]
# Add root to users
users.append("root")
for user in users:
currently_installed_flatpak = self.flatpak.get_installed(
as_user=True, which_user=user
)
to_remove_flatpak = self.source.flatpak_packages_to_remove(
currently_installed_flatpak, as_user=True, which_user=user
)
l.print_list(
f"Removing flatpak packages from user installation for user {user}",
to_remove_flatpak,
)
if only_print:
continue
self.flatpak.remove(to_remove_flatpak, True, user)
def _upgrade_pkgs(self): def _upgrade_pkgs(self):
"""
Upgrade pacman, fpm and flatpak packages
"""
# flatpak + fpm
l.print_summary("Upgrading packages.") l.print_summary("Upgrading packages.")
if not self.only_print: if self.only_print:
self.pacman.upgrade() return
if conf.enable_fpm and self.update_foreign_packages:
self.fpm.upgrade(self.upgrade_devel, self.force_build, self.pacman.upgrade()
self.source.ignored_packages) if conf.enable_fpm and self.update_foreign_packages:
self.fpm.upgrade(
self.upgrade_devel, self.force_build, self.source.ignored_packages
)
# flatpak
if conf.enable_flatpak and self.update_flatpaks:
l.print_summary("Upgrading flatpak packages.")
self.flatpak.upgrade()
users = [
u.pw_name
for u in pwd.getpwall()
if u.pw_uid >= 1000 and u.pw_name not in ("nobody",)
]
# Add root to users
users.append("root")
for user in users:
l.print_summary(f"Upgrading flatpak packages for {user}.")
self.flatpak.upgrade(True, user)
def _install_pkgs(self): def _install_pkgs(self):
"""
Installs all pacman, fpm, and flatpak packages.
"""
# pacman + fpm
currently_installed = self.pacman.get_installed() currently_installed = self.pacman.get_installed()
to_install_pacman = self.source.pacman_packages_to_install( to_install_pacman = self.source.pacman_packages_to_install(currently_installed)
currently_installed) to_install_fpm = self.source.foreign_packages_to_install(currently_installed)
to_install_fpm = self.source.foreign_packages_to_install(
currently_installed) # flatpak
currently_installed_flatpak = self.flatpak.get_installed()
to_install_flatpak = self.source.flatpak_packages_to_install(
currently_installed_flatpak
)
l.print_list("Installing pacman packages:", to_install_pacman) l.print_list("Installing pacman packages:", to_install_pacman)
@@ -255,10 +382,48 @@ class Core:
if self.only_print: if self.only_print:
l.print_list("Installing foreign packages:", to_install_fpm) l.print_list("Installing foreign packages:", to_install_fpm)
if not self.only_print: if conf.enable_flatpak and self.update_flatpaks:
self.pacman.install(to_install_pacman) l.print_list("Installing flatpak packages:", to_install_flatpak)
if conf.enable_fpm and self.update_foreign_packages:
self.fpm.install(to_install_fpm, force=self.force_build) if self.only_print:
self._install_user_flatpaks(only_print=True)
return
self.pacman.install(to_install_pacman)
if conf.enable_fpm and self.update_foreign_packages:
self.fpm.install(to_install_fpm, force=self.force_build)
if conf.enable_flatpak and self.update_flatpaks:
self.flatpak.install(to_install_flatpak)
# Print summary before the action
self._install_user_flatpaks(only_print=True)
self._install_user_flatpaks()
def _install_user_flatpaks(self, only_print: bool = False):
# Get all non system users (users that have uid >= 1000), also ignore nobody
users = [
u.pw_name
for u in pwd.getpwall()
if u.pw_uid >= 1000 and u.pw_name not in ("nobody",)
]
# Add root to users
users.append("root")
for user in users:
currently_installed_flatpak = self.flatpak.get_installed(
as_user=True, which_user=user
)
to_install_flatpak = self.source.flatpak_packages_to_install(
currently_installed_flatpak, as_user=True, which_user=user
)
if only_print:
l.print_list(
f"Installing flatpak packages to user installation for user {user}",
to_install_flatpak,
)
continue
self.flatpak.install(to_install_flatpak, True, user)
def _create_and_remove_files(self): def _create_and_remove_files(self):
l.print_summary("Installing files.") l.print_summary("Installing files.")
@@ -266,9 +431,7 @@ class Core:
all_created = self.source.create_all_files(self.only_print) all_created = self.source.create_all_files(self.only_print)
to_remove = self.source.files_to_remove(self.store, all_created) to_remove = self.source.files_to_remove(self.store, all_created)
l.print_list("Ensured files are up to date:", l.print_list("Ensured files are up to date:", all_created, elements_per_line=1)
all_created,
elements_per_line=1)
l.print_list("Removing files:", to_remove, elements_per_line=1) l.print_list("Removing files:", to_remove, elements_per_line=1)
if self.only_print: if self.only_print:
@@ -286,6 +449,8 @@ class Core:
def _enable_units(self): def _enable_units(self):
to_enable = self.source.units_to_enable(self.store) to_enable = self.source.units_to_enable(self.store)
l.print_list("Enabling systemd units:", to_enable) l.print_list("Enabling systemd units:", to_enable)
if to_enable:
l.print_info("Enabled systemd units won't be started automatically.")
if not self.only_print: if not self.only_print:
self.systemctl.enable_units(to_enable) self.systemctl.enable_units(to_enable)
@@ -318,6 +483,10 @@ def _resolve_source() -> l.Source:
for user, units in decman.enabled_systemd_user_units.items(): for user, units in decman.enabled_systemd_user_units.items():
enabled_systemd_user_units[user] = set(units) enabled_systemd_user_units[user] = set(units)
flatpak_user_packages = {}
for user, pkgs in decman.flatpak_user_packages.items():
flatpak_user_packages[user] = set(pkgs)
return l.Source( return l.Source(
pacman_packages=set(decman.packages), pacman_packages=set(decman.packages),
aur_packages=set(decman.aur_packages), aur_packages=set(decman.aur_packages),
@@ -328,6 +497,9 @@ def _resolve_source() -> l.Source:
files=decman.files, files=decman.files,
directories=decman.directories, directories=decman.directories,
modules=set(decman.modules), modules=set(decman.modules),
flatpak_packages=set(decman.flatpak_packages),
flatpak_user_packages=flatpak_user_packages,
ignored_flatpak_packages=set(decman.ignored_flatpak_packages),
) )
+93 -16
View File
@@ -34,6 +34,20 @@ class Commands:
""" """
return ["pacman", "-Qeq", "--color=never"] return ["pacman", "-Qeq", "--color=never"]
def list_flatpak_pkgs(self, as_user: bool = False) -> list[str]:
"""
Running this command outputs a newline separated list of installed flatpak application ids
The first line just says 'Application ID' so this one is ignored.
"""
return [
"flatpak",
"list",
"--app",
"--user" if as_user else "--system",
"--columns",
"application",
]
def list_foreign_pkgs_versioned(self) -> list[str]: def list_foreign_pkgs_versioned(self) -> list[str]:
""" """
Running this command outputs a newline seperated list of installed packages and their Running this command outputs a newline seperated list of installed packages and their
@@ -47,6 +61,12 @@ class Commands:
""" """
return ["pacman", "-S", "--color=always", "--needed"] + pkgs return ["pacman", "-S", "--color=always", "--needed"] + pkgs
def install_flatpak_pkgs(self, pkgs: list[str], as_user: bool = False) -> list[str]:
"""
Running this command installs all listed packages, and their dependencies/runtimes automatically.
"""
return ["flatpak", "install", "-y", "--user" if as_user else "--system"] + pkgs
def install_files(self, pkg_files: list[str]) -> list[str]: def install_files(self, pkg_files: list[str]) -> list[str]:
""" """
Running this command installs the given packages files. Running this command installs the given packages files.
@@ -64,8 +84,7 @@ class Commands:
Running this command installs the given packages from pacman repositories. Running this command installs the given packages from pacman repositories.
The packages are installed as dependencies. The packages are installed as dependencies.
""" """
return ["pacman", "-S", "--color=always", "--needed", "--asdeps" return ["pacman", "-S", "--color=always", "--needed", "--asdeps"] + deps
] + deps
def is_installable(self, pkg: str) -> list[str]: def is_installable(self, pkg: str) -> list[str]:
""" """
@@ -79,6 +98,18 @@ class Commands:
""" """
return ["pacman", "-Syu", "--color=always"] return ["pacman", "-Syu", "--color=always"]
def upgrade_flatpak(self, as_user: bool = False) -> list[str]:
"""
Updates all installed flatpak REFs including runtimes and dependencies.
"""
return [
"flatpak",
"update",
"--noninteractive",
"-y",
"--user" if as_user else "--system",
]
def remove(self, pkgs: list[str]) -> list[str]: def remove(self, pkgs: list[str]) -> list[str]:
""" """
Running this command removes the given packages and their dependencies Running this command removes the given packages and their dependencies
@@ -86,6 +117,31 @@ class Commands:
""" """
return ["pacman", "-Rs", "--color=always"] + pkgs return ["pacman", "-Rs", "--color=always"] + pkgs
def remove_flatpak(self, pkgs: list[str], as_user: bool = False) -> list[str]:
"""
Running this command will remove the listed REFs. Unused dependencies might be kept, but to remove them another command needs to be run.
"""
return [
"flatpak",
"remove",
"--noninteractive",
"-y",
"--user" if as_user else "--system",
] + pkgs
def remove_unused_flatpak(self, as_user: bool = False) -> list[str]:
"""
This will remove all unused flatpak dependencies and runtimes.
"""
return [
"flatpak",
"remove",
"--noninteractive",
"-y",
"--unused",
"--user" if as_user else "--system",
]
def enable_units(self, units: list[str]) -> list[str]: def enable_units(self, units: list[str]) -> list[str]:
""" """
Running this command enables the given systemd units. Running this command enables the given systemd units.
@@ -110,8 +166,7 @@ class Commands:
""" """
return ["systemctl", "--user", "-M", f"{user}@", "disable"] + units return ["systemctl", "--user", "-M", f"{user}@", "disable"] + units
def compare_versions(self, installed_version: str, def compare_versions(self, installed_version: str, new_version: str) -> list[str]:
new_version: str) -> list[str]:
""" """
Running this command outputs -1 when the installed version is older than the new version. Running this command outputs -1 when the installed version is older than the new version.
""" """
@@ -136,6 +191,12 @@ class Commands:
""" """
return ["git", "rev-parse", "HEAD"] return ["git", "rev-parse", "HEAD"]
def git_log_commit_ids(self) -> list[str]:
"""
Running this command outputs commit hashes of the repository.
"""
return ["git", "log", "--format=format:%H"]
def review_file(self, file: str) -> list[str]: def review_file(self, file: str) -> list[str]:
""" """
Running this command outputs a file for the user to see. Running this command outputs a file for the user to see.
@@ -154,27 +215,42 @@ class Commands:
Running this command installs the given packages to the given chroot. Running this command installs the given packages to the given chroot.
""" """
return [ return [
"arch-nspawn", chroot_dir, "pacman", "-S", "--needed", "arch-nspawn",
"--noconfirm" chroot_dir,
"pacman",
"-S",
"--needed",
"--noconfirm",
] + packages ] + packages
def resolve_real_name(self, chroot_dir: str, pkg: str) -> list[str]:
"""
This command prints a real name of a package. For example, it prints the package which provides a virtual package.
"""
return [
"arch-nspawn",
chroot_dir,
"pacman",
"-Sddp",
"--print-format=%n",
pkg,
]
def remove_chroot_packages(self, chroot_dir: str, packages: list[str]): def remove_chroot_packages(self, chroot_dir: str, packages: list[str]):
""" """
Running this command removes the given packages from the given chroot. Running this command removes the given packages from the given chroot.
""" """
return ["arch-nspawn", chroot_dir, "pacman", "-Rsu", "--noconfirm" return ["arch-nspawn", chroot_dir, "pacman", "-Rsu", "--noconfirm"] + packages
] + packages
def make_chroot_pkg(self, chroot_wd_dir: str, user: str, def make_chroot_pkg(
pkgfiles_to_install: list[str]) -> list[str]: self, chroot_wd_dir: str, user: str, pkgfiles_to_install: list[str]
) -> list[str]:
""" """
Running this command creates a package file using the given chroot. Running this command creates a package file using the given chroot.
The package is created as the user and the pkg_files_to_install are installed The package is created as the user and the pkg_files_to_install are installed
in the chroot before the package is created. in the chroot before the package is created.
""" """
makechrootpkg_cmd = [ makechrootpkg_cmd = ["makechrootpkg", "-c", "-r", chroot_wd_dir, "-U", user]
"makechrootpkg", "-c", "-r", chroot_wd_dir, "-U", user
]
for pkgfile in pkgfiles_to_install: for pkgfile in pkgfiles_to_install:
makechrootpkg_cmd += ["-I", pkgfile] makechrootpkg_cmd += ["-I", pkgfile]
@@ -204,9 +280,9 @@ pacman_output_keywords: list[str] = [
"pacsave", "pacsave",
"pacnew", "pacnew",
# These cause too many false positives IMO # These cause too many false positives IMO
#"warning", # "warning",
#"error", # "error",
#"note", # "note",
] ]
print_pacman_output_highlights: bool = True print_pacman_output_highlights: bool = True
@@ -215,4 +291,5 @@ build_dir: str = "/tmp/decman/build"
pkg_cache_dir: str = "/var/cache/decman" pkg_cache_dir: str = "/var/cache/decman"
aur_rpc_timeout: typing.Optional[int] = 30 aur_rpc_timeout: typing.Optional[int] = 30
enable_fpm: bool = True enable_fpm: bool = True
enable_flatpak: bool = False
number_of_packages_stored_in_cache: int = 3 number_of_packages_stored_in_cache: int = 3
+343 -130
View File
@@ -2,17 +2,18 @@
Library module for decman. Library module for decman.
""" """
import threading
import sys
import shutil
import subprocess
import json import json
import os import os
import typing import pty
import pwd
import shutil
import subprocess
import time import time
import typing
import decman
import decman.config as conf import decman.config as conf
import decman.error as err import decman.error as err
import decman
_DECMAN_MSG_TAG = "[\033[1;35mDECMAN\033[m]" _DECMAN_MSG_TAG = "[\033[1;35mDECMAN\033[m]"
_RED_PREFIX = "\033[91m" _RED_PREFIX = "\033[91m"
@@ -60,12 +61,14 @@ def print_summary(msg: str):
print(f"{_DECMAN_MSG_TAG} {_CYAN_PREFIX}SUMMARY{_RESET_SUFFIX}: {msg}") print(f"{_DECMAN_MSG_TAG} {_CYAN_PREFIX}SUMMARY{_RESET_SUFFIX}: {msg}")
def print_list(msg: str, def print_list(
l: list[str], msg: str,
elements_per_line: typing.Optional[int] = None, l: list[str],
max_line_width: typing.Optional[int] = None, elements_per_line: typing.Optional[int] = None,
limit_to_term_size: bool = True, max_line_width: typing.Optional[int] = None,
level: int = SUMMARY): limit_to_term_size: bool = True,
level: int = SUMMARY,
):
""" """
Prints a summary message to the user along with a list of elements. Prints a summary message to the user along with a list of elements.
@@ -89,8 +92,11 @@ def print_list(msg: str,
max_line_width = 2**32 # Big enough to basically be unlimited max_line_width = 2**32 # Big enough to basically be unlimited
if limit_to_term_size: if limit_to_term_size:
max_line_width = shutil.get_terminal_size().columns - len( max_line_width = (
_SPACING) - len(_CONTINUATION_PREFIX) shutil.get_terminal_size().columns
- len(_SPACING)
- len(_CONTINUATION_PREFIX)
)
lines = [f"{l.pop(0)}"] lines = [f"{l.pop(0)}"]
index = 0 index = 0
@@ -131,10 +137,9 @@ def print_debug(msg: str):
print(f"{_DECMAN_MSG_TAG} {_GRAY_PREFIX}DEBUG{_RESET_SUFFIX}: {msg}") print(f"{_DECMAN_MSG_TAG} {_GRAY_PREFIX}DEBUG{_RESET_SUFFIX}: {msg}")
def prompt_number(msg: str, def prompt_number(
min_num: int, msg: str, min_num: int, max_num: int, default: typing.Optional[int] = None
max_num: int, ) -> int:
default: typing.Optional[int] = None) -> int:
""" """
Prompts the user for a integer. Prompts the user for a integer.
""" """
@@ -263,11 +268,12 @@ class Store:
if latest_path is None: if latest_path is None:
return None return None
assert latest_version is not None, "If latest_path is set, then latest_version is set." assert latest_version is not None, (
"If latest_path is set, then latest_version is set."
)
return (latest_version, latest_path) return (latest_version, latest_path)
def add_package_to_cache(self, package: str, version: str, def add_package_to_cache(self, package: str, version: str, path_to_built_pkg: str):
path_to_built_pkg: str):
""" """
Adds a built package to the package file cache. Tries to remove excess cached packages. Adds a built package to the package file cache. Tries to remove excess cached packages.
""" """
@@ -332,14 +338,13 @@ class Store:
d = { d = {
"source_file": self.source_file, "source_file": self.source_file,
"allow_running_source_without_prompt": "allow_running_source_without_prompt": self.allow_running_source_without_prompt,
self.allow_running_source_without_prompt,
"enabled_systemd_units": self.enabled_systemd_units, "enabled_systemd_units": self.enabled_systemd_units,
"enabled_user_systemd_units": self._enabled_user_systemd_units, "enabled_user_systemd_units": self._enabled_user_systemd_units,
"enabled_modules": self.enabled_modules, "enabled_modules": self.enabled_modules,
"created_files": self.created_files, "created_files": self.created_files,
"package_file_cache": self._package_file_cache, "package_file_cache": self._package_file_cache,
"pkgbuild_git_commits": self.pkgbuild_latest_reviewed_commits "pkgbuild_git_commits": self.pkgbuild_latest_reviewed_commits,
} }
try: try:
@@ -370,7 +375,8 @@ class Store:
store.source_file = d.get("source_file", None) store.source_file = d.get("source_file", None)
store.allow_running_source_without_prompt = d.get( store.allow_running_source_without_prompt = d.get(
"allow_running_source_without_prompt", False) "allow_running_source_without_prompt", False
)
store.enabled_systemd_units = d.get( store.enabled_systemd_units = d.get(
"enabled_systemd_units", "enabled_systemd_units",
[], [],
@@ -390,12 +396,10 @@ class Store:
return store return store
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
print_error(f"{e}") print_error(f"{e}")
raise err.UserFacingError( raise err.UserFacingError("Failed to parse decman store json.") from e
"Failed to parse decman store json.") from e
except OSError as e: except OSError as e:
print_error(f"{e}") print_error(f"{e}")
raise err.UserFacingError( raise err.UserFacingError("Failed to read saved decman store.") from e
"Failed to read saved decman store.") from e
class Source: class Source:
@@ -414,6 +418,9 @@ class Source:
files: dict[str, decman.File], files: dict[str, decman.File],
directories: dict[str, decman.Directory], directories: dict[str, decman.Directory],
modules: set[decman.Module], modules: set[decman.Module],
flatpak_packages: set[str],
flatpak_user_packages: dict[str, set[str]],
ignored_flatpak_packages: set[str],
): ):
self.pacman_packages = pacman_packages self.pacman_packages = pacman_packages
self.aur_packages = aur_packages self.aur_packages = aur_packages
@@ -424,6 +431,9 @@ class Source:
self.files = files self.files = files
self.directories = directories self.directories = directories
self.modules = modules self.modules = modules
self.flatpak_packages = flatpak_packages
self.flatpak_user_packages = flatpak_user_packages
self.ignored_flatpak_packages = ignored_flatpak_packages
def run_on_enable(self, store: Store): def run_on_enable(self, store: Store):
""" """
@@ -455,7 +465,8 @@ class Source:
""" """
for module in self.modules: for module in self.modules:
if module.enabled and module.version != store.enabled_modules.get( if module.enabled and module.version != store.enabled_modules.get(
module.name, module.version): module.name, module.version
):
module.after_version_change() module.after_version_change()
elif module.enabled and module.name not in store.enabled_modules: elif module.enabled and module.name not in store.enabled_modules:
module.after_version_change() module.after_version_change()
@@ -467,8 +478,10 @@ class Source:
""" """
created_files = [] created_files = []
def install_files(files: dict[str, decman.File], def install_files(
variables: typing.Optional[dict[str, str]] = None): files: dict[str, decman.File],
variables: typing.Optional[dict[str, str]] = None,
):
for target, file in files.items(): for target, file in files.items():
created_files.append(target) created_files.append(target)
@@ -481,19 +494,24 @@ class Source:
except OSError as e: except OSError as e:
print_error(f"{e}") print_error(f"{e}")
raise err.UserFacingError( raise err.UserFacingError(
f"Failed to install file to {target}.") from e f"Failed to install file to {target}."
) from e
def install_dirs(dirs: dict[str, decman.Directory], def install_dirs(
variables: typing.Optional[dict[str, str]] = None): dirs: dict[str, decman.Directory],
variables: typing.Optional[dict[str, str]] = None,
):
for target, directory in dirs.items(): for target, directory in dirs.items():
try: try:
print_debug(f"Installing directory to {target}.") print_debug(f"Installing directory to {target}.")
created_files.extend( created_files.extend(
directory.copy_to(target, variables, only_print)) directory.copy_to(target, variables, only_print)
)
except OSError as e: except OSError as e:
print_error(f"{e}") print_error(f"{e}")
raise err.UserFacingError( raise err.UserFacingError(
f"Failed to install directory to {target}.") from e f"Failed to install directory to {target}."
) from e
install_files(self.files) install_files(self.files)
install_dirs(self.directories) install_dirs(self.directories)
@@ -531,8 +549,7 @@ class Source:
return all_dirs return all_dirs
def files_to_remove(self, store: Store, def files_to_remove(self, store: Store, created_files: list[str]) -> list[str]:
created_files: list[str]) -> list[str]:
""" """
Returns all files that should be removed. Returns all files that should be removed.
""" """
@@ -587,8 +604,7 @@ class Source:
result[user] = entry result[user] = entry
return result return result
def packages_to_remove( def packages_to_remove(self, currently_installed_packages: list[str]) -> list[str]:
self, currently_installed_packages: list[str]) -> list[str]:
""" """
Returns all packages that should be removed. This includes pacman, aur and user packages. Returns all packages that should be removed. This includes pacman, aur and user packages.
""" """
@@ -601,7 +617,8 @@ class Source:
return result return result
def pacman_packages_to_install( def pacman_packages_to_install(
self, currently_installed_packages: list[str]) -> list[str]: self, currently_installed_packages: list[str]
) -> list[str]:
""" """
Returns all pacman packages that should be installed. Returns all pacman packages that should be installed.
""" """
@@ -614,7 +631,8 @@ class Source:
return result return result
def foreign_packages_to_install( def foreign_packages_to_install(
self, currently_installed_packages: list[str]) -> list[str]: self, currently_installed_packages: list[str]
) -> list[str]:
""" """
Returns all aur and user packages that should be installed. Returns all aur and user packages that should be installed.
""" """
@@ -626,6 +644,43 @@ class Source:
result.append(pkg) result.append(pkg)
return result return result
def flatpak_packages_to_install(
self,
currently_installed_packages: list[str],
as_user: bool = False,
which_user: str = "",
) -> list[str]:
"""
Returns all flatpak packages, that are not installed or ignored
"""
result: list[str] = []
for pkg in self._all_flatpak_packages(as_user, which_user):
if pkg in self.ignored_flatpak_packages:
continue
if pkg not in currently_installed_packages:
result.append(pkg)
return result
def flatpak_packages_to_remove(
self,
currently_installed_packages: list[str],
as_user: bool = False,
which_user: str = "",
) -> list[str]:
"""
This returns a list of flatpak app ids, that need to be removed since they are installed but not found in either the list of ignored packages,
the list of system packages or the list of user packages that need to be installed.
"""
result: list[str] = []
for package in currently_installed_packages:
if package in self.ignored_flatpak_packages:
continue
if package not in self._all_flatpak_packages(as_user, which_user):
result.append(package)
return result
def all_enabled_modules(self) -> list[tuple[str, str]]: def all_enabled_modules(self) -> list[tuple[str, str]]:
""" """
Returns all enabled modules and their versions. Returns all enabled modules and their versions.
@@ -655,6 +710,27 @@ class Source:
result.update(module.pacman_packages()) result.update(module.pacman_packages())
return result return result
def _all_flatpak_packages(
self, as_user: bool = False, which_user: str = ""
) -> set[str]:
# loop through all the user packages and save which ones are owned by the currently selected user
current_user_flatpak_packages = self.flatpak_user_packages.get(which_user, [])
result = set()
result.update(
self.flatpak_packages if not as_user else current_user_flatpak_packages
)
for module in self.modules:
if not module.enabled:
continue
result.update(
module.flatpak_packages()
if not as_user
else module.flatpak_user_packages().get(which_user, [])
)
return result
def _all_foreign_pkgs(self) -> set[str]: def _all_foreign_pkgs(self) -> set[str]:
result = set() result = set()
result.update(self.aur_packages) result.update(self.aur_packages)
@@ -680,11 +756,13 @@ class Source:
return result return result
def _all_user_units(self) -> dict[str, set[str]]: def _all_user_units(self) -> dict[str, set[str]]:
result = {} result = self.systemd_user_units
result.update(self.systemd_user_units) for module in [m for m in self.modules if m.enabled]:
for module in self.modules: module_user_units: dict[str, list[str]] = module.systemd_user_units()
if module.enabled: for user in module_user_units.keys():
result.update(module.systemd_user_units()) if user not in result:
result[user] = set()
result[user].update(module_user_units[user])
return result return result
@@ -702,11 +780,16 @@ class Pacman:
""" """
try: try:
packages = subprocess.run( packages = (
conf.commands.list_pkgs(), subprocess.run(
check=True, conf.commands.list_pkgs(),
stdout=subprocess.PIPE, check=True,
).stdout.decode().strip().split('\n') stdout=subprocess.PIPE,
)
.stdout.decode()
.strip()
.split("\n")
)
return packages return packages
except subprocess.CalledProcessError as error: except subprocess.CalledProcessError as error:
raise err.UserFacingError( raise err.UserFacingError(
@@ -720,9 +803,12 @@ class Pacman:
if dep in self._installable: if dep in self._installable:
return self._installable[dep] return self._installable[dep]
result = subprocess.run(conf.commands.is_installable(dep), result = (
check=False, subprocess.run(
capture_output=True).returncode == 0 conf.commands.is_installable(dep), check=False, capture_output=True
).returncode
== 0
)
self._installable[dep] = result self._installable[dep] = result
return result return result
@@ -732,18 +818,23 @@ class Pacman:
basically AUR packages. basically AUR packages.
""" """
try: try:
output = subprocess.run( output = (
conf.commands.list_foreign_pkgs_versioned(), subprocess.run(
check=True, conf.commands.list_foreign_pkgs_versioned(),
stdout=subprocess.PIPE).stdout.decode().strip().split('\n') check=True,
stdout=subprocess.PIPE,
)
.stdout.decode()
.strip()
.split("\n")
)
except subprocess.CalledProcessError as error: except subprocess.CalledProcessError as error:
raise err.UserFacingError( raise err.UserFacingError(
f"Failed to get foreign packages using '{error.cmd}'. Output: {error.stdout}." f"Failed to get foreign packages using '{error.cmd}'. Output: {error.stdout}."
) from error ) from error
try: try:
return [(line.split(" ")[0], line.split(" ")[1]) return [(line.split(" ")[0], line.split(" ")[1]) for line in output]
for line in output]
except IndexError as error: except IndexError as error:
raise err.UserFacingError( raise err.UserFacingError(
f"Failed to parse foreign packages from pacman output. Output: {output}" f"Failed to parse foreign packages from pacman output. Output: {output}"
@@ -757,7 +848,8 @@ class Pacman:
return return
returncode, output = echo_and_capture_command( returncode, output = echo_and_capture_command(
conf.commands.install_pkgs(packages)) conf.commands.install_pkgs(packages)
)
if returncode != 0: if returncode != 0:
raise err.UserFacingError( raise err.UserFacingError(
f"Failed to install packages using pacman. Process exited with code {returncode}." f"Failed to install packages using pacman. Process exited with code {returncode}."
@@ -766,9 +858,11 @@ class Pacman:
print_highlighted_pacman_messages(output) print_highlighted_pacman_messages(output)
try: try:
subprocess.run(conf.commands.set_as_explicitly_installed(packages), subprocess.run(
check=True, conf.commands.set_as_explicitly_installed(packages),
capture_output=conf.suppress_command_output) check=True,
capture_output=conf.suppress_command_output,
)
except subprocess.CalledProcessError as error: except subprocess.CalledProcessError as error:
raise err.UserFacingError( raise err.UserFacingError(
"Failed to set packages as explicitly installed using pacman." "Failed to set packages as explicitly installed using pacman."
@@ -781,8 +875,7 @@ class Pacman:
if not deps: if not deps:
return return
returncode, output = echo_and_capture_command( returncode, output = echo_and_capture_command(conf.commands.install_deps(deps))
conf.commands.install_deps(deps))
if returncode != 0: if returncode != 0:
raise err.UserFacingError( raise err.UserFacingError(
f"Failed to install packages as dependencies using pacman. Process exited with code {returncode}." f"Failed to install packages as dependencies using pacman. Process exited with code {returncode}."
@@ -799,7 +892,8 @@ class Pacman:
return return
returncode, output = echo_and_capture_command( returncode, output = echo_and_capture_command(
conf.commands.install_files(files)) conf.commands.install_files(files)
)
if returncode != 0: if returncode != 0:
raise err.UserFacingError( raise err.UserFacingError(
f"Failed to install package files using pacman. Process exited with code {returncode}." f"Failed to install package files using pacman. Process exited with code {returncode}."
@@ -812,7 +906,8 @@ class Pacman:
subprocess.run( subprocess.run(
conf.commands.set_as_explicitly_installed(as_explicit), conf.commands.set_as_explicitly_installed(as_explicit),
check=True, check=True,
capture_output=conf.suppress_command_output) capture_output=conf.suppress_command_output,
)
except subprocess.CalledProcessError as error: except subprocess.CalledProcessError as error:
if conf.suppress_command_output: if conf.suppress_command_output:
print_error("Output:") print_error("Output:")
@@ -840,8 +935,7 @@ class Pacman:
if not packages: if not packages:
return return
returncode, output = echo_and_capture_command( returncode, output = echo_and_capture_command(conf.commands.remove(packages))
conf.commands.remove(packages))
if returncode != 0: if returncode != 0:
raise err.UserFacingError( raise err.UserFacingError(
f"Failed to remove packages using pacman. Process exited with code {returncode}." f"Failed to remove packages using pacman. Process exited with code {returncode}."
@@ -859,7 +953,7 @@ def print_highlighted_pacman_messages(output: str):
for index, line in enumerate(lines): for index, line in enumerate(lines):
for keyword in conf.pacman_output_keywords: for keyword in conf.pacman_output_keywords:
if keyword.lower() in line.lower(): if keyword.lower() in line.lower():
print_summary(f"lines: {index}-{index+2}") print_summary(f"lines: {index}-{index + 2}")
if index >= 1: if index >= 1:
print_continuation(lines[index - 1]) print_continuation(lines[index - 1])
print_continuation(line) print_continuation(line)
@@ -877,57 +971,166 @@ def echo_and_capture_command(program: list[str]) -> tuple[int, str]:
Returns a tuple containing the return code of the program as well as all output of the program. Returns a tuple containing the return code of the program as well as all output of the program.
""" """
with subprocess.Popen(program,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT) as process:
os.set_blocking(process.stdout.fileno(), False)
output_thread = _OutputCapturingThread(process.stdout) output = ""
output_thread.start()
os.set_blocking(sys.stdin.fileno(), False) def read(fd):
nonlocal output
buffer = os.read(fd, 1024)
output += buffer.decode(encoding="utf-8")
return buffer
# Capture stdin and forward it to the process in a non-blocking manner returncode = os.waitstatus_to_exitcode(pty.spawn(program, read))
while process.poll() is None:
inp = sys.stdin.readline()
if inp: return (returncode, output)
process.stdin.write(inp.encode())
process.stdin.flush()
time.sleep(0.1)
os.set_blocking(sys.stdin.fileno(), True)
output_thread.done = True
output_thread.join()
# Capture any output that may not have been yet captured
output = output_thread.output
missing_output = process.stdout.read()
if missing_output:
output += missing_output.decode()
return (process.returncode, output)
class _OutputCapturingThread(threading.Thread): def get_user_info(username: str) -> tuple[int, int]:
info = pwd.getpwnam(username)
return (info.pw_uid, info.pw_gid)
def __init__(self, stream):
super().__init__()
self._stream = stream
self.output = ""
self.done = False
def run(self): class Flatpak:
while not self.done and not self._stream.closed: def __init__(self) -> None:
output = self._stream.read() pass
if output:
output = output.decode() def get_installed(self, as_user: bool = False, which_user: str = "") -> list[str]:
self.output += output """
print(output, end="") Return all of the installed applications. Dependencies and runtimes are exluded since they will not be explicitly installed and thus flatpak will manage them.
time.sleep(0.1) """
try:
uinfo: tuple[int, int] = (0, 0)
env = os.environ.copy()
user_env = env.copy()
user_env["HOME"] = os.path.expanduser(f"~{which_user}")
if as_user:
uinfo = get_user_info(which_user)
proc = subprocess.run(
conf.commands.list_flatpak_pkgs(as_user),
check=True,
stdout=subprocess.PIPE,
user=uinfo[0],
group=uinfo[1],
env=user_env if as_user else env,
)
packages = proc.stdout.decode().strip().split("\n")
# print(
# f"as_user: {as_user}, which_user: {which_user}, uinfo: {uinfo}, stdout: {proc.stdout.decode()}, packages: {packages}"
# )
# The header might be included. It might also not. This will make sure that it is not present.
if "Application ID" in packages:
packages.remove("Application ID")
if packages == [""]:
return []
return packages
except subprocess.CalledProcessError as error:
raise err.UserFacingError(
user_facing_msg=f"Failed to get installed flatpak packages using '{error.cmd}'. Output: {error.stdout}."
) from error
def install(
self, packages: list[str], as_user: bool = False, which_user: str = "root"
):
"""
Install the listed flatpak packages.
"""
if not packages:
return
uinfo: tuple[int, int] = (0, 0)
if as_user:
uinfo = get_user_info(which_user)
env = os.environ.copy()
user_env = env.copy()
user_env["HOME"] = os.path.expanduser(f"~{which_user}")
proc = subprocess.run(
conf.commands.install_flatpak_pkgs(packages, as_user),
check=True,
user=uinfo[0],
group=uinfo[1],
env=user_env if as_user else env,
)
if proc.returncode != 0:
raise err.UserFacingError(
f"Failed to install flatpak packages. Process exited with code {proc.returncode}."
)
def upgrade(self, as_user: bool = False, which_user: str = "root") -> None:
"""
Upgrade all flatpak packages.
"""
uinfo: tuple[int, int] = (0, 0)
if as_user:
uinfo = get_user_info(which_user)
env = os.environ.copy()
user_env = env.copy()
user_env["HOME"] = os.path.expanduser(f"~{which_user}")
proc = subprocess.run(
conf.commands.upgrade_flatpak(as_user=True),
check=True,
user=uinfo[0],
group=uinfo[1],
env=user_env if as_user else env,
)
if not proc.returncode == 0:
raise err.UserFacingError(
f"Failed to upgrade flatpak packages. Process exited with code {proc.returncode}."
)
def remove(
self, packages: list[str], as_user: bool = False, which_user: str = "root"
):
"""
Remove all the listed packages and their unused dependecies. This has to happen in two steps.
"""
if not packages:
return
uinfo: tuple[int, int] = (0, 0)
if as_user:
uinfo = get_user_info(which_user)
env = os.environ.copy()
user_env = env.copy()
user_env["HOME"] = os.path.expanduser(f"~{which_user}")
proc = subprocess.run(
conf.commands.remove_flatpak(packages, as_user),
check=True,
user=uinfo[0],
group=uinfo[1],
env=user_env if as_user else env,
)
if not proc.returncode == 0:
raise err.UserFacingError(
f"Failed to remove flatpak packages. Process exited with code {proc.returncode}."
)
proc = subprocess.run(
conf.commands.remove_unused_flatpak(as_user),
check=True,
user=uinfo[0] if as_user else 0,
group=uinfo[1] if as_user else 0,
env=user_env if as_user else env,
)
if not proc.returncode == 0:
raise err.UserFacingError(
f"Failed to remove unused flatpak packages. Process exited with code {proc.returncode}."
)
class Systemd: class Systemd:
@@ -946,12 +1149,15 @@ class Systemd:
return return
try: try:
subprocess.run(conf.commands.enable_units(units), subprocess.run(
check=True, conf.commands.enable_units(units),
capture_output=conf.suppress_command_output) check=True,
capture_output=conf.suppress_command_output,
)
except subprocess.CalledProcessError as error: except subprocess.CalledProcessError as error:
raise err.UserFacingError( raise err.UserFacingError(
f"Failed to enable systemd units: {units}") from error f"Failed to enable systemd units: {units}"
) from error
self.state.enabled_systemd_units += units self.state.enabled_systemd_units += units
def disable_units(self, units: list[str]): def disable_units(self, units: list[str]):
@@ -962,12 +1168,15 @@ class Systemd:
return return
try: try:
subprocess.run(conf.commands.disable_units(units), subprocess.run(
check=True, conf.commands.disable_units(units),
capture_output=conf.suppress_command_output) check=True,
capture_output=conf.suppress_command_output,
)
except subprocess.CalledProcessError as error: except subprocess.CalledProcessError as error:
raise err.UserFacingError( raise err.UserFacingError(
f"Failed to disable systemd units: {units}") from error f"Failed to disable systemd units: {units}"
) from error
for unit in units: for unit in units:
try: try:
self.state.enabled_systemd_units.remove(unit) self.state.enabled_systemd_units.remove(unit)
@@ -982,9 +1191,11 @@ class Systemd:
return return
try: try:
subprocess.run(conf.commands.enable_user_units(units, user), subprocess.run(
check=True, conf.commands.enable_user_units(units, user),
capture_output=conf.suppress_command_output) check=True,
capture_output=conf.suppress_command_output,
)
except subprocess.CalledProcessError as error: except subprocess.CalledProcessError as error:
raise err.UserFacingError( raise err.UserFacingError(
f"Failed to enable systemd units: {units} for {user}." f"Failed to enable systemd units: {units} for {user}."
@@ -1001,9 +1212,11 @@ class Systemd:
return return
try: try:
subprocess.run(conf.commands.disable_user_units(units, user), subprocess.run(
check=True, conf.commands.disable_user_units(units, user),
capture_output=conf.suppress_command_output) check=True,
capture_output=conf.suppress_command_output,
)
except subprocess.CalledProcessError as error: except subprocess.CalledProcessError as error:
raise err.UserFacingError( raise err.UserFacingError(
f"Failed to disable systemd units: {units} for {user}." f"Failed to disable systemd units: {units} for {user}."
+238 -159
View File
@@ -11,18 +11,18 @@ Terminology:
- all dependencies: normal dependencies and build dependencies combined - all dependencies: normal dependencies and build dependencies combined
""" """
import shutil
import subprocess
import os import os
import re import re
import shutil
import subprocess
import typing import typing
import requests import requests
import decman import decman
import decman.config as conf import decman.config as conf
import decman.lib as l
import decman.error as err import decman.error as err
import decman.lib as l
def strip_dependency(dep: str) -> str: def strip_dependency(dep: str) -> str:
@@ -58,10 +58,18 @@ class PackageInfo:
In case of AUR packages, these are fetched from AUR RPC. In case of AUR packages, these are fetched from AUR RPC.
""" """
def __init__(self, pkgname: str, pkgbase: str, version: str, def __init__(
provides: list[str], dependencies: list[str], self,
make_dependencies: list[str], check_dependencies: list[str], pkgname: str,
git_url: str, pacman: l.Pacman): pkgbase: str,
version: str,
provides: list[str],
dependencies: list[str],
make_dependencies: list[str],
check_dependencies: list[str],
git_url: str,
pacman: l.Pacman,
):
self.pkgname = pkgname self.pkgname = pkgname
self.pkgbase = pkgbase self.pkgbase = pkgbase
self.version = version self.version = version
@@ -79,22 +87,23 @@ class PackageInfo:
if pacman.is_installable(dep): if pacman.is_installable(dep):
self.pacman_dependencies.append(dep) self.pacman_dependencies.append(dep)
else: else:
self.foreign_dependencies_stripped.append( self.foreign_dependencies_stripped.append(strip_dependency(dep))
strip_dependency(dep))
for make_dep in make_dependencies: for make_dep in make_dependencies:
if pacman.is_installable(make_dep): if pacman.is_installable(make_dep):
self.pacman_make_dependencies.append(make_dep) self.pacman_make_dependencies.append(make_dep)
else: else:
self.foreign_make_dependencies_stripped.append( self.foreign_make_dependencies_stripped.append(
strip_dependency(make_dep)) strip_dependency(make_dep)
)
for check_dep in check_dependencies: for check_dep in check_dependencies:
if pacman.is_installable(check_dep): if pacman.is_installable(check_dep):
self.pacman_check_dependencies.append(check_dep) self.pacman_check_dependencies.append(check_dep)
else: else:
self.foreign_check_dependencies_stripped.append( self.foreign_check_dependencies_stripped.append(
strip_dependency(check_dep)) strip_dependency(check_dep)
)
def pkg_file_prefix(self) -> str: def pkg_file_prefix(self) -> str:
""" """
@@ -103,8 +112,9 @@ class PackageInfo:
return f"{self.pkgname}-{self.version}" return f"{self.pkgname}-{self.version}"
@staticmethod @staticmethod
def from_user_package(user_package: decman.UserPackage, def from_user_package(
pacman: l.Pacman) -> "PackageInfo": user_package: decman.UserPackage, pacman: l.Pacman
) -> "PackageInfo":
""" """
Converts a UserPackage to PackageInfo Converts a UserPackage to PackageInfo
""" """
@@ -132,8 +142,11 @@ class ForeignPackage:
def __eq__(self, value: object, /) -> bool: def __eq__(self, value: object, /) -> bool:
if isinstance(value, self.__class__): if isinstance(value, self.__class__):
return self.name == value.name \ return (
and self._all_recursive_foreign_deps == value._all_recursive_foreign_deps self.name == value.name
and self._all_recursive_foreign_deps
== value._all_recursive_foreign_deps
)
return False return False
def __hash__(self) -> int: def __hash__(self) -> int:
@@ -145,8 +158,7 @@ class ForeignPackage:
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.name}" return f"{self.name}"
def add_foreign_dependency_packages(self, def add_foreign_dependency_packages(self, package_names: typing.Iterable[str]):
package_names: typing.Iterable[str]):
""" """
Adds dependencies to the package. Adds dependencies to the package.
""" """
@@ -174,8 +186,7 @@ class DepNode:
Returns True if the given package name is in the parents of this DepNode. Returns True if the given package name is in the parents of this DepNode.
""" """
for name, parent in self.parents.items(): for name, parent in self.parents.items():
if name == pkgname or parent.is_pkgname_in_parents_recursive( if name == pkgname or parent.is_pkgname_in_parents_recursive(pkgname):
pkgname):
return True return True
return False return False
@@ -189,15 +200,15 @@ class DepGraph:
self.package_nodes: dict[str, DepNode] = {} self.package_nodes: dict[str, DepNode] = {}
self._childless_node_names = set() self._childless_node_names = set()
def add_requirement(self, child_pkgname: str, def add_requirement(self, child_pkgname: str, parent_pkgname: typing.Optional[str]):
parent_pkgname: typing.Optional[str]):
""" """
Adds a connection between two packages, creating the child package if it doesn't exist. Adds a connection between two packages, creating the child package if it doesn't exist.
The parent is the package that requires the child package. The parent is the package that requires the child package.
""" """
child_node = self.package_nodes.get( child_node = self.package_nodes.get(
child_pkgname, DepNode(ForeignPackage(child_pkgname))) child_pkgname, DepNode(ForeignPackage(child_pkgname))
)
self.package_nodes[child_pkgname] = child_node self.package_nodes[child_pkgname] = child_node
if len(child_node.children) == 0: if len(child_node.children) == 0:
@@ -212,7 +223,8 @@ class DepGraph:
raise err.UserFacingError( raise err.UserFacingError(
f"Foreign package dependency cycle detected involving '{child_pkgname}' \ f"Foreign package dependency cycle detected involving '{child_pkgname}' \
and '{parent_pkgname}'. Foreign package dependencies are also required \ and '{parent_pkgname}'. Foreign package dependencies are also required \
during package building and therefore dependency cycles cannot be handled.") during package building and therefore dependency cycles cannot be handled."
)
parent_node.children[child_pkgname] = child_node parent_node.children[child_pkgname] = child_node
child_node.parents[parent_pkgname] = parent_node child_node.parents[parent_pkgname] = parent_node
@@ -230,8 +242,7 @@ during package building and therefore dependency cycles cannot be handled.")
childless_node = self.package_nodes[childless_node_name] childless_node = self.package_nodes[childless_node_name]
for parent in childless_node.parents.values(): for parent in childless_node.parents.values():
new_deps = childless_node.pkg.get_all_recursive_foreign_dep_pkgs( new_deps = childless_node.pkg.get_all_recursive_foreign_dep_pkgs()
)
new_deps.add(childless_node.pkg.name) new_deps.add(childless_node.pkg.name)
parent.pkg.add_foreign_dependency_packages(new_deps) parent.pkg.add_foreign_dependency_packages(new_deps)
del parent.children[childless_node_name] del parent.children[childless_node_name]
@@ -254,6 +265,7 @@ class ExtendedPackageSearch:
self._pacman = pacman self._pacman = pacman
self._package_info_cache: dict[str, PackageInfo] = {} self._package_info_cache: dict[str, PackageInfo] = {}
self._dep_provider_cache: dict[str, PackageInfo] = {} self._dep_provider_cache: dict[str, PackageInfo] = {}
self._known_providers_cache: dict[str, list[str]] = {}
self._user_packages: list[PackageInfo] = [] self._user_packages: list[PackageInfo] = []
def add_user_pkg(self, user_pkg: PackageInfo): def add_user_pkg(self, user_pkg: PackageInfo):
@@ -261,6 +273,15 @@ class ExtendedPackageSearch:
Adds the given package to user packages. Adds the given package to user packages.
""" """
self._user_packages.append(user_pkg) self._user_packages.append(user_pkg)
self._cache_pkg(user_pkg)
def _cache_pkg(self, pkg: PackageInfo):
for provided_pkg in pkg.provides:
self._known_providers_cache[provided_pkg] = self._known_providers_cache.get(
provided_pkg, []
)
self._known_providers_cache[provided_pkg].append(pkg.pkgname)
self._package_info_cache[pkg.pkgname] = pkg
def try_caching_packages(self, packages: list[str]): def try_caching_packages(self, packages: list[str]):
""" """
@@ -270,8 +291,7 @@ class ExtendedPackageSearch:
times, because then those methods don't have to make new AUR RPC requests. times, because then those methods don't have to make new AUR RPC requests.
""" """
packages = list( packages = list(filter(lambda p: p not in self._package_info_cache, packages))
filter(lambda p: p not in self._package_info_cache, packages))
if len(packages) == 0: if len(packages) == 0:
return return
@@ -281,8 +301,7 @@ class ExtendedPackageSearch:
max_pkgs_per_request = 200 max_pkgs_per_request = 200
while packages: while packages:
to_request = map(lambda p: f"arg[]={p}", to_request = map(lambda p: f"arg[]={p}", packages[:max_pkgs_per_request])
packages[:max_pkgs_per_request])
packages = packages[max_pkgs_per_request:] packages = packages[max_pkgs_per_request:]
url = f"https://aur.archlinux.org/rpc/v5/info?{'&'.join(to_request)}" url = f"https://aur.archlinux.org/rpc/v5/info?{'&'.join(to_request)}"
@@ -293,8 +312,7 @@ class ExtendedPackageSearch:
d = request.json() d = request.json()
if d["type"] == "error": if d["type"] == "error":
raise err.UserFacingError( raise err.UserFacingError(f"AUR RPC returned error: {d['error']}")
f"AUR RPC returned error: {d['error']}")
for result in d["results"]: for result in d["results"]:
pkgname = result["Name"] pkgname = result["Name"]
@@ -304,9 +322,8 @@ class ExtendedPackageSearch:
for user_package in self._user_packages: for user_package in self._user_packages:
if user_package.pkgname == pkgname: if user_package.pkgname == pkgname:
l.print_debug( l.print_debug(f"'{pkgname}' found in user packages.")
f"'{pkgname}' found in user packages.") self._cache_pkg(user_package)
self._package_info_cache[pkgname] = user_package
break break
else: # if not in user_packages then: else: # if not in user_packages then:
info = PackageInfo( info = PackageInfo(
@@ -317,10 +334,10 @@ class ExtendedPackageSearch:
make_dependencies=result.get("MakeDepends", []), make_dependencies=result.get("MakeDepends", []),
check_dependencies=result.get("CheckDepends", []), check_dependencies=result.get("CheckDepends", []),
provides=result.get("Provides", []), provides=result.get("Provides", []),
git_url= git_url=f"https://aur.archlinux.org/{result['PackageBase']}.git",
f"https://aur.archlinux.org/{result['PackageBase']}.git", pacman=self._pacman,
pacman=self._pacman) )
self._package_info_cache[pkgname] = info self._cache_pkg(info)
l.print_debug("Request completed.") l.print_debug("Request completed.")
except (requests.RequestException, KeyError) as e: except (requests.RequestException, KeyError) as e:
@@ -345,7 +362,7 @@ class ExtendedPackageSearch:
for user_package in self._user_packages: for user_package in self._user_packages:
if user_package.pkgname == package: if user_package.pkgname == package:
l.print_debug(f"'{package}' found in user packages.") l.print_debug(f"'{package}' found in user packages.")
self._package_info_cache[package] = user_package self._cache_pkg(user_package)
return user_package return user_package
url = f"https://aur.archlinux.org/rpc/v5/info/{package}" url = f"https://aur.archlinux.org/rpc/v5/info/{package}"
@@ -355,8 +372,7 @@ class ExtendedPackageSearch:
d = request.json() d = request.json()
if d["type"] == "error": if d["type"] == "error":
raise err.UserFacingError( raise err.UserFacingError(f"AUR RPC returned error: {d['error']}")
f"AUR RPC returned error: {d['error']}")
if d["resultcount"] == 0: if d["resultcount"] == 0:
l.print_debug(f"'{package}' not found.") l.print_debug(f"'{package}' not found.")
@@ -373,11 +389,11 @@ class ExtendedPackageSearch:
make_dependencies=result.get("MakeDepends", []), make_dependencies=result.get("MakeDepends", []),
check_dependencies=result.get("CheckDepends", []), check_dependencies=result.get("CheckDepends", []),
provides=result.get("Provides", []), provides=result.get("Provides", []),
git_url= git_url=f"https://aur.archlinux.org/{result['PackageBase']}.git",
f"https://aur.archlinux.org/{result['PackageBase']}.git", pacman=self._pacman,
pacman=self._pacman) )
self._package_info_cache[package] = info self._cache_pkg(info)
return info return info
except (requests.RequestException, KeyError) as e: except (requests.RequestException, KeyError) as e:
@@ -386,8 +402,7 @@ class ExtendedPackageSearch:
f"Failed to fetch package information for {package} from AUR RPC." f"Failed to fetch package information for {package} from AUR RPC."
) from e ) from e
def find_provider( def find_provider(self, stripped_dependency: str) -> typing.Optional[PackageInfo]:
self, stripped_dependency: str) -> typing.Optional[PackageInfo]:
""" """
Finds a provider for a dependency. Finds a provider for a dependency.
@@ -410,25 +425,31 @@ class ExtendedPackageSearch:
l.print_debug("No exact name matches found. Finding providers.") l.print_debug("No exact name matches found. Finding providers.")
user_pkg_results = [] known_pkg_results = self._known_providers_cache.get(stripped_dependency, [])
for user_package in self._user_packages: for user_package in self._user_packages:
if stripped_dependency in user_package.provides: if (
user_pkg_results.append(user_package.pkgname) stripped_dependency in user_package.provides
and stripped_dependency not in known_pkg_results
):
known_pkg_results.append(user_package.pkgname)
if len(user_pkg_results) == 1: if len(known_pkg_results) == 1:
pkg = self.get_package_info(user_pkg_results[0]) pkg = self.get_package_info(known_pkg_results[0])
assert pkg is not None assert pkg is not None
l.print_debug( l.print_debug(
f"Single provider for '{stripped_dependency}' found in user packages: '{pkg}'." f"Single provider for '{stripped_dependency}' found in known packages: '{pkg}'."
) )
self._dep_provider_cache[stripped_dependency] = pkg self._dep_provider_cache[stripped_dependency] = pkg
return pkg return pkg
if len(user_pkg_results) > 1: if len(known_pkg_results) > 1:
return self._choose_provider(stripped_dependency, user_pkg_results, return self._choose_provider(
"user packages") stripped_dependency, known_pkg_results, "user packages"
)
url = f"https://aur.archlinux.org/rpc/v5/search/{stripped_dependency}?by=provides" url = (
f"https://aur.archlinux.org/rpc/v5/search/{stripped_dependency}?by=provides"
)
l.print_debug( l.print_debug(
f"Requesting providers for '{stripped_dependency}' from AUR. URL = {url}" f"Requesting providers for '{stripped_dependency}' from AUR. URL = {url}"
) )
@@ -437,8 +458,7 @@ class ExtendedPackageSearch:
d = request.json() d = request.json()
if d["type"] == "error": if d["type"] == "error":
raise err.UserFacingError( raise err.UserFacingError(f"AUR RPC returned error: {d['error']}")
f"AUR RPC returned error: {d['error']}")
if d["resultcount"] == 0: if d["resultcount"] == 0:
l.print_debug(f"'{stripped_dependency}' not found.") l.print_debug(f"'{stripped_dependency}' not found.")
@@ -461,8 +481,9 @@ class ExtendedPackageSearch:
f"Failed to search for {stripped_dependency} from AUR RPC." f"Failed to search for {stripped_dependency} from AUR RPC."
) from e ) from e
def _choose_provider(self, dep: str, possible_providers: list[str], def _choose_provider(
where: str) -> typing.Optional[PackageInfo]: self, dep: str, possible_providers: list[str], where: str
) -> typing.Optional[PackageInfo]:
min_selection = 1 min_selection = 1
max_selection = len(possible_providers) max_selection = len(possible_providers)
l.print_summary( l.print_summary(
@@ -478,7 +499,8 @@ class ExtendedPackageSearch:
f"Select a provider [{min_selection}-{max_selection}] (default: {min_selection}): ", f"Select a provider [{min_selection}-{max_selection}] (default: {min_selection}): ",
min_selection, min_selection,
max_selection, max_selection,
default=min_selection) default=min_selection,
)
info = self.get_package_info(possible_providers[selection - 1]) info = self.get_package_info(possible_providers[selection - 1])
if info is not None: if info is not None:
@@ -541,16 +563,17 @@ class ForeignPackageManager:
Class for dealing with foreign packages. Class for dealing with foreign packages.
""" """
def __init__(self, store: l.Store, pacman: l.Pacman, def __init__(self, store: l.Store, pacman: l.Pacman, search: ExtendedPackageSearch):
search: ExtendedPackageSearch):
self._store = store self._store = store
self._pacman = pacman self._pacman = pacman
self._search = search self._search = search
def upgrade(self, def upgrade(
upgrade_devel: bool = False, self,
force: bool = False, upgrade_devel: bool = False,
ignored_pkgs: typing.Optional[set[str]] = None): force: bool = False,
ignored_pkgs: typing.Optional[set[str]] = None,
):
""" """
Upgrades all foreign packages. Upgrades all foreign packages.
""" """
@@ -561,11 +584,9 @@ class ForeignPackageManager:
all_foreign_pkgs = self._pacman.get_versioned_foreign_packages() all_foreign_pkgs = self._pacman.get_versioned_foreign_packages()
all_explicit_pkgs = set(self._pacman.get_installed()) all_explicit_pkgs = set(self._pacman.get_installed())
l.print_debug( l.print_debug(f"Foreign packages to check for upgrades: {all_foreign_pkgs}")
f"Foreign packages to check for upgrades: {all_foreign_pkgs}")
self._search.try_caching_packages( self._search.try_caching_packages(list(map(lambda p: p[0], all_foreign_pkgs)))
list(map(lambda p: p[0], all_foreign_pkgs)))
as_explicit = [] as_explicit = []
as_deps = [] as_deps = []
@@ -579,8 +600,7 @@ class ForeignPackageManager:
f"Failed to find '{pkg}' from AUR or user provided packages." f"Failed to find '{pkg}' from AUR or user provided packages."
) )
if self.should_upgrade_package(pkg, ver, info.version, if self.should_upgrade_package(pkg, ver, info.version, upgrade_devel):
upgrade_devel):
if pkg in all_explicit_pkgs: if pkg in all_explicit_pkgs:
as_explicit.append(pkg) as_explicit.append(pkg)
else: else:
@@ -592,10 +612,12 @@ class ForeignPackageManager:
self.install(as_explicit, as_deps, force) self.install(as_explicit, as_deps, force)
def install(self, def install(
foreign_pkgs: list[str], self,
foreign_dep_pkgs: typing.Optional[list[str]] = None, foreign_pkgs: list[str],
force: bool = False): foreign_dep_pkgs: typing.Optional[list[str]] = None,
force: bool = False,
):
""" """
Installs the given foreign packages and their dependencies (both pacman/AUR). Installs the given foreign packages and their dependencies (both pacman/AUR).
""" """
@@ -607,39 +629,44 @@ class ForeignPackageManager:
return return
resolved_dependencies = self.resolve_dependencies( resolved_dependencies = self.resolve_dependencies(
foreign_pkgs, foreign_dep_pkgs) foreign_pkgs, foreign_dep_pkgs
)
l.print_list( l.print_list(
"The following foreign packages will be installed explicitly:", "The following foreign packages will be installed explicitly:",
list(resolved_dependencies.foreign_pkgs), list(resolved_dependencies.foreign_pkgs),
level=l.SUMMARY) level=l.SUMMARY,
)
l.print_list( l.print_list(
"The following foreign packages will be installed as dependencies:", "The following foreign packages will be installed as dependencies:",
list(resolved_dependencies.foreign_dep_pkgs), list(resolved_dependencies.foreign_dep_pkgs),
level=l.SUMMARY) level=l.SUMMARY,
)
l.print_list( l.print_list(
"The following foreign packages will be built in order to install other packages. They will not be installed:", "The following foreign packages will be built in order to install other packages. They will not be installed:",
list(resolved_dependencies.foreign_build_dep_pkgs), list(resolved_dependencies.foreign_build_dep_pkgs),
level=l.SUMMARY) level=l.SUMMARY,
)
if not l.prompt_confirm("Proceed?", default=True): if not l.prompt_confirm("Proceed?", default=True):
raise err.UserFacingError("Installing aborted.") raise err.UserFacingError("Installing aborted.")
l.print_summary("Installing foreign package dependencies from pacman.") l.print_summary("Installing foreign package dependencies from pacman.")
self._pacman.install_dependencies( self._pacman.install_dependencies(list(resolved_dependencies.pacman_deps))
list(resolved_dependencies.pacman_deps))
try: try:
with PackageBuilder(self._search, self._store, with PackageBuilder(
resolved_dependencies) as builder: self._search, self._store, resolved_dependencies
) as builder:
while resolved_dependencies.build_order: while resolved_dependencies.build_order:
to_build = resolved_dependencies.build_order.pop(0) to_build = resolved_dependencies.build_order.pop(0)
pkgbase = resolved_dependencies.get_pkgbase(to_build) pkgbase = resolved_dependencies.get_pkgbase(to_build)
package_names = resolved_dependencies.get_pkgs_with_common_pkgbase( package_names = resolved_dependencies.get_pkgs_with_common_pkgbase(
to_build) to_build
)
packages = [ packages = [
resolved_dependencies.packages[pkgname] resolved_dependencies.packages[pkgname]
@@ -663,16 +690,17 @@ class ForeignPackageManager:
if package_files_to_install or force: if package_files_to_install or force:
l.print_summary("Installing foreign packages.") l.print_summary("Installing foreign packages.")
self._pacman.install_files(package_files_to_install, self._pacman.install_files(
as_explicit=list( package_files_to_install,
resolved_dependencies.foreign_pkgs)) as_explicit=list(resolved_dependencies.foreign_pkgs),
)
else: else:
l.print_summary("No packages to install.") l.print_summary("No packages to install.")
def resolve_dependencies( def resolve_dependencies(
self, self,
foreign_pkgs: list[str], foreign_pkgs: list[str],
foreign_dep_pkgs: typing.Optional[list[str]] = None foreign_dep_pkgs: typing.Optional[list[str]] = None,
) -> ResolvedDependencies: ) -> ResolvedDependencies:
""" """
Resolves foreign dependencies of foreign packages. Resolves foreign dependencies of foreign packages.
@@ -690,7 +718,7 @@ class ForeignPackageManager:
graph = DepGraph() graph = DepGraph()
for name in (foreign_pkgs + foreign_dep_pkgs): for name in foreign_pkgs + foreign_dep_pkgs:
graph.add_requirement(name, None) graph.add_requirement(name, None)
seen_packages = set(foreign_pkgs + foreign_dep_pkgs) seen_packages = set(foreign_pkgs + foreign_dep_pkgs)
@@ -709,8 +737,7 @@ class ForeignPackageManager:
add_to.add(dep_info.pkgname) add_to.add(dep_info.pkgname)
l.print_debug( l.print_debug(f"Adding dependency {dep_info.pkgname} to package {pkgname}.")
f"Adding dependency {dep_info.pkgname} to package {pkgname}.")
graph.add_requirement(dep_info.pkgname, pkgname) graph.add_requirement(dep_info.pkgname, pkgname)
if dep_info.pkgname not in seen_packages: if dep_info.pkgname not in seen_packages:
to_process.append(dep_info.pkgname) to_process.append(dep_info.pkgname)
@@ -728,10 +755,14 @@ class ForeignPackageManager:
result.pacman_deps.update(info.pacman_dependencies) result.pacman_deps.update(info.pacman_dependencies)
result.add_pkgbase_info(pkgname, info.pkgbase) result.add_pkgbase_info(pkgname, info.pkgbase)
build_deps = info.foreign_make_dependencies_stripped + info.foreign_check_dependencies_stripped build_deps = (
info.foreign_make_dependencies_stripped
+ info.foreign_check_dependencies_stripped
)
self._search.try_caching_packages( self._search.try_caching_packages(
info.foreign_dependencies_stripped + build_deps) info.foreign_dependencies_stripped + build_deps
)
for depname in info.foreign_dependencies_stripped: for depname in info.foreign_dependencies_stripped:
process_dep(pkgname, depname, result.foreign_dep_pkgs) process_dep(pkgname, depname, result.foreign_dep_pkgs)
@@ -758,26 +789,29 @@ class ForeignPackageManager:
return result return result
def should_upgrade_package(self, def should_upgrade_package(
package: str, self,
installed_version: str, package: str,
fetched_version: str, installed_version: str,
upgrade_devel=False) -> bool: fetched_version: str,
upgrade_devel=False,
) -> bool:
""" """
Returns True if a package should be upgraded. Returns True if a package should be upgraded.
""" """
if upgrade_devel and is_devel(package): if upgrade_devel and is_devel(package):
l.print_debug( l.print_debug(f"Package {package} is devel package. It should be upgraded.")
f"Package {package} is devel package. It should be upgraded.")
return True return True
try: try:
result = int( result = int(
subprocess.run(conf.commands.compare_versions( subprocess.run(
installed_version, fetched_version), conf.commands.compare_versions(installed_version, fetched_version),
check=True, check=True,
stdout=subprocess.PIPE).stdout.decode()) stdout=subprocess.PIPE,
).stdout.decode()
)
should_upgrade = result < 0 should_upgrade = result < 0
l.print_debug( l.print_debug(
f"Installed version is: {installed_version}. Available version is {fetched_version}. Should upgrade: {should_upgrade}" f"Installed version is: {installed_version}. Available version is {fetched_version}. Should upgrade: {should_upgrade}"
@@ -786,7 +820,8 @@ class ForeignPackageManager:
except (ValueError, subprocess.CalledProcessError) as error: except (ValueError, subprocess.CalledProcessError) as error:
l.print_error(f"{error}") l.print_error(f"{error}")
raise err.UserFacingError( raise err.UserFacingError(
"Failed to compare versions using vercmp.") from error "Failed to compare versions using vercmp."
) from error
class PackageBuilder: class PackageBuilder:
@@ -796,8 +831,12 @@ class PackageBuilder:
always_included_packages = ["base-devel", "git"] always_included_packages = ["base-devel", "git"]
def __init__(self, search: ExtendedPackageSearch, store: l.Store, def __init__(
resolved_deps: ResolvedDependencies): self,
search: ExtendedPackageSearch,
store: l.Store,
resolved_deps: ResolvedDependencies,
):
self._search = search self._search = search
self._store = store self._store = store
self._resolved_deps = resolved_deps self._resolved_deps = resolved_deps
@@ -850,7 +889,8 @@ class PackageBuilder:
os.chdir(pkgbuild_dir) os.chdir(pkgbuild_dir)
git_url_info = self._search.get_package_info( git_url_info = self._search.get_package_info(
self._resolved_deps.get_some_pkgname(pkgbase)) self._resolved_deps.get_some_pkgname(pkgbase)
)
# Because all dependencies and packages should be resolved during the creation # Because all dependencies and packages should be resolved during the creation
# of ResolvedDependencies. git_url should not be None. # of ResolvedDependencies. git_url should not be None.
@@ -869,16 +909,16 @@ class PackageBuilder:
mkarchroot_env_vars = os.environ.copy() mkarchroot_env_vars = os.environ.copy()
try: try:
del mkarchroot_env_vars["GNUPGHOME"] del mkarchroot_env_vars["GNUPGHOME"]
l.print_debug( l.print_debug("Removed GNUPGHOME variable from mkarchroot environment.")
"Removed GNUPGHOME variable from mkarchroot environment.")
except KeyError: except KeyError:
pass pass
subprocess.run(conf.commands.make_chroot(self.chroot_dir, subprocess.run(
list(self._pkgs_in_chroot)), conf.commands.make_chroot(self.chroot_dir, list(self._pkgs_in_chroot)),
env=mkarchroot_env_vars, env=mkarchroot_env_vars,
check=True, check=True,
capture_output=conf.suppress_command_output) capture_output=conf.suppress_command_output,
)
def remove_build_environment(self): def remove_build_environment(self):
""" """
@@ -886,8 +926,9 @@ class PackageBuilder:
""" """
shutil.rmtree(conf.build_dir) shutil.rmtree(conf.build_dir)
def build_packages(self, package_base: str, packages: list[ForeignPackage], def build_packages(
force: bool): self, package_base: str, packages: list[ForeignPackage], force: bool
):
""" """
Builds package(s) with the same package base. Builds package(s) with the same package base.
@@ -906,8 +947,7 @@ class PackageBuilder:
l.print_info(f"Building '{' '.join(package_names)}'.") l.print_info(f"Building '{' '.join(package_names)}'.")
chroot_new_pacman_pkgs, chroot_pkg_files = self._get_chroot_packages( chroot_new_pacman_pkgs, chroot_pkg_files = self._get_chroot_packages(packages)
packages)
pkgbuild_dir = self.pkgbase_dir_map[package_base] pkgbuild_dir = self.pkgbase_dir_map[package_base]
os.chdir(pkgbuild_dir) os.chdir(pkgbuild_dir)
@@ -918,19 +958,24 @@ class PackageBuilder:
l.print_info("Installing build dependencies to chroot.") l.print_info("Installing build dependencies to chroot.")
subprocess.run(conf.commands.install_chroot_packages( subprocess.run(
self.chroot_dir, conf.commands.install_chroot_packages(
chroot_new_pacman_pkgs + PackageBuilder.always_included_packages), self.chroot_dir,
check=True, chroot_new_pacman_pkgs + PackageBuilder.always_included_packages,
capture_output=conf.suppress_command_output) ),
check=True,
capture_output=conf.suppress_command_output,
)
l.print_info("Making package.") l.print_info("Making package.")
subprocess.run(conf.commands.make_chroot_pkg(self.chroot_wd_dir, subprocess.run(
conf.makepkg_user, conf.commands.make_chroot_pkg(
chroot_pkg_files), self.chroot_wd_dir, conf.makepkg_user, chroot_pkg_files
check=True, ),
capture_output=conf.quiet_output) check=True,
capture_output=conf.quiet_output,
)
for pkgname in package_names: for pkgname in package_names:
file = self._find_pkgfile(pkgname, pkgbuild_dir) file = self._find_pkgfile(pkgname, pkgbuild_dir)
@@ -952,16 +997,25 @@ class PackageBuilder:
l.print_info("Removing build dependencies from chroot.") l.print_info("Removing build dependencies from chroot.")
# FIX: If installed packages are virtual packages, removing them wont succeed.
if len(chroot_new_pacman_pkgs) != 0: if len(chroot_new_pacman_pkgs) != 0:
to_remove = [] to_remove = []
for p in chroot_new_pacman_pkgs: for p in chroot_new_pacman_pkgs:
if p not in self._pkgs_in_chroot: if p not in self._pkgs_in_chroot:
to_remove.append(strip_dependency(p)) real_pkgname = (
subprocess.run(conf.commands.remove_chroot_packages( subprocess.run(
self.chroot_dir, to_remove), conf.commands.resolve_real_name(self.chroot_dir, p),
check=True, check=True,
capture_output=conf.suppress_command_output) stdout=subprocess.PIPE,
)
.stdout.decode()
.strip()
)
to_remove.append(real_pkgname)
subprocess.run(
conf.commands.remove_chroot_packages(self.chroot_dir, to_remove),
check=True,
capture_output=conf.suppress_command_output,
)
l.print_info(f"Finished building: '{' '.join(package_names)}'.") l.print_info(f"Finished building: '{' '.join(package_names)}'.")
@@ -984,7 +1038,7 @@ class PackageBuilder:
return True return True
def _get_chroot_packages( def _get_chroot_packages(
self, pkgs_to_build: list[ForeignPackage] self, pkgs_to_build: list[ForeignPackage]
) -> tuple[list[str], list[str]]: ) -> tuple[list[str], list[str]]:
""" """
Returns a tuple of pacman build dependencies and built foreign pkgs files that are needed Returns a tuple of pacman build dependencies and built foreign pkgs files that are needed
@@ -1030,8 +1084,10 @@ class PackageBuilder:
for foreign_pkg in chroot_foreign_pkgs: for foreign_pkg in chroot_foreign_pkgs:
entry = self._store.get_package(foreign_pkg) entry = self._store.get_package(foreign_pkg)
assert entry is not None, "Build order determines that the dependencies are built \ assert entry is not None, (
"Build order determines that the dependencies are built \
before and thus are found in the cache." before and thus are found in the cache."
)
_, file = entry _, file = entry
@@ -1070,32 +1126,55 @@ before and thus are found in the cache."
The user is prompted to review the PKGBUILD and confirm if the package should be built. The user is prompted to review the PKGBUILD and confirm if the package should be built.
""" """
try: try:
subprocess.run(conf.commands.git_clone(git_url, "."), subprocess.run(
check=True, conf.commands.git_clone(git_url, "."),
capture_output=conf.suppress_command_output) check=True,
capture_output=conf.suppress_command_output,
)
if l.prompt_confirm(f"Review PKGBUILD or show diff for {pkgbase}?", if l.prompt_confirm(
default=True): f"Review PKGBUILD or show diff for {pkgbase}?", default=True
latest_reviewed_commit = self._store.pkgbuild_latest_reviewed_commits.get( ):
pkgbase) latest_reviewed_commit = (
if latest_reviewed_commit is None: self._store.pkgbuild_latest_reviewed_commits.get(pkgbase)
)
git_commit_ids = (
subprocess.run(
conf.commands.git_log_commit_ids(),
check=True,
stdout=subprocess.PIPE,
)
.stdout.decode()
.strip()
.split("\n")
)
if (
latest_reviewed_commit is None
or latest_reviewed_commit not in git_commit_ids
):
for file in os.scandir("."): for file in os.scandir("."):
if file.is_file() and not file.name.startswith("."): if file.is_file() and not file.name.startswith("."):
subprocess.run(conf.commands.review_file( subprocess.run(
file.path), conf.commands.review_file(file.path), check=True
check=True) )
else: else:
subprocess.run( subprocess.run(
conf.commands.git_diff(latest_reviewed_commit), conf.commands.git_diff(latest_reviewed_commit), check=True
check=True) )
if l.prompt_confirm("Build this package?", default=True): if l.prompt_confirm("Build this package?", default=True):
commit_id = subprocess.run( commit_id = (
conf.commands.git_get_commit_id(), subprocess.run(
check=True, conf.commands.git_get_commit_id(),
capture_output=True).stdout.decode().strip() check=True,
self._store.pkgbuild_latest_reviewed_commits[ capture_output=True,
pkgbase] = commit_id )
.stdout.decode()
.strip()
)
self._store.pkgbuild_latest_reviewed_commits[pkgbase] = commit_id
else: else:
raise err.UserFacingError("Building aborted.") raise err.UserFacingError("Building aborted.")
+61 -24
View File
@@ -1,12 +1,13 @@
# pylint: disable=missing-module-docstring,missing-class-docstring,missing-function-docstring # pylint: disable=missing-module-docstring,missing-class-docstring,missing-function-docstring
from typing import override
import unittest import unittest
from decman import Module, UserPackage
from decman.lib import Source, Store from decman.lib import Source, Store
from decman import UserPackage, Module
class ExistingTestModule(Module): class ExistingTestModule(Module):
def __init__(self): def __init__(self):
self.on_enable_executed = False self.on_enable_executed = False
self.on_disable_executed = False self.on_disable_executed = False
@@ -28,7 +29,6 @@ class ExistingTestModule(Module):
class ExistingChangedVersionTestModule(Module): class ExistingChangedVersionTestModule(Module):
def __init__(self): def __init__(self):
self.on_enable_executed = False self.on_enable_executed = False
self.on_disable_executed = False self.on_disable_executed = False
@@ -50,7 +50,6 @@ class ExistingChangedVersionTestModule(Module):
class EnabledTestModule(Module): class EnabledTestModule(Module):
def __init__(self): def __init__(self):
self.on_enable_executed = False self.on_enable_executed = False
self.on_disable_executed = False self.on_disable_executed = False
@@ -76,9 +75,11 @@ class EnabledTestModule(Module):
def systemd_user_units(self) -> dict[str, list[str]]: def systemd_user_units(self) -> dict[str, list[str]]:
return {"muser": ["M_u1.service"]} return {"muser": ["M_u1.service"]}
def flatpak_packages(self) -> list[str]:
return ["M_f1", "M_f2"]
class DisabledTestModule(Module): class DisabledTestModule(Module):
def __init__(self): def __init__(self):
self.on_enable_executed = False self.on_enable_executed = False
self.on_disable_executed = False self.on_disable_executed = False
@@ -106,7 +107,6 @@ class DisabledTestModule(Module):
class TestSource(unittest.TestCase): class TestSource(unittest.TestCase):
def setUp(self): def setUp(self):
self.disabled_module = DisabledTestModule() self.disabled_module = DisabledTestModule()
self.enabled_module = EnabledTestModule() self.enabled_module = EnabledTestModule()
@@ -133,7 +133,7 @@ class TestSource(unittest.TestCase):
version="1", version="1",
dependencies=["d2"], dependencies=["d2"],
git_url="/am/url/yes", git_url="/am/url/yes",
) ),
}, },
ignored_packages={"i1", "i2"}, ignored_packages={"i1", "i2"},
systemd_units={"1.service", "2.timer"}, systemd_units={"1.service", "2.timer"},
@@ -141,11 +141,13 @@ class TestSource(unittest.TestCase):
modules=modules, modules=modules,
files={}, files={},
directories={}, directories={},
flatpak_packages={"f1", "f2", "f3"},
flatpak_user_packages={"fu1", "fu2", "fu3"},
ignored_flatpak_packages={"i1", "i2"},
) )
store = Store() store = Store()
store.enabled_systemd_units.extend( store.enabled_systemd_units.extend(["1.service", "3.service", "M_1.service"])
["1.service", "3.service", "M_1.service"])
store.add_enabled_user_systemd_unit("user", "u1.service") store.add_enabled_user_systemd_unit("user", "u1.service")
store.add_enabled_user_systemd_unit("user", "u3.service") store.add_enabled_user_systemd_unit("user", "u3.service")
store.enabled_modules = { store.enabled_modules = {
@@ -174,16 +176,19 @@ class TestSource(unittest.TestCase):
self.currently_installed_packages = currently_installed_packages self.currently_installed_packages = currently_installed_packages
def test_all_enabled_modules(self): def test_all_enabled_modules(self):
enabled_modules = [("Enabled", "1"), ("Existing", "1"), enabled_modules = [
("ExistingChanged", "2")] ("Enabled", "1"),
self.assertCountEqual(self.source.all_enabled_modules(), ("Existing", "1"),
enabled_modules) ("ExistingChanged", "2"),
]
self.assertCountEqual(self.source.all_enabled_modules(), enabled_modules)
def test_files_to_remove(self): def test_files_to_remove(self):
created_files = ["/test/file1", "/test/file4"] created_files = ["/test/file1", "/test/file4"]
self.assertCountEqual( self.assertCountEqual(
self.source.files_to_remove(self.store, created_files), self.source.files_to_remove(self.store, created_files),
["/test/file2", "/test/file3"]) ["/test/file2", "/test/file3"],
)
def test_after_update_executed(self): def test_after_update_executed(self):
self.source.run_after_update() self.source.run_after_update()
@@ -197,8 +202,7 @@ class TestSource(unittest.TestCase):
self.source.run_after_version_change(self.store) self.source.run_after_version_change(self.store)
self.assertTrue(self.enabled_module.after_version_change_executed) self.assertTrue(self.enabled_module.after_version_change_executed)
self.assertTrue( self.assertTrue(self.existing_module_changed.after_version_change_executed)
self.existing_module_changed.after_version_change_executed)
self.assertFalse(self.existing_module.after_version_change_executed) self.assertFalse(self.existing_module.after_version_change_executed)
self.assertFalse(self.disabled_module.after_version_change_executed) self.assertFalse(self.disabled_module.after_version_change_executed)
@@ -233,10 +237,7 @@ class TestSource(unittest.TestCase):
def test_user_units_to_enable(self): def test_user_units_to_enable(self):
self.assertDictEqual( self.assertDictEqual(
self.source.user_units_to_enable(self.store), self.source.user_units_to_enable(self.store),
{ {"user": ["u2.timer"], "muser": ["M_u1.service"]},
"user": ["u2.timer"],
"muser": ["M_u1.service"]
},
) )
def test_user_units_to_disable(self): def test_user_units_to_disable(self):
@@ -247,15 +248,13 @@ class TestSource(unittest.TestCase):
def test_pacman_packages_to_install(self): def test_pacman_packages_to_install(self):
self.assertCountEqual( self.assertCountEqual(
self.source.pacman_packages_to_install( self.source.pacman_packages_to_install(self.currently_installed_packages),
self.currently_installed_packages),
["p3", "M_p1", "M_p2"], ["p3", "M_p1", "M_p2"],
) )
def test_foreign_packages_to_install(self): def test_foreign_packages_to_install(self):
self.assertCountEqual( self.assertCountEqual(
self.source.foreign_packages_to_install( self.source.foreign_packages_to_install(self.currently_installed_packages),
self.currently_installed_packages),
["A1", "U2"], ["A1", "U2"],
) )
@@ -264,3 +263,41 @@ class TestSource(unittest.TestCase):
self.source.packages_to_remove(self.currently_installed_packages), self.source.packages_to_remove(self.currently_installed_packages),
["p4", "A4", "M_A1", "M_A2"], ["p4", "A4", "M_A1", "M_A2"],
) )
class TestModuleUserServices(unittest.TestCase):
class ModuleWithUserServiceOne(Module):
def __init__(self):
super().__init__("one", True, "0")
def systemd_user_units(self) -> dict[str, list[str]]:
return {"user": ["foo.service"]}
class ModuleWithUserServiceTwo(Module):
def __init__(self):
super().__init__("two", True, "0")
def systemd_user_units(self) -> dict[str, list[str]]:
return {"user": ["bar.service"]}
def setUp(self) -> None:
self.source = Source(
pacman_packages=set(),
aur_packages=set(),
user_packages=set(),
ignored_packages=set(),
systemd_units=set(),
systemd_user_units={},
files={},
directories={},
modules={self.ModuleWithUserServiceOne(), self.ModuleWithUserServiceTwo()},
flatpak_packages=set(),
flatpak_user_packages=set(),
ignored_flatpak_packages=set(),
)
self.store = Store()
def test_user_units_to_enable(self):
result = self.source.user_units_to_enable(self.store)
self.assertEqual(len(result), 1)
self.assertCountEqual(result["user"], ["foo.service", "bar.service"])