Better methods for running commands

This commit is contained in:
Kivi Kaitaniemi
2025-12-12 23:08:30 +02:00
parent 9da024c8bd
commit 2b1bbdb884
31 changed files with 1693 additions and 3990 deletions
+2 -2
View File
@@ -12,10 +12,10 @@ sudo uv run decman
## Testing ## Testing
Run unit tests: Run all unit tests (`-s` disables output capturing):
```sh ```sh
uv run python -m unittest uv run pytest -s
``` ```
## Formatting ## Formatting
+1
View File
@@ -17,6 +17,7 @@ decman = "decman.app:main"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"ruff>=0.14.9", "ruff>=0.14.9",
"pytest>=8.4.2",
] ]
[build-system] [build-system]
+95 -411
View File
@@ -1,425 +1,109 @@
""" import shlex
Module for writing system configurations for decman.
"""
import grp
import os
import pwd
import shutil
import subprocess
import typing import typing
import decman.error import decman.core.command as command
import decman.core.output as output
class UserRaisedError(Exception): def prg(
""" cmd: list[str],
Error raised by running source user: typing.Optional[str] = None,
env_overrides: typing.Optional[dict[str, str]] = None,
mimic_login: bool = False,
pty: bool = True,
check: bool = True,
) -> str:
""" """
Shortcut for running a command. Returns the output of that command.
def __init__(self, message) -> None: Args:
super().__init__(message) cmd:
Command to execute.
user:
User name to run the command as. If set, the command is executed after dropping
privileges to this user.
env_overrides:
Environment variables to override or add for the command execution.
These values are merged on top of the current process environment.
mimic_login:
If mimic_login is True, will set the following environment variables according to the
given user's passwd file details. This only happens when user is set.
- HOME
- USER
- LOGNAME
- SHELL
pty:
If True, run the command inside a pseudo-terminal (PTY). This enables interactive
behavior and terminal-dependent programs. If False, run the command without a PTY
using standard subprocess execution.
check:
If True, raise CommandFailedError when the command exits with a non-zero status.
If False, print a warning when encountering a non-zero exit code.
"""
if pty:
result = command.pty_run(
cmd, user=user, env_overrides=env_overrides, mimic_login=mimic_login
)
else:
result = command.run(cmd, user=user, env_overrides=env_overrides, mimic_login=mimic_login)
if check:
# This raises an error if the command failed exiting the function early
result = command.check_run_result(cmd, result)
code, command_output = result
if code != 0:
output.print_warning(f"Command '{shlex.join(cmd)}' returned with an exit code {code}.")
return command_output
def sh( def sh(
sh_cmd: str, sh_cmd: str,
user: typing.Optional[str] = None, user: typing.Optional[str] = None,
env_overrides: typing.Optional[dict[str, str]] = None, env_overrides: typing.Optional[dict[str, str]] = None,
): mimic_login: bool = False,
pty: bool = True,
check: bool = True,
) -> str:
""" """
Shortcut for running a shell command. Shortcut for running a shell command. Returns the output of that command.
Args:
sh_cmd:
Shell command to execute. The command is passed to the system shell /bin/sh.
user:
User name to run the command as. If set, the command is executed after dropping
privileges to this user.
env_overrides:
Environment variables to override or add for the command execution.
These values are merged on top of the current process environment.
mimic_login:
If mimic_login is True, will set the following environment variables according to the
given user's passwd file details. This only happens when user is set.
- HOME
- USER
- LOGNAME
- SHELL
pty:
If True, run the command inside a pseudo-terminal (PTY). This enables interactive
behavior and terminal-dependent programs. If False, run the command without a PTY
using standard subprocess execution.
check:
If True, raise CommandFailedError when the command exits with a non-zero status.
If False, print a warning when encountering a non-zero exit code.
""" """
if env_overrides is None: cmd = ["/bin/sh", "-c", sh_cmd]
env_overrides = {} return prg(
cmd, user=user, env_overrides=env_overrides, mimic_login=mimic_login, pty=pty, check=check
env = os.environ.copy() )
for var, val in env_overrides.items():
env[var] = val
if user is None:
try:
subprocess.run(sh_cmd, shell=True, check=True, env=env)
except subprocess.CalledProcessError as e:
raise decman.error.UserFacingError(
f"Running user defined shell command '{sh_cmd}' failed."
) from e
else:
try:
uid = pwd.getpwnam(user).pw_uid
gid = pwd.getpwnam(user).pw_gid
except KeyError as e:
raise decman.error.UserFacingError(
f"Running user defined shell command failed because the user {user} doesn't exist."
) from e
with subprocess.Popen(sh_cmd, shell=True, group=gid, user=uid, env=env) as process:
if process.wait() != 0:
raise decman.error.UserFacingError(
f"Running user shell command '{sh_cmd}' as {user} failed."
)
def prg(
command: list[str],
user: typing.Optional[str] = None,
env_overrides: typing.Optional[dict[str, str]] = None,
):
"""
Shortcut for running a program.
"""
if env_overrides is None:
env_overrides = {}
env = os.environ.copy()
for var, val in env_overrides.items():
env[var] = val
if user is None:
try:
subprocess.run(command, check=True, env=env)
except subprocess.CalledProcessError as e:
raise decman.error.UserFacingError(
f"Running user defined program '{command}' failed."
) from e
else:
try:
uid = pwd.getpwnam(user).pw_uid
gid = pwd.getpwnam(user).pw_gid
except KeyError as e:
raise decman.error.UserFacingError(
f"Running user defined program failed because the user {user} doesn't exist."
) from e
with subprocess.Popen(command, group=gid, user=uid, env=env) as process:
if process.wait() != 0:
raise decman.error.UserFacingError(
f"Running user program '{command}' as {user} failed."
)
class File:
"""
A simple file that gets copied to the target.
"""
def __init__(
self,
source_file: typing.Optional[str] = None,
content: typing.Optional[str] = None,
bin_file: bool = False,
encoding: str = "utf-8",
owner: typing.Optional[str] = None,
group: typing.Optional[str] = None,
permissions: int = 0o644,
):
if source_file is None and content is None:
raise ValueError("Both source_file and content cannot be None.")
if source_file is not None and content is not None:
raise ValueError("Both source_file and content cannot be set.")
self.source_file = source_file
self.content = content
self.permissions = permissions
self.bin_file = bin_file
self.encoding = encoding
self.uid = None
self.gid = None
if owner is not None:
self.uid = pwd.getpwnam(owner).pw_uid
self.gid = pwd.getpwnam(owner).pw_gid
if group is not None:
self.gid = grp.getgrnam(group).gr_gid
def copy_to(self, target: str, variables: typing.Optional[dict[str, str]] = None):
"""
Copies the contents of this file to the target file.
"""
if variables is None:
variables = {}
target_directory = os.path.dirname(target)
def create_missing_dirs(dirct: str, uid: typing.Optional[int], gid: typing.Optional[int]):
if not os.path.isdir(dirct):
parent_dir = os.path.dirname(dirct)
if not os.path.isdir(parent_dir):
create_missing_dirs(parent_dir, uid, gid)
os.mkdir(dirct)
if uid is not None:
assert gid is not None, "If uid is set, then gid is set."
os.chown(dirct, uid, gid)
create_missing_dirs(target_directory, self.uid, self.gid)
self._write_content(target, variables)
if self.uid is not None:
assert self.gid is not None, "If uid is set, then gid is set."
os.chown(target, self.uid, self.gid)
os.chmod(target, self.permissions)
def _write_content(self, target: str, variables: dict[str, str]):
if self.source_file is not None and (self.bin_file or len(variables) == 0):
shutil.copy(self.source_file, target)
elif self.bin_file and self.content is not None:
with open(target, "wb") as file:
file.write(self.content.encode(encoding=self.encoding))
elif self.source_file is not None:
with open(self.source_file, "rt", encoding=self.encoding) as src:
content = src.read()
for var, value in variables.items():
content = content.replace(var, value)
with open(target, "wt", encoding=self.encoding) as file:
file.write(content)
else:
assert self.content is not None, "Content should be set since source_file was not set."
content = self.content
for var, value in variables.items():
content = content.replace(var, value)
with open(target, "wt", encoding=self.encoding) as file:
file.write(content)
class Directory:
"""
Contents of this directory will be copied to the target.
"""
def __init__(
self,
source_directory: str,
bin_files: bool = False,
encoding: str = "utf-8",
owner: typing.Optional[str] = None,
group: typing.Optional[str] = None,
permissions: int = 0o644,
):
self.source_directory = source_directory
self.bin_files = bin_files
self.encoding = encoding
self.permissions = permissions
self.owner = owner
self.group = group
self.uid = None
self.gid = None
if owner is not None:
self.uid = pwd.getpwnam(owner).pw_uid
self.gid = pwd.getpwnam(owner).pw_gid
if group is not None:
self.gid = grp.getgrnam(group).gr_gid
def copy_to(
self,
target_directory: str,
variables: typing.Optional[dict[str, str]] = None,
only_print: bool = False,
) -> list[str]:
"""
Copies the files in this directory to the target directory.
Returns all created files.
"""
created = []
original_wd = os.getcwd()
try:
os.chdir(self.source_directory)
for src_dir, _, src_files in os.walk("."):
for src_file in src_files:
src_path = os.path.join(src_dir, src_file)
file = File(
source_file=src_path,
bin_file=self.bin_files,
encoding=self.encoding,
owner=self.owner,
group=self.group,
permissions=self.permissions,
)
target = os.path.normpath(os.path.join(target_directory, src_path))
created.append(target)
if not only_print:
file.copy_to(target, variables)
finally:
os.chdir(original_wd)
return created
class UserPackage:
"""
Defines a custom package.
"""
def __init__(
self,
pkgname: str,
version: str,
dependencies: list[str],
git_url: str,
pkgbase: typing.Optional[str] = None,
provides: typing.Optional[list[str]] = None,
make_dependencies: typing.Optional[list[str]] = None,
check_dependencies: typing.Optional[list[str]] = None,
):
if pkgbase is None:
pkgbase = pkgname
if provides is None:
provides = []
if make_dependencies is None:
make_dependencies = []
if check_dependencies is None:
check_dependencies = []
self.pkgname = pkgname
self.pkgbase = pkgbase
self.version = version
self.provides = provides
self.dependencies = dependencies
self.make_dependencies = make_dependencies
self.check_dependencies = check_dependencies
self.git_url = git_url
def __hash__(self) -> int:
return self.pkgname.__hash__()
def __eq__(self, value: object, /) -> bool:
if isinstance(value, self.__class__):
return value.pkgname == self.pkgname
return False
class Module:
"""
Collection of connected packages, services and files.
Inherit this class to create your own modules.
"""
def __init__(self, name: str, enabled: bool, version: str):
self.name = name
self.enabled = enabled
self.version = version
def on_enable(self):
"""
Override this method to run python code when this module gets enabled.
"""
def on_disable(self):
"""
Override this method to run python code when this module gets disabled.
Note! If this module is simply removed, the code will not exacute. Instead set enabled to
False.
"""
def after_update(self):
"""
Override this method to run python code after updating the system. If this module is
disabled, this will not run.
"""
def after_version_change(self):
"""
Override this method to run python code after the version of this module has changed.
"""
def files(self) -> dict[str, File]:
"""
Override this method to return files that should be installed as a part of this module.
"""
return {}
def directories(self) -> dict[str, Directory]:
"""
Override this method to return directories that should be installed as a part of this module.
"""
return {}
def file_variables(self) -> dict[str, str]:
"""
Override this method to return variables that should replaced with a new value inside
this module's text files.
"""
return {}
def pacman_packages(self) -> list[str]:
"""
Override this method to return pacman packages that should be installed as a part of this
Module.
"""
return []
def user_packages(self) -> list[UserPackage]:
"""
Override this method to return user packages that should be installed as a part of this
Module.
"""
return []
def aur_packages(self) -> list[str]:
"""
Override this method to return AUR packages that should be installed as a part of this
Module.
"""
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]:
"""
Override this method to return systemd units that should be enabled as a part of this
Module.
"""
return []
def systemd_user_units(self) -> dict[str, list[str]]:
"""
Override this method to return systemd user units that should be enabled as a part of this
Module.
"""
return {}
def __hash__(self) -> int:
return self.name.__hash__()
def __eq__(self, value: object, /) -> bool:
if isinstance(value, self.__class__):
return value.name == self.name
return False
packages: list[str] = []
aur_packages: list[str] = []
user_packages: list[UserPackage] = []
ignored_packages: list[str] = []
enabled_systemd_units: list[str] = []
enabled_systemd_user_units: dict[str, list[str]] = {}
files: dict[str, File] = {}
directories: dict[str, Directory] = {}
modules: list[Module] = []
flatpak_packages: list[str] = []
flatpak_user_packages: dict[str, list[str]] = {}
ignored_flatpak_packages: list[str] = []
-3
View File
@@ -1,3 +0,0 @@
import decman.app
decman.app.main()
-485
View File
@@ -1,485 +0,0 @@
# pyright: reportUnusedCallResult=false
"""
Module containing the CLI Application.
"""
import argparse
import os
import pwd
import shutil
import sys
import traceback
import decman
import decman.config as conf
import decman.error as err
import decman.lib as l
from decman.lib import fpm
def main():
"""
Main entry for the CLI app
"""
sys.pycache_prefix = os.path.join(conf.pkg_cache_dir, "python/")
parser = argparse.ArgumentParser(
prog="decman",
description="Declarative package & configuration manager for Arch Linux",
epilog="See more help at: https://github.com/kiviktnm/decman",
)
parser.add_argument("--source", action="store", help="python file containing configuration")
parser.add_argument(
"--print",
"--dry-run",
action="store_true",
default=False,
help="print what would happen as a result of running decman",
)
parser.add_argument("--debug", action="store_true", default=False, help="show debug output")
parser.add_argument(
"--no-packages",
action="store_true",
default=False,
help="don't upgrade any packages (including foreign packages)",
)
parser.add_argument(
"--no-foreign-packages",
action="store_true",
default=False,
help="don't upgrade foreign packages",
)
parser.add_argument(
"--no-flatpaks",
action="store_true",
default=False,
help="don't upgrade flatpak packages",
)
parser.add_argument(
"--no-files", action="store_true", default=False, help="don't install any files"
)
parser.add_argument(
"--no-systemd-units",
action="store_true",
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(
"--force-build",
action="store_true",
default=False,
help="force building of packages that are already cached",
)
args = parser.parse_args()
if not _is_root():
l.print_error("Not running as root. Please run decman as root.")
sys.exit(1)
original_wd = os.getcwd()
try:
store = l.Store.restore()
except err.UserFacingError as error:
l.print_error(error.user_facing_msg)
for line in traceback.format_exc().splitlines():
l.print_debug(line)
sys.exit(1)
errored = False
try:
opts = _set_up(store, args)
# Override debug_output if cli option is used
if args.debug:
conf.debug_output = True
conf.suppress_command_output = False
# When print cli option is used, show info output
if args.print:
conf.quiet_output = False
Core(store, opts).run()
except err.UserFacingError as error:
l.print_error(error.user_facing_msg)
for line in traceback.format_exc().splitlines():
l.print_debug(line)
errored = True
except decman.UserRaisedError as user_error:
l.print_error(f"Error encountered while running the source: {user_error}")
errored = True
# Save even when an error has occurred, since this avoids repeating steps like building pkgs.
try:
store.save()
except err.UserFacingError as error:
l.print_error(error.user_facing_msg)
for line in traceback.format_exc().splitlines():
l.print_debug(line)
errored = True
os.chdir(original_wd)
if errored:
sys.exit(2)
def _set_up(store: l.Store, args):
source = store.source_file
source_changed = False
if args.source is not None:
source = args.source
source_changed = True
if source is None:
l.print_error(
"Source was not specified. Please specify a source with the '--source' argument."
)
l.print_info("Decman will remember the previously specified source.")
sys.exit(1)
if source_changed or not store.allow_running_source_without_prompt:
l.print_warning(f"Decman will run the file '{source}' as root!")
l.print_warning(
"Only proceed if you trust the file completely. The file can also import other files."
)
if not l.prompt_confirm("Proceed?", default=False):
sys.exit(1)
if l.prompt_confirm("Remember this choice?", default=False):
store.allow_running_source_without_prompt = True
source_path = os.path.abspath(source)
source_dir = os.path.dirname(source_path)
store.source_file = source_path
try:
with open(source_path, "rt", encoding="utf-8") as file:
content = file.read()
except OSError as e:
raise err.UserFacingError(f"Failed to read source file '{store.source_file}'.") from e
os.chdir(source_dir)
sys.path.append(".")
exec(content)
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:
"""
Contains the main logic of decman.
"""
def __init__(self, store: l.Store, 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.source = _resolve_source()
self.pacman = l.Pacman()
self.flatpak = l.Flatpak()
self.systemctl = l.Systemd(store)
self.fpkg_search = fpm.ExtendedPackageSearch(self.pacman)
for upkg in self.source.all_user_pkgs():
self.fpkg_search.add_user_pkg(fpm.PackageInfo.from_user_package(upkg, self.pacman))
self.fpm = fpm.ForeignPackageManager(store, self.pacman, self.fpkg_search)
def run(self):
"""
Run the main logic of decman.
"""
if self.update_units:
self._disable_units()
if self.update_files:
self._create_and_remove_files()
if self.update_packages:
self._remove_pkgs()
self._upgrade_pkgs()
self._install_pkgs()
if self.update_units:
self._enable_units()
if self.run_commands:
self._run_modules()
all_enabled_modules = {}
for mod, version in self.source.all_enabled_modules():
all_enabled_modules[mod] = version
# Enabled modules are really only stored for commands,
# so they can be set only when the commands were exacuted.
self.store.enabled_modules = all_enabled_modules
def _disable_units(self):
to_disable = self.source.units_to_disable(self.store)
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:
self.systemctl.disable_units(to_disable)
user_units_to_disable = self.source.user_units_to_disable(self.store)
for user, units in user_units_to_disable.items():
l.print_list(f"Disabling systemd units for {user}:", units)
if not self.only_print:
self.systemctl.disable_user_units(units, user)
def _remove_pkgs(self):
"""
Remove pacman and flatpak packages
"""
# pacman
currently_installed = self.pacman.get_installed()
to_remove = self.source.packages_to_remove(currently_installed)
currently_installed_flatpak = self.flatpak.get_installed()
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):
"""
Upgrade pacman, fpm and flatpak packages
"""
# flatpak + fpm
l.print_summary("Upgrading packages.")
if self.only_print:
return
self.pacman.upgrade()
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):
"""
Installs all pacman, fpm, and flatpak packages.
"""
# pacman + fpm
currently_installed = self.pacman.get_installed()
to_install_pacman = self.source.pacman_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)
# fpm prints a summary so no need to print it twice
if self.only_print:
l.print_list("Installing foreign packages:", to_install_fpm)
if conf.enable_flatpak and self.update_flatpaks:
l.print_list("Installing flatpak packages:", to_install_flatpak)
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):
l.print_summary("Installing files.")
all_created = self.source.create_all_files(self.only_print)
to_remove = self.source.files_to_remove(self.store, all_created)
l.print_list("Ensured files are up to date:", all_created, elements_per_line=1)
l.print_list("Removing files:", to_remove, elements_per_line=1)
if self.only_print:
return
for file in to_remove:
try:
os.remove(file)
except OSError as e:
l.print_error(f"{e}")
l.print_warning(f"Failed to remove file: {file}")
self.store.created_files = all_created
def _enable_units(self):
to_enable = self.source.units_to_enable(self.store)
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:
self.systemctl.enable_units(to_enable)
user_units_to_enable = self.source.user_units_to_enable(self.store)
for user, units in user_units_to_enable.items():
l.print_list(f"Enabling systemd units for {user}:", units)
if not self.only_print:
self.systemctl.enable_user_units(units, user)
def _run_modules(self):
l.print_summary("Running on enable hooks.")
if not self.only_print:
self.source.run_on_enable(self.store)
l.print_summary("Running after version change hooks.")
if not self.only_print:
self.source.run_after_version_change(self.store)
l.print_summary("Running on disable hooks.")
if not self.only_print:
self.source.run_on_disable(self.store)
l.print_summary("Running after update hooks.")
if not self.only_print:
self.source.run_after_update()
def _resolve_source() -> l.Source:
enabled_systemd_user_units = {}
for user, units in decman.enabled_systemd_user_units.items():
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(
pacman_packages=set(decman.packages),
aur_packages=set(decman.aur_packages),
user_packages=set(decman.user_packages),
ignored_packages=set(decman.ignored_packages),
systemd_units=set(decman.enabled_systemd_units),
systemd_user_units=enabled_systemd_user_units,
files=decman.files,
directories=decman.directories,
modules=set(decman.modules),
flatpak_packages=set(decman.flatpak_packages),
flatpak_user_packages=flatpak_user_packages,
ignored_flatpak_packages=set(decman.ignored_flatpak_packages),
)
def _is_root() -> bool:
return os.geteuid() == 0
+1 -271
View File
@@ -20,276 +20,6 @@ To change the defalts, create a new child class of the Commands-class and set th
variable to an instance of your class. Look in the example directory for an example. variable to an instance of your class. Look in the example directory for an example.
""" """
import typing
class Commands:
"""
Default commands.
"""
def list_pkgs(self) -> list[str]:
"""
Running this command outputs a newline seperated list of explicitly installed packages.
"""
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]:
"""
Running this command outputs a newline seperated list of installed packages and their
versions that are not from pacman repositories.
"""
return ["pacman", "-Qm", "--color=never"]
def install_pkgs(self, pkgs: list[str]) -> list[str]:
"""
Running this command installs the given packages from pacman repositories.
"""
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]:
"""
Running this command installs the given packages files.
"""
return ["pacman", "-U", "--color=always", "--asdeps"] + pkg_files
def set_as_explicitly_installed(self, pkgs: list[str]) -> list[str]:
"""
Running this command installs sets the given as explicitly installed.
"""
return ["pacman", "-D", "--color=always", "--asexplicit"] + pkgs
def install_deps(self, deps: list[str]) -> list[str]:
"""
Running this command installs the given packages from pacman repositories.
The packages are installed as dependencies.
"""
return ["pacman", "-S", "--color=always", "--needed", "--asdeps"] + deps
def is_installable(self, pkg: str) -> list[str]:
"""
This command exits with code 0 when a package is installable from pacman repositories.
"""
return ["pacman", "-Sddp", pkg]
def upgrade(self) -> list[str]:
"""
Running this command upgrades all pacman packages.
"""
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]:
"""
Running this command removes the given packages and their dependencies
(that aren't required by other packages).
"""
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]:
"""
Running this command enables the given systemd units.
"""
return ["systemctl", "enable"] + units
def disable_units(self, units: list[str]) -> list[str]:
"""
Running this command disables the given systemd units.
"""
return ["systemctl", "disable"] + units
def enable_user_units(self, units: list[str], user: str) -> list[str]:
"""
Running this command enables the given systemd units for the user.
"""
return ["systemctl", "--user", "-M", f"{user}@", "enable"] + units
def disable_user_units(self, units: list[str], user: str) -> list[str]:
"""
Running this command disables the given systemd units for the user.
"""
return ["systemctl", "--user", "-M", f"{user}@", "disable"] + units
def compare_versions(self, installed_version: str, new_version: str) -> list[str]:
"""
Running this command outputs -1 when the installed version is older than the new version.
"""
return ["vercmp", installed_version, new_version]
def git_clone(self, repo: str, dest: str) -> list[str]:
"""
Running this command clones a git repository to the the given destination.
"""
return ["git", "clone", repo, dest]
def git_diff(self, from_commit: str) -> list[str]:
"""
Running this command outputs the difference between the given commit and
the current state of the repository.
"""
return ["git", "diff", from_commit]
def git_get_commit_id(self) -> list[str]:
"""
Running this command outputs the current commit id.
"""
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]:
"""
Running this command outputs a file for the user to see.
"""
return ["less", file]
def make_chroot(self, chroot_dir: str, with_pkgs: list[str]) -> list[str]:
"""
Running this command creates a new arch chroot to the chroot directory and installs the
given packages there.
"""
return ["mkarchroot", chroot_dir] + with_pkgs
def install_chroot_packages(self, chroot_dir: str, packages: list[str]):
"""
Running this command installs the given packages to the given chroot.
"""
return [
"arch-nspawn",
chroot_dir,
"pacman",
"-S",
"--needed",
"--noconfirm",
] + 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]):
"""
Running this command removes the given packages from the given chroot.
"""
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]:
"""
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
in the chroot before the package is created.
"""
makechrootpkg_cmd = ["makechrootpkg", "-c", "-r", chroot_wd_dir, "-U", user]
for pkgfile in pkgfiles_to_install:
makechrootpkg_cmd += ["-I", pkgfile]
return makechrootpkg_cmd
commands: Commands = Commands()
debug_output: bool = False debug_output: bool = False
quiet_output: bool = False quiet_output: bool = False
suppress_command_output: bool = True color_output: bool = True
valid_pkgexts: list[str] = [
".pkg.tar",
".pkg.tar.gz",
".pkg.tar.bz2",
".pkg.tar.xz",
".pkg.tar.zst",
".pkg.tar.lzo",
".pkg.tar.lrz",
".pkg.tar.lz4",
".pkg.tar.lz",
".pkg.tar.Z",
]
pacman_output_keywords: list[str] = [
"pacsave",
"pacnew",
# These cause too many false positives IMO
# "warning",
# "error",
# "note",
]
print_pacman_output_highlights: bool = True
makepkg_user: str = "nobody"
build_dir: str = "/tmp/decman/build"
pkg_cache_dir: str = "/var/cache/decman"
aur_rpc_timeout: typing.Optional[int] = 30
enable_fpm: bool = True
enable_flatpak: bool = False
number_of_packages_stored_in_cache: int = 3
View File
+244
View File
@@ -0,0 +1,244 @@
"""
Module for running external commands.
"""
import errno
import fcntl
import os
import pty
import pwd
import select
import shutil
import struct
import subprocess
import sys
import termios
import tty
import typing
import decman.core.error as errors
def get_user_info(user: str) -> tuple[int, int]:
"""
Returns UID and GID of the given user.
If the user doesn't exist, raises UserNotFoundError.
"""
info = _get_passwd(user)
return info.pw_uid, info.pw_gid
def pty_run(
command: list[str],
user: None | str = None,
env_overrides: None | dict[str, str] = None,
mimic_login: bool = False,
) -> tuple[int, str]:
"""
Runs a given command with the given arguments in a pseudo TTY. The command can be ran as
the given user and environment variables can be overridden manually.
If mimic_login is True, will set the following environment variables according to the given
user's passwd file details. This only happens when user is set.
- HOME
- USER
- LOGNAME
- SHELL
If the given command is empty, returns (0, "").
Returns the return code of the command and the output as a string.
If the user doesn't exist, raises UserNotFoundError.
If forking the process fails or stdin is not a TTY, raises OSError.
"""
if not command:
return 0, ""
if not sys.stdin.isatty():
raise OSError(errno.ENOTTY, "Stdin is not a TTY.")
env = _build_env(user=user, env_overrides=env_overrides, mimic_login=mimic_login)
pid, master_fd = pty.fork()
if pid == 0:
_exec_in_child(command, env, user)
return _run_parent(master_fd, pid)
def run(
command: list[str],
user: None | str = None,
env_overrides: None | dict[str, str] = None,
mimic_login: bool = False,
) -> tuple[int, str]:
"""
Runs a given command with the given arguments. The command can be ran as the given user and
environment variables can be overridden manually.
If mimic_login is True, will set the following environment variables according to the given
user's passwd file details. This only happens when user is set.
- HOME
- USER
- LOGNAME
- SHELL
If the given command is empty, returns (0, "").
Returns the return code of the command and the output as a string.
If the user doesn't exist, raises UserNotFoundError.
"""
if not command:
return 0, ""
env = _build_env(user=user, env_overrides=env_overrides, mimic_login=mimic_login)
uid, gid = None, None
if user:
uid, gid = get_user_info(user)
try:
process = subprocess.Popen(
command, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, user=uid, group=gid
)
stdout, _ = process.communicate()
except OSError as error:
# Mirror PTY behavior: "<cmd>: <error>\n" and errno-based exit code
msg = error.strerror or str(error)
output = f"{command[0]}: {msg}\n"
code = error.errno if error.errno and error.errno < 128 else 127
return code, output
return process.returncode, stdout.decode("utf-8", errors="replace")
def check_run_result(command: list[str], result: tuple[int, str]) -> tuple[int, str]:
"""
Validates the result of a command execution.
If the command exited with a non-zero return code, raises CommandFailedError
containing the original command and its captured output.
Otherwise, returns the result unchanged.
"""
code, output = result
if code != 0:
raise errors.CommandFailedError(command, output)
return code, output
def _build_env(
user: None | str,
env_overrides: None | dict[str, str],
mimic_login: bool,
) -> dict[str, str]:
env = os.environ.copy()
if mimic_login and user:
pw = _get_passwd(user)
env.update(
{
"HOME": pw.pw_dir,
"USER": pw.pw_name,
"LOGNAME": pw.pw_name,
"SHELL": pw.pw_shell,
}
)
if env_overrides:
env.update(env_overrides)
return env
def _exec_in_child(command: list[str], env: dict[str, str], user: None | str) -> typing.NoReturn:
try:
if user:
uid, gid = get_user_info(user=user)
os.setgid(gid)
os.setuid(uid)
os.execve(command[0], command, env)
except OSError as error:
try:
os.write(2, f"{command[0]}: {error.strerror}\n".encode())
except OSError:
# Not much can be done, if outputting the failure state fails
pass
code = error.errno if (error.errno and error.errno < 128) else 127
os._exit(code)
def _run_parent(master_fd: int, pid: int) -> tuple[int, str]:
stdin_fd = sys.stdin.fileno()
stdout_fd = sys.stdout.fileno()
# Put stdin into raw mode and save previous termios attributes.
old_tattr = termios.tcgetattr(stdin_fd)
tty.setraw(stdin_fd)
# Set PTY window size to match the current terminal size.
# We accept that resizing the real terminal causes issues here, it doesn't need to be handeled
rows, columns = shutil.get_terminal_size()
winsz = struct.pack("HHHH", rows, columns, 0, 0)
fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsz)
try:
output_bytes = _relay_pty(master_fd, stdin_fd, stdout_fd)
finally:
# Restore stdin termios attributes.
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_tattr)
os.close(master_fd)
_, status = os.waitpid(pid, 0)
exitcode = os.waitstatus_to_exitcode(status)
output = output_bytes.decode("utf-8", errors="replace").replace("\r\n", "\n")
return exitcode, output
def _relay_pty(master_fd: int, stdin_fd: int, stdout_fd: int) -> bytes:
"""
Drive interactive I/O between stdin/stdout and the PTY, capturing output.
"""
output_chunks: list[bytes] = []
while True:
# Wait until process or stdin has data
rlist, _, _ = select.select([master_fd, stdin_fd], [], [])
# Capture and echo child process
if master_fd in rlist:
try:
data = os.read(master_fd, 1024)
except OSError:
# Child process probably exited, EOF
break
output_chunks.append(data)
try:
os.write(stdout_fd, data)
except OSError:
# stdout closed, ignore
pass
# Forward stdin
if stdin_fd in rlist:
try:
data = os.read(stdin_fd, 1024)
os.write(master_fd, data)
except OSError:
# Either stdin EOF -> no data to pass
# or child died -> wait for master_fd to handle
pass
return b"".join(output_chunks)
def _get_passwd(user: str) -> pwd.struct_passwd:
try:
return pwd.getpwnam(user)
except KeyError as error:
raise errors.UserNotFoundError(user) from error
+43
View File
@@ -0,0 +1,43 @@
"""
Module for decman errors.
"""
class UserNotFoundError(Exception):
"""
Raised when a specified user cannot be found in the system.
Attributes:
user (str): The user that caused the exception.
"""
def __init__(self, user: str) -> None:
self.user = user
super().__init__(f"The user '{user}' doesn't exist.")
class GroupNotFoundError(Exception):
"""
Raised when a specified group cannot be found in the system.
Attributes:
group (str): The group that caused the exception.
"""
def __init__(self, group: str) -> None:
self.group = group
super().__init__(f"The group '{group}' doesn't exist.")
class CommandFailedError(Exception):
"""
Raised when running a command failed.
Attributes:
command (list[str]): The command that caused the exception.
"""
def __init__(self, command: list[str], output: str) -> None:
self.command = command
self.output = output
super().__init__(f"Running a command '{' '.join(command)}' failed. Output: '{output}'.")
+352
View File
@@ -0,0 +1,352 @@
import grp
import os
import shutil
import typing
import decman.core.command as command
import decman.core.error as errors
class File:
"""
Declarative file specification describing how a file should be materialized at a target path.
Exactly one of ``source_file`` or ``content`` must be provided.
The file can be created by copying an existing source file or by writing provided content. For
text files, optional variable substitution is applied at copy time. Binary files are copied or
written verbatim and never undergo substitution.
Ownership, permissions, and parent directories are enforced on creation. Missing parent
directories are created recursively and assigned the same ownership as the file when specified.
Parameters
----------
source_file:
Path to an existing file to copy from. Mutually exclusive with ``content``.
content:
In-memory file contents to write. Mutually exclusive with ``source_file``.
bin_file:
If ``True``, treat the file as binary. Disables variable substitution and writes bytes
verbatim.
encoding:
Text encoding used when reading or writing non-binary files.
owner:
System user name to own the file and created parent directories.
group:
System group name to own the file and created parent directories.
permissions:
File mode applied to the target file (e.g. ``0o644``).
Raises
------
ValueError
If both ``source_file`` and ``content`` are ``None`` or if both are set.
UserNotFoundError
If ``owner`` does not exist on the system.
GroupNotFoundError
If ``group`` does not exist on the system.
Notes
-----
Variable substitution is a simple string replacement where each key in ``variables`` is
replaced by its corresponding value. No escaping or templating semantics are applied.
"""
def __init__(
self,
source_file: typing.Optional[str] = None,
content: typing.Optional[str] = None,
bin_file: bool = False,
encoding: str = "utf-8",
owner: typing.Optional[str] = None,
group: typing.Optional[str] = None,
permissions: int = 0o644,
):
if source_file is None and content is None:
raise ValueError("Both source_file and content cannot be None.")
if source_file is not None and content is not None:
raise ValueError("Both source_file and content cannot be set.")
self.source_file = source_file
self.content = content
self.permissions = permissions
self.bin_file = bin_file
self.encoding = encoding
self.uid = None
self.gid = None
if owner is not None:
self.uid, self.gid = command.get_user_info(owner)
if group is not None:
try:
self.gid = grp.getgrnam(group).gr_gid
except KeyError as error:
raise errors.GroupNotFoundError(group) from error
def copy_to(self, target: str, variables: typing.Optional[dict[str, str]] = None) -> bool:
"""
Copies the contents of this file to the target file if they differ.
Parameters
----------
target:
Path to the target file on disk.
variables:
Optional mapping of literal substrings to replace in the text content before writing.
Ignored for binary files and when ``bin_file`` is True.
Returns
-------
bool
True if the file contents were created or modified.
False if the existing file already contained the desired contents.
Raises
------
OSError
If directory creation, file I/O, permission changes, or ownership changes fail
(e.g. permission denied, missing parent path components, I/O errors).
FileNotFoundError
If ``source_file`` is set and does not exist.
UnicodeDecodeError
If a text file cannot be decoded using ``encoding``.
UnicodeEncodeError
If text content cannot be encoded using ``encoding``.
"""
if variables is None:
variables = {}
target_directory = os.path.dirname(target)
def create_missing_dirs(dirct: str, uid: typing.Optional[int], gid: typing.Optional[int]):
if not os.path.isdir(dirct):
parent_dir = os.path.dirname(dirct)
if not os.path.isdir(parent_dir):
create_missing_dirs(parent_dir, uid, gid)
os.mkdir(dirct)
if uid is not None:
assert gid is not None, "If uid is set, then gid is set."
os.chown(dirct, uid, gid)
create_missing_dirs(target_directory, self.uid, self.gid)
changed = self._write_content(target, variables)
if self.uid is not None:
assert self.gid is not None, "If uid is set, then gid is set."
os.chown(target, self.uid, self.gid)
os.chmod(target, self.permissions)
return changed
def _write_content(self, target: str, variables: dict[str, str]):
# Case 1: copy from source file directly (binary or no substitutions)
if self.source_file is not None and (self.bin_file or len(variables) == 0):
if os.path.exists(target):
with open(self.source_file, "rb") as src, open(target, "rb") as dst:
if src.read() == dst.read():
return False
shutil.copy(self.source_file, target)
return True
# Case 2: binary content from memory
if self.bin_file and self.content is not None:
desired_bytes = self.content.encode(encoding=self.encoding)
if os.path.exists(target):
with open(target, "rb") as file:
if file.read() == desired_bytes:
return False
with open(target, "wb") as file:
file.write(desired_bytes)
return True
# From here on: text modes with possible substitutions
# Case 3: text content from source file with substitutions
if self.source_file is not None:
with open(self.source_file, "rt", encoding=self.encoding) as src:
content = src.read()
for var, value in variables.items():
content = content.replace(var, value)
if os.path.exists(target):
with open(target, "rt", encoding=self.encoding) as file:
if file.read() == content:
return False
with open(target, "wt", encoding=self.encoding) as file:
file.write(content)
return True
# Case 4: text content from in-memory string with substitutions
assert self.content is not None, "Content should be set since source_file was not set."
content = self.content
for var, value in variables.items():
content = content.replace(var, value)
if os.path.exists(target):
with open(target, "rt", encoding=self.encoding) as file:
if file.read() == content:
return False
with open(target, "wt", encoding=self.encoding) as file:
file.write(content)
return True
class Directory:
"""
Declarative specification for copying the contents of a source directory into a target
directory.
Files are copied using the :class:`File` abstraction, inheriting its ownership,
permissions, encoding, and binary/text behavior. Text files can optionally undergo
variable substitution before being written.
Parameters
----------
source_directory:
Path to the directory whose contents will be mirrored into the target.
bin_files:
If ``True``, treat all files as binary; disables variable substitution and copies bytes
verbatim.
encoding:
Text encoding used when reading or writing non-binary files.
owner:
System user name to own created files and directories.
group:
System group name to own created files and directories.
permissions:
File mode applied to created or updated files (e.g. ``0o644``).
Raises
------
UserNotFoundError
If ``owner`` does not exist on the system.
GroupNotFoundError
If ``group`` does not exist on the system.
"""
def __init__(
self,
source_directory: str,
bin_files: bool = False,
encoding: str = "utf-8",
owner: typing.Optional[str] = None,
group: typing.Optional[str] = None,
permissions: int = 0o644,
):
self.source_directory = source_directory
self.bin_files = bin_files
self.encoding = encoding
self.permissions = permissions
self.owner = owner
self.group = group
self.uid = None
self.gid = None
if owner is not None:
self.uid, self.gid = command.get_user_info(owner)
if group is not None:
try:
self.gid = grp.getgrnam(group).gr_gid
except KeyError as error:
raise errors.GroupNotFoundError(group) from error
def copy_to(
self,
target_directory: str,
variables: typing.Optional[dict[str, str]] = None,
dry_run: bool = False,
) -> list[str]:
"""
Copies the files in this directory to the target directory. Only replaces files that differ.
Parameters
----------
target_directory:
Destination directory root. Relative layout from the source is preserved beneath this
path.
variables:
Optional mapping of literal substrings to replace in text files before writing. Ignored
for binary files.
dry_run:
If ``True``, perform a dry-run: no files are written, but the list of files that *would*
be processed is returned.
Returns
-------
list[str]
When ``dry_run`` is ``False``, paths of files that were created or whose contents
were modified.
When ``dry_run`` is ``True``, paths of all files that would be considered for
creation or modification (no changes are actually performed).
Raises
------
OSError
If directory traversal or file I/O fails (e.g. permission denied).
FileNotFoundError
If ``source_directory`` does not exist or becomes unavailable.
UnicodeDecodeError
If a text file cannot be decoded using ``encoding``.
UnicodeEncodeError
If text content cannot be encoded using ``encoding``.
"""
changed_or_created = []
original_wd = os.getcwd()
try:
os.chdir(self.source_directory)
for src_dir, _, src_files in os.walk("."):
for src_file in src_files:
src_path = os.path.join(src_dir, src_file)
file = File(
source_file=src_path,
bin_file=self.bin_files,
encoding=self.encoding,
owner=self.owner,
group=self.group,
permissions=self.permissions,
)
target = os.path.normpath(os.path.join(target_directory, src_path))
if dry_run:
changed_or_created.append(target)
else:
if file.copy_to(target, variables):
changed_or_created.append(target)
finally:
os.chdir(original_wd)
return changed_or_created
+241
View File
@@ -0,0 +1,241 @@
import os
import shutil
import sys
import typing
import decman.config as config
# ─────────────────────────────
# Visible (non-ANSI) constants
# ─────────────────────────────
_TAG_TEXT = "[DECMAN]"
_SPACING = " "
_CONTINUATION_PREFIX_TEXT = f"{_TAG_TEXT}{_SPACING} "
INFO = 1
SUMMARY = 2
# ─────────────────────────────
# Color / formatting helpers
# ─────────────────────────────
def has_ansi_support() -> bool:
"""
Returns True if the running terminal supports ANSI colors or if colors should be enabled.
"""
if os.environ.get("NO_COLOR") is not None:
return False
if os.environ.get("FORCE_COLOR") is not None:
return True
if not sys.stdout.isatty():
return False
term = os.environ.get("TERM", "")
return term not in ("", "dumb")
def _apply_color(code: str, text: str) -> str:
if not config.color_output:
return text
return f"{code}{text}\033[m"
def _tag() -> str:
if not config.color_output:
return _TAG_TEXT
return "[\033[1;35mDECMAN\033[m]"
def _continuation_prefix() -> str:
return f"{_tag()}{_SPACING} "
def _red(text: str) -> str:
return _apply_color("\033[91m", text)
def _yellow(text: str) -> str:
return _apply_color("\033[93m", text)
def _cyan(text: str) -> str:
return _apply_color("\033[96m", text)
def _green(text: str) -> str:
return _apply_color("\033[92m", text)
def _gray(text: str) -> str:
return _apply_color("\033[90m", text)
# ─────────────────────────────
# Printing helpers
# ─────────────────────────────
def print_continuation(msg: str, level: int = SUMMARY):
"""
Prints a message without a prefix.
"""
if level == SUMMARY or config.debug_output or not config.quiet_output:
print(f"{_continuation_prefix()}{msg}")
def print_error(error_msg: str):
"""
Prints an error message to the user.
"""
print(f"{_tag()} {_red('ERROR')}: {error_msg}")
def print_warning(msg: str):
"""
Prints a warning to the user.
"""
print(f"{_tag()} {_yellow('WARNING')}: {msg}")
def print_summary(msg: str):
"""
Prints a summary message to the user.
"""
print(f"{_tag()} {_cyan('SUMMARY')}: {msg}")
def print_info(msg: str):
"""
Prints a detailed message to the user if verbose output is not disabled.
"""
if config.debug_output or not config.quiet_output:
print(f"{_tag()} INFO: {msg}")
def print_debug(msg: str):
"""
Prints a detailed message to the user if debug messages are enabled.
"""
if config.debug_output:
print(f"{_tag()} {_gray('DEBUG')}: {msg}")
# ─────────────────────────────
# List printing
# ─────────────────────────────
def print_list(
msg: str,
list_to_print: list[str],
elements_per_line: typing.Optional[int] = None,
max_line_width: typing.Optional[int] = None,
limit_to_term_size: bool = True,
level: int = SUMMARY,
):
"""
Prints a summary message to the user along with a list of elements.
If the list is empty, prints nothing.
"""
if len(list_to_print) == 0:
return
list_to_print = list_to_print.copy()
if level == SUMMARY:
print_summary(msg)
elif level == INFO:
print_info(msg)
print_continuation("", level=level)
if elements_per_line is None:
elements_per_line = len(list_to_print)
if max_line_width is None:
max_line_width = 2**32
if limit_to_term_size:
visible_prefix_len = len(_CONTINUATION_PREFIX_TEXT)
max_line_width = shutil.get_terminal_size().columns - visible_prefix_len
lines = [list_to_print.pop(0)]
index = 0
elements_in_current_line = 1
while list_to_print:
next_element = list_to_print.pop(0)
can_fit_elements = elements_in_current_line + 1 <= elements_per_line
can_fit_text = len(lines[index]) + len(next_element) <= max_line_width
if can_fit_text and can_fit_elements:
lines[index] += f" {next_element}"
elements_in_current_line += 1
else:
lines.append(next_element)
index += 1
elements_in_current_line = 1
for line in lines:
print_continuation(line, level=level)
print_continuation("", level=level)
# ─────────────────────────────
# Prompts
# ─────────────────────────────
def prompt_number(
msg: str,
min_num: int,
max_num: int,
default: typing.Optional[int] = None,
) -> int:
"""
Prompts the user for an integer.
"""
while True:
i = input(f"{_tag()} {_green('PROMPT')}: {msg}").strip()
if default is not None and i == "":
return default
try:
num = int(i)
if min_num <= num <= max_num:
return num
except ValueError:
pass
print_error("Invalid input.")
def prompt_confirm(msg: str, default: typing.Optional[bool] = None) -> bool:
"""
Prompts the user for confirmation.
"""
options_suffix = "(y/n)"
if default is not None:
options_suffix = "(Y/n)" if default else "(y/N)"
while True:
i = input(f"{_tag()} {_green('PROMPT')} {options_suffix}: {msg} ").strip()
if default is not None and i == "":
return default
if i.lower() in ("y", "ye", "yes"):
return True
if i.lower() in ("n", "no"):
return False
print_error("Invalid input.")
-12
View File
@@ -1,12 +0,0 @@
"""
Errors used by decman.
"""
class UserFacingError(Exception):
"""
Execution of an important step failed and the program shouldn't continue.
"""
def __init__(self, user_facing_msg: str):
self.user_facing_msg = user_facing_msg
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
View File
-6
View File
@@ -1,6 +0,0 @@
import os
import sys
_SRC_PATH = os.path.join(os.path.dirname(__file__), "../src/")
sys.path.append(_SRC_PATH)
-3
View File
@@ -1,3 +0,0 @@
Simple text file with a %variable%
twice: %another_variable%
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
# This file should be executable.
echo "Hello, world!"
-3
View File
@@ -1,3 +0,0 @@
1
1
1
-3
View File
@@ -1,3 +0,0 @@
2
2
2
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

-3
View File
@@ -1,3 +0,0 @@
s1
s1
s1
-3
View File
@@ -1,3 +0,0 @@
s2
s2
s2
-56
View File
@@ -1,56 +0,0 @@
import os
import sys
# This test is manual. You'll have to verify the results manually.
# NOTE: Change this if you want to run this script.
user = "kk"
cd = os.path.dirname(os.path.abspath(__file__))
os.chdir(cd)
sys.path.append(os.path.join(cd, "../../src/."))
from decman import Directory, File
# if os.path.exists("/tmp/decman-files"):
# shutil.rmtree("/tmp/decman-files")
# os.makedirs("/tmp/decman-files")
f1 = File(source_file="src/f1.txt")
f1.copy_to(
"/tmp/decman-files/f1.txt",
variables={"%variable%": "123", "%another_variable%": "456"},
)
f2 = File(source_file="src/f2.sh", permissions=0o744)
f2.copy_to(
"/tmp/decman-files/f2.sh",
)
f3 = File(content="%variable% doesn't work here.", bin_file=True)
f3.copy_to(
"/tmp/decman-files/f3.txt",
variables={
"%variable%": "123",
},
)
f4 = File(content="%variable% works here.", bin_file=False, owner=user)
f4.copy_to(
"/tmp/decman-files/f4.txt",
variables={
"%variable%": "123",
},
)
f5 = File(content="%variable% works here.", bin_file=False, owner=user, group="root")
f5.copy_to(
"/tmp/decman-files/f5.txt",
variables={
"%variable%": "123",
},
)
d = Directory("src/srcdir", bin_files=True)
d.copy_to("/tmp/decman-files/targetdir")
+70
View File
@@ -0,0 +1,70 @@
import json
import sys
import pytest
import decman.core.command as command
def test_run_simple():
code, out = command.run([sys.executable, "-c", "print('ok')"])
assert code == 0
assert out.strip() == "ok"
def test_run_exec_failure():
code, out = command.run(["/does/not/exist"])
assert code != 0
assert "not" in out.lower()
def test_run_env_overrides_and_mimic_login_visible_in_child(monkeypatch):
class FakePw:
pw_dir = "/fake/home"
pw_name = "fakeuser"
pw_uid = 1000
pw_gid = 1000
pw_shell = "/bin/fakesh"
# Mock passwd lookup
monkeypatch.setattr(
"decman.core.command.pwd.getpwnam",
lambda user: FakePw(),
)
code, out = command.run(
[
sys.executable,
"-c",
(
"import os, json; "
"print(json.dumps({"
"'FOO': os.environ['FOO'], "
"'HOME': os.environ['HOME'], "
"'USER': os.environ['USER'], "
"'LOGNAME': os.environ['LOGNAME'], "
"'SHELL': os.environ['SHELL']"
"}))"
),
],
user="fakeuser",
mimic_login=True,
env_overrides={"FOO": "BAR"},
)
assert code == 0
data = json.loads(out.strip())
assert data["FOO"] == "BAR"
assert data["HOME"] == "/fake/home"
assert data["USER"] == "fakeuser"
assert data["LOGNAME"] == "fakeuser"
assert data["SHELL"] == "/bin/fakesh"
@pytest.mark.skipif(not sys.stdin.isatty(), reason="requires TTY")
def test_pty_run_simple():
code, out = command.pty_run([sys.executable, "-c", "print('ok')"])
assert code == 0
assert "ok" in out
assert "\r\n" not in out
+244
View File
@@ -0,0 +1,244 @@
import os
import stat
from pathlib import Path
# Adjust this import to match your actual module location
import decman.core.files as files
# --- files.File tests --------------------------------------------------------------
def test_file_from_content_creates_and_is_idempotent(tmp_path: Path) -> None:
target = tmp_path / "file.txt"
f = files.File(content="hello", permissions=0o600)
# First run: file must be created and reported as changed
changed1 = f.copy_to(str(target))
assert changed1 is True
assert target.read_text(encoding="utf-8") == "hello"
mode = stat.S_IMODE(target.stat().st_mode)
assert mode == 0o600
# Second run with same configuration: no content change
changed2 = f.copy_to(str(target))
assert changed2 is False
assert target.read_text(encoding="utf-8") == "hello"
assert stat.S_IMODE(target.stat().st_mode) == 0o600
def test_file_content_with_variables_and_change_detection(tmp_path: Path) -> None:
target = tmp_path / "templated.txt"
f = files.File(content="hello {{NAME}}")
# First run: NAME=world
changed1 = f.copy_to(str(target), {"{{NAME}}": "world"})
assert changed1 is True
assert target.read_text(encoding="utf-8") == "hello world"
# Second run: same variables, no change
changed2 = f.copy_to(str(target), {"{{NAME}}": "world"})
assert changed2 is False
assert target.read_text(encoding="utf-8") == "hello world"
# Third run: different variables, should change
changed3 = f.copy_to(str(target), {"{{NAME}}": "there"})
assert changed3 is True
assert target.read_text(encoding="utf-8") == "hello there"
def test_file_from_source_text_with_and_without_variables(tmp_path: Path) -> None:
src = tmp_path / "src.txt"
src.write_text("VALUE={{X}}", encoding="utf-8")
target = tmp_path / "dst.txt"
# Without variables (raw copy)
f_raw = files.File(source_file=str(src))
changed1 = f_raw.copy_to(str(target), {})
assert changed1 is True
assert target.read_text(encoding="utf-8") == "VALUE={{X}}"
# Idempotent raw copy
changed2 = f_raw.copy_to(str(target), {})
assert changed2 is False
# With variables (substitution)
f_sub = files.File(source_file=str(src))
changed3 = f_sub.copy_to(str(target), {"{{X}}": "42"})
assert changed3 is True
assert target.read_text(encoding="utf-8") == "VALUE=42"
# Idempotent after substitution
changed4 = f_sub.copy_to(str(target), {"{{X}}": "42"})
assert changed4 is False
def test_file_binary_from_content(tmp_path: Path) -> None:
target = tmp_path / "bin.dat"
payload = b"\x00\x01\x02hello"
f = files.File(content=payload.decode("latin1"), bin_file=True)
changed1 = f.copy_to(str(target))
assert changed1 is True
assert target.read_bytes() == payload
# Idempotent: second call does not rewrite
changed2 = f.copy_to(str(target))
assert changed2 is False
assert target.read_bytes() == payload
def test_file_binary_copy_from_source(tmp_path: Path) -> None:
src = tmp_path / "src.bin"
payload = b"\x10\x20\x30binary"
src.write_bytes(payload)
target = tmp_path / "dst.bin"
f = files.File(source_file=str(src), bin_file=True)
changed1 = f.copy_to(str(target), {"IGNORED": "x"})
assert changed1 is True
assert target.read_bytes() == payload
# Idempotent, comparing bytes
changed2 = f.copy_to(str(target), {"IGNORED": "x"})
assert changed2 is False
assert target.read_bytes() == payload
def test_file_creates_parent_directories_and_applies_permissions(tmp_path: Path) -> None:
nested_dir = tmp_path / "a" / "b" / "c"
target = nested_dir / "file.txt"
f = files.File(content="data", permissions=0o644)
changed = f.copy_to(str(target))
assert changed is True
assert target.read_text(encoding="utf-8") == "data"
# Directories created
assert nested_dir.is_dir()
# Permissions on file
mode = stat.S_IMODE(target.stat().st_mode)
assert mode == 0o644
# --- files.Directory tests ---------------------------------------------------------
def _create_sample_source_tree(root: Path) -> None:
(root / "sub").mkdir(parents=True)
(root / "a.txt").write_text("A={{X}}", encoding="utf-8")
(root / "sub" / "b.txt").write_text("B={{X}}", encoding="utf-8")
def test_directory_copy_to_creates_and_is_idempotent(tmp_path: Path) -> None:
src_dir = tmp_path / "src"
dst_dir = tmp_path / "dst"
src_dir.mkdir()
_create_sample_source_tree(src_dir)
d = files.Directory(
source_directory=str(src_dir),
bin_files=False,
encoding="utf-8",
permissions=0o644,
)
# First run: both files should be created and reported as changed
changed1 = d.copy_to(str(dst_dir), variables={"{{X}}": "1"})
expected_paths = {
str(dst_dir / "a.txt"),
str(dst_dir / "sub" / "b.txt"),
}
assert set(changed1) == expected_paths
assert (dst_dir / "a.txt").read_text(encoding="utf-8") == "A=1"
assert (dst_dir / "sub" / "b.txt").read_text(encoding="utf-8") == "B=1"
# Second run with same variables: no files should be reported as changed
changed2 = d.copy_to(str(dst_dir), variables={"{{X}}": "1"})
assert changed2 == []
def test_directory_copy_to_detects_changes_via_variables(tmp_path: Path) -> None:
src_dir = tmp_path / "src"
dst_dir = tmp_path / "dst"
src_dir.mkdir()
_create_sample_source_tree(src_dir)
d = files.Directory(source_directory=str(src_dir))
# Initial materialization
changed1 = d.copy_to(str(dst_dir), variables={"{{X}}": "alpha"})
assert set(changed1) == {
str(dst_dir / "a.txt"),
str(dst_dir / "sub" / "b.txt"),
}
# Change variables -> both files change
changed2 = d.copy_to(str(dst_dir), variables={"{{X}}": "beta"})
assert set(changed2) == {
str(dst_dir / "a.txt"),
str(dst_dir / "sub" / "b.txt"),
}
assert (dst_dir / "a.txt").read_text(encoding="utf-8") == "A=beta"
assert (dst_dir / "sub" / "b.txt").read_text(encoding="utf-8") == "B=beta"
def test_directory_copy_to_dry_run(tmp_path: Path) -> None:
src_dir = tmp_path / "src"
dst_dir = tmp_path / "dst"
src_dir.mkdir()
_create_sample_source_tree(src_dir)
d = files.Directory(source_directory=str(src_dir))
# First, actually materialize once
d.copy_to(str(dst_dir), variables={"{{X}}": "1"})
# Now perform dry-run with different variables; contents must not change
before_a = (dst_dir / "a.txt").read_text(encoding="utf-8")
before_b = (dst_dir / "sub" / "b.txt").read_text(encoding="utf-8")
changed_dry = d.copy_to(
str(dst_dir),
variables={"{{X}}": "2"},
dry_run=True,
)
expected_paths = {
str(dst_dir / "a.txt"),
str(dst_dir / "sub" / "b.txt"),
}
assert set(changed_dry) == expected_paths
# Contents remain as before (no writes in dry-run)
assert (dst_dir / "a.txt").read_text(encoding="utf-8") == before_a
assert (dst_dir / "sub" / "b.txt").read_text(encoding="utf-8") == before_b
def test_directory_copy_to_restores_working_directory(tmp_path: Path) -> None:
src_dir = tmp_path / "src"
dst_dir = tmp_path / "dst"
src_dir.mkdir()
_create_sample_source_tree(src_dir)
d = files.Directory(source_directory=str(src_dir))
original_cwd = os.getcwd()
try:
changed = d.copy_to(str(dst_dir), variables={"{{X}}": "x"})
assert set(changed) == {
str(dst_dir / "a.txt"),
str(dst_dir / "sub" / "b.txt"),
}
finally:
# Ensure the implementation restored CWD
assert os.getcwd() == original_cwd
+191
View File
@@ -0,0 +1,191 @@
import builtins
import types
import pytest
import decman.config as config
import decman.core.output as output
@pytest.fixture(autouse=True)
def reset_config():
# snapshot & restore config flags between tests
orig = types.SimpleNamespace(
debug_output=getattr(config, "debug_output", False),
quiet_output=getattr(config, "quiet_output", False),
color_output=getattr(config, "color_output", True),
)
yield
config.debug_output = orig.debug_output
config.quiet_output = orig.quiet_output
config.color_output = orig.color_output
def test_print_error_with_color_enabled(capsys):
config.color_output = True
output.print_error("boom")
out = capsys.readouterr().out
assert "boom" in out
assert "ERROR" in out
# crude check that some ANSI escapes are present
assert "\x1b[" in out
def test_print_error_with_color_disabled(capsys):
config.color_output = False
output.print_error("boom")
out = capsys.readouterr().out
assert out.strip().endswith("ERROR: boom")
# no ANSI escapes
assert "\x1b[" not in out
def test_print_info_respects_quiet_and_debug(capsys):
config.quiet_output = True
config.debug_output = False
output.print_info("msg 1")
out = capsys.readouterr().out
assert out == "" # suppressed
config.debug_output = True
output.print_info("msg 2")
out = capsys.readouterr().out
assert "INFO: msg 2" in out
config.quiet_output = False
config.debug_output = False
output.print_info("msg 3")
out = capsys.readouterr().out
assert "INFO: msg 3" in out
def test_print_debug_only_with_debug_enabled(capsys):
config.debug_output = False
output.print_debug("dbg")
assert capsys.readouterr().out == ""
config.debug_output = True
output.print_debug("dbg")
out = capsys.readouterr().out
assert "DEBUG" in out
assert "dbg" in out
def test_print_continuation_respects_level_and_config(capsys):
config.quiet_output = True
config.debug_output = False
output.print_continuation("x", level=output.INFO)
assert capsys.readouterr().out == ""
output.print_continuation("y", level=output.SUMMARY)
out = capsys.readouterr().out
assert "y" in out
def test_print_list_empty_outputs_nothing(capsys):
output.print_list("Header", [])
assert capsys.readouterr().out == ""
def test_print_list_summary_and_elements(capsys, monkeypatch):
# fixed terminal size for deterministic wrapping
monkeypatch.setattr(
output.shutil, "get_terminal_size", lambda: types.SimpleNamespace(columns=80)
)
config.quiet_output = False
config.debug_output = False
output.print_list("Installed packages:", ["a", "b", "c"])
out = capsys.readouterr().out.splitlines()
# header summary
assert any("SUMMARY" in line and "Installed packages:" in line for line in out)
# list content printed as continuation lines
assert any("a" in line for line in out)
assert any("b" in line for line in out)
assert any("c" in line for line in out)
def test_print_list_respects_elements_per_line_and_width(capsys, monkeypatch):
# very small width to force wrapping
monkeypatch.setattr(
output.shutil, "get_terminal_size", lambda: types.SimpleNamespace(columns=30)
)
items = [f"pkg{i}" for i in range(5)]
output.print_list(
"Pkgs:",
items,
elements_per_line=2,
limit_to_term_size=True,
level=output.SUMMARY,
)
out_lines = capsys.readouterr().out.splitlines()
list_lines = [l for l in out_lines if "pkg" in l]
# at most 2 per line
for line in list_lines:
assert len([p for p in items if p in line]) <= 2
def test_prompt_number_valid_input(monkeypatch):
inputs = iter(["3"])
monkeypatch.setattr(builtins, "input", lambda _: next(inputs))
res = output.prompt_number("Pick", 1, 5)
assert res == 3
def test_prompt_number_invalid_then_valid(monkeypatch, capsys):
inputs = iter(["foo", "10", "2"])
monkeypatch.setattr(builtins, "input", lambda _: next(inputs))
res = output.prompt_number("Pick", 1, 5)
assert res == 2
out = capsys.readouterr().out
# at least one error printed
assert "Invalid input" in out
def test_prompt_number_default_on_empty(monkeypatch):
inputs = iter([""])
monkeypatch.setattr(builtins, "input", lambda _: next(inputs))
res = output.prompt_number("Pick", 1, 5, default=4)
assert res == 4
@pytest.mark.parametrize(
"user_input,default,expected",
[
("y", None, True),
("Y", None, True),
("yes", None, True),
("n", None, False),
("No", None, False),
("", True, True),
("", False, False),
],
)
def test_prompt_confirm(monkeypatch, user_input, default, expected):
inputs = iter([user_input])
monkeypatch.setattr(builtins, "input", lambda _: next(inputs))
res = output.prompt_confirm("Continue?", default=default)
assert res is expected
def test_prompt_confirm_invalid_then_yes(monkeypatch, capsys):
inputs = iter(["maybe", "y"])
monkeypatch.setattr(builtins, "input", lambda _: next(inputs))
res = output.prompt_confirm("Continue?")
assert res is True
out = capsys.readouterr().out
assert "Invalid input." in out
+143
View File
@@ -0,0 +1,143 @@
import typing
import pytest
import decman
def test_prg_pty_true_uses_pty_run_and_check(monkeypatch: pytest.MonkeyPatch):
calls: dict[str, typing.Any] = {}
def fake_pty_run(cmd, user=None, env_overrides=None, mimic_login=False):
calls["pty_run"] = (cmd, user, env_overrides, mimic_login)
return 0, "ok"
def fake_check_run_result(cmd, result):
calls["check_run_result"] = (cmd, result)
return result
def fake_print_warning(msg: str):
raise AssertionError("print_warning must not be called when code == 0")
monkeypatch.setattr(decman, "command", decman.command)
monkeypatch.setattr(decman.command, "pty_run", fake_pty_run)
monkeypatch.setattr(decman.command, "check_run_result", fake_check_run_result)
monkeypatch.setattr(decman, "output", decman.output)
monkeypatch.setattr(decman.output, "print_warning", fake_print_warning)
out = decman.prg(
["echo", "hi"],
user="alice",
env_overrides={"FOO": "bar"},
mimic_login=True,
pty=True,
check=True,
)
assert out == "ok"
assert calls["pty_run"] == (["echo", "hi"], "alice", {"FOO": "bar"}, True)
assert calls["check_run_result"] == (["echo", "hi"], (0, "ok"))
def test_prg_pty_false_uses_run(monkeypatch: pytest.MonkeyPatch):
calls: dict[str, typing.Any] = {}
def fake_run(cmd, user=None, env_overrides=None, mimic_login=False):
calls["run"] = (cmd, user, env_overrides, mimic_login)
return 0, "no-pty"
def fake_check_run_result(cmd, result):
return result
def fake_print_warning(msg: str):
raise AssertionError("print_warning must not be called when code == 0")
monkeypatch.setattr(decman.command, "run", fake_run)
monkeypatch.setattr(decman.command, "check_run_result", fake_check_run_result)
monkeypatch.setattr(decman.output, "print_warning", fake_print_warning)
out = decman.prg(["true"], pty=False, check=True)
assert out == "no-pty"
assert calls["run"] == (["true"], None, None, False)
def test_prg_check_false_warns_on_nonzero(monkeypatch: pytest.MonkeyPatch):
calls: dict[str, typing.Any] = {}
def fake_run(cmd, user=None, env_overrides=None, mimic_login=False):
# non-zero exit code
return 3, "bad"
def fake_check_run_result(cmd, result):
raise AssertionError("check_run_result must not be called when check=False")
def fake_print_warning(msg: str):
calls["warning"] = msg
monkeypatch.setattr(decman.command, "run", fake_run)
monkeypatch.setattr(decman.command, "check_run_result", fake_check_run_result)
monkeypatch.setattr(decman.output, "print_warning", fake_print_warning)
out = decman.prg(["cmd", "arg"], pty=False, check=False)
assert out == "bad"
assert "cmd arg" in calls["warning"]
assert "exit code 3" in calls["warning"]
def test_prg_check_true_propagates_command_failed_error(monkeypatch: pytest.MonkeyPatch):
class CommandFailedError(Exception):
pass
def fake_run(cmd, user=None, env_overrides=None, mimic_login=False):
return 42, "boom"
def fake_check_run_result(cmd, result):
raise CommandFailedError((cmd, result))
def fake_print_warning(msg: str):
raise AssertionError("print_warning must not be called when check=True and error")
monkeypatch.setattr(decman.command, "run", fake_run)
monkeypatch.setattr(decman.command, "check_run_result", fake_check_run_result)
monkeypatch.setattr(decman.output, "print_warning", fake_print_warning)
with pytest.raises(CommandFailedError):
decman.prg(["boom"], pty=False, check=True)
def test_sh_calls_prg_with_sh_command(monkeypatch: pytest.MonkeyPatch):
calls: dict[str, typing.Any] = {}
def fake_prg(
cmd,
user=None,
env_overrides=None,
mimic_login=False,
pty=True,
check=True,
):
calls["prg"] = (cmd, user, env_overrides, mimic_login, pty, check)
return "output-from-prg"
monkeypatch.setattr(decman, "prg", fake_prg)
out = decman.sh(
"echo test",
user="bob",
env_overrides={"X": "1"},
mimic_login=True,
pty=False,
check=False,
)
assert out == "output-from-prg"
cmd, user, env_overrides, mimic_login, pty, check = calls["prg"]
assert cmd == ["/bin/sh", "-c", "echo test"]
assert user == "bob"
assert env_overrides == {"X": "1"}
assert mimic_login is True
assert pty is False
assert check is False
-96
View File
@@ -1,96 +0,0 @@
# pylint: disable=missing-module-docstring,missing-class-docstring,missing-function-docstring
import unittest
from decman.error import UserFacingError
from decman.lib import Pacman, Store
from decman.lib.fpm import DepGraph, ExtendedPackageSearch, ForeignPackage, ForeignPackageManager
class TestVersionComparisons(unittest.TestCase):
def setUp(self):
pacman = Pacman()
self.pm = ForeignPackageManager(Store(), pacman, ExtendedPackageSearch(pacman))
def test_should_upgrade_package_returns_true_on_newer_version(self):
self.assertTrue(self.pm.should_upgrade_package("test", "0.1.9", "0.2.0"))
def test_should_upgrade_package_returns_false_on_older_version(self):
self.assertFalse(self.pm.should_upgrade_package("test", "0.1.9", "0.1.8"))
def test_should_upgrade_package_returns_false_on_same_version(self):
self.assertFalse(self.pm.should_upgrade_package("test", "0.1.9", "0.1.9"))
def test_should_upgrade_package_returns_true_on_devel(self):
self.assertTrue(self.pm.should_upgrade_package("test-git", "0", "0", upgrade_devel=True))
class TestDepGraph(unittest.TestCase):
def test_add_dependency(self):
graph = DepGraph()
graph.add_requirement("A", None)
graph.add_requirement("B1", "A")
graph.add_requirement("B2", "A")
graph.add_requirement("C", "B1")
self.assertIn("B1", graph.package_nodes["A"].children)
self.assertIn("B2", graph.package_nodes["A"].children)
self.assertIn("C", graph.package_nodes["B1"].children)
def test_cyclic_dep_fails(self):
graph = DepGraph()
graph.add_requirement("A", None)
graph.add_requirement("B", "A")
graph.add_requirement("C", "B")
with self.assertRaises(UserFacingError):
graph.add_requirement("A", "C")
def test_get_and_remove_outer_deps(self):
graph = DepGraph()
graph.add_requirement("A", None)
graph.add_requirement("V", None)
graph.add_requirement("B1", "A")
graph.add_requirement("B2", "A")
graph.add_requirement("B3", "A")
graph.add_requirement("B1", "B2")
graph.add_requirement("C1", "B1")
graph.add_requirement("C2", "B1")
graph.add_requirement("D", "C1")
graph.add_requirement("C2", "D")
v = ForeignPackage("V")
a = ForeignPackage("A")
a.add_foreign_dependency_packages(["B1", "B2", "B3", "C1", "C2", "D"])
b1 = ForeignPackage("B1")
b1.add_foreign_dependency_packages(["C1", "C2", "D"])
b2 = ForeignPackage("B2")
b2.add_foreign_dependency_packages(["B1", "C1", "C2", "D"])
b3 = ForeignPackage("B3")
c1 = ForeignPackage("C1")
c1.add_foreign_dependency_packages(["D", "C2"])
c2 = ForeignPackage("C2")
d = ForeignPackage("D")
d.add_foreign_dependency_packages(["C2"])
self.assertCountEqual(graph.get_and_remove_outer_dep_pkgs(), [c2, b3, v])
self.assertCountEqual(graph.get_and_remove_outer_dep_pkgs(), [d])
self.assertCountEqual(graph.get_and_remove_outer_dep_pkgs(), [c1])
self.assertCountEqual(graph.get_and_remove_outer_dep_pkgs(), [b1])
self.assertCountEqual(graph.get_and_remove_outer_dep_pkgs(), [b2])
self.assertCountEqual(graph.get_and_remove_outer_dep_pkgs(), [a])
self.assertCountEqual(graph.get_and_remove_outer_dep_pkgs(), [])
-302
View File
@@ -1,302 +0,0 @@
# pylint: disable=missing-module-docstring,missing-class-docstring,missing-function-docstring
import unittest
from decman import Module, UserPackage
from decman.lib import Source, Store
class ExistingTestModule(Module):
def __init__(self):
self.on_enable_executed = False
self.on_disable_executed = False
self.after_update_executed = False
self.after_version_change_executed = False
super().__init__("Existing", True, "1")
def on_enable(self):
self.on_enable_executed = True
def on_disable(self):
self.on_disable_executed = True
def after_update(self):
self.after_update_executed = True
def after_version_change(self):
self.after_version_change_executed = True
class ExistingChangedVersionTestModule(Module):
def __init__(self):
self.on_enable_executed = False
self.on_disable_executed = False
self.after_update_executed = False
self.after_version_change_executed = False
super().__init__("ExistingChanged", True, "2")
def on_enable(self):
self.on_enable_executed = True
def on_disable(self):
self.on_disable_executed = True
def after_update(self):
self.after_update_executed = True
def after_version_change(self):
self.after_version_change_executed = True
class EnabledTestModule(Module):
def __init__(self):
self.on_enable_executed = False
self.on_disable_executed = False
self.after_update_executed = False
self.after_version_change_executed = False
super().__init__("Enabled", True, "1")
def on_enable(self):
self.on_enable_executed = True
def on_disable(self):
self.on_disable_executed = True
def after_update(self):
self.after_update_executed = True
def after_version_change(self):
self.after_version_change_executed = True
def pacman_packages(self) -> list[str]:
return ["M_p1", "M_p2", "M_p3"]
def systemd_user_units(self) -> dict[str, list[str]]:
return {"muser": ["M_u1.service"]}
def flatpak_packages(self) -> list[str]:
return ["M_f1", "M_f2"]
class DisabledTestModule(Module):
def __init__(self):
self.on_enable_executed = False
self.on_disable_executed = False
self.after_update_executed = False
self.after_version_change_executed = False
super().__init__("Disabled", False, "1")
def on_enable(self):
self.on_enable_executed = True
def on_disable(self):
self.on_disable_executed = True
def after_update(self):
self.after_update_executed = True
def after_version_change(self):
self.after_version_change_executed = True
def aur_packages(self) -> list[str]:
return ["M_A1", "M_A2", "M_A3"]
def systemd_units(self) -> list[str]:
return ["M_1.service"]
class TestSource(unittest.TestCase):
def setUp(self):
self.disabled_module = DisabledTestModule()
self.enabled_module = EnabledTestModule()
self.existing_module = ExistingTestModule()
self.existing_module_changed = ExistingChangedVersionTestModule()
modules = {
self.enabled_module,
self.disabled_module,
self.existing_module,
self.existing_module_changed,
}
source = Source(
pacman_packages={"p1", "p2", "p3"},
aur_packages={"A1", "A2", "A3"},
user_packages={
UserPackage(
pkgname="U1",
version="1",
dependencies=["d1"],
git_url="/am/url/yes",
),
UserPackage(
pkgname="U2",
version="1",
dependencies=["d2"],
git_url="/am/url/yes",
),
},
ignored_packages={"i1", "i2"},
systemd_units={"1.service", "2.timer"},
systemd_user_units={"user": {"u1.service", "u2.timer"}},
modules=modules,
files={},
directories={},
flatpak_packages={"f1", "f2", "f3"},
flatpak_user_packages={"fu1", "fu2", "fu3"},
ignored_flatpak_packages={"i1", "i2"},
)
store = Store()
store.enabled_systemd_units.extend(["1.service", "3.service", "M_1.service"])
store.add_enabled_user_systemd_unit("user", "u1.service")
store.add_enabled_user_systemd_unit("user", "u3.service")
store.enabled_modules = {
"Existing": "1",
"ExistingChanged": "1",
"Disabled": "1",
}
store.created_files = ["/test/file1", "/test/file2", "/test/file3"]
currently_installed_packages = [
"p1",
"p2",
"p4",
"A2",
"A3",
"A4",
"U1",
"i1",
"M_p3",
"M_A1",
"M_A2",
]
self.source = source
self.store = store
self.currently_installed_packages = currently_installed_packages
def test_all_enabled_modules(self):
enabled_modules = [
("Enabled", "1"),
("Existing", "1"),
("ExistingChanged", "2"),
]
self.assertCountEqual(self.source.all_enabled_modules(), enabled_modules)
def test_files_to_remove(self):
created_files = ["/test/file1", "/test/file4"]
self.assertCountEqual(
self.source.files_to_remove(self.store, created_files),
["/test/file2", "/test/file3"],
)
def test_after_update_executed(self):
self.source.run_after_update()
self.assertTrue(self.enabled_module.after_update_executed)
self.assertTrue(self.existing_module.after_update_executed)
self.assertTrue(self.existing_module_changed.after_update_executed)
self.assertFalse(self.disabled_module.after_update_executed)
def test_after_version_change_executed(self):
self.source.run_after_version_change(self.store)
self.assertTrue(self.enabled_module.after_version_change_executed)
self.assertTrue(self.existing_module_changed.after_version_change_executed)
self.assertFalse(self.existing_module.after_version_change_executed)
self.assertFalse(self.disabled_module.after_version_change_executed)
def test_on_enable_executed(self):
self.source.run_on_enable(self.store)
self.assertTrue(self.enabled_module.on_enable_executed)
self.assertFalse(self.disabled_module.on_enable_executed)
self.assertFalse(self.existing_module.on_enable_executed)
self.assertFalse(self.existing_module_changed.on_enable_executed)
def test_on_disable_executed(self):
self.source.run_on_disable(self.store)
self.assertTrue(self.disabled_module.on_disable_executed)
self.assertFalse(self.enabled_module.on_disable_executed)
self.assertFalse(self.existing_module.on_disable_executed)
self.assertFalse(self.existing_module_changed.on_disable_executed)
def test_units_to_enable(self):
self.assertCountEqual(
self.source.units_to_enable(self.store),
["2.timer"],
)
def test_units_to_disable(self):
self.assertCountEqual(
self.source.units_to_disable(self.store),
["3.service", "M_1.service"],
)
def test_user_units_to_enable(self):
self.assertDictEqual(
self.source.user_units_to_enable(self.store),
{"user": ["u2.timer"], "muser": ["M_u1.service"]},
)
def test_user_units_to_disable(self):
self.assertDictEqual(
self.source.user_units_to_disable(self.store),
{"user": ["u3.service"]},
)
def test_pacman_packages_to_install(self):
self.assertCountEqual(
self.source.pacman_packages_to_install(self.currently_installed_packages),
["p3", "M_p1", "M_p2"],
)
def test_foreign_packages_to_install(self):
self.assertCountEqual(
self.source.foreign_packages_to_install(self.currently_installed_packages),
["A1", "U2"],
)
def test_packages_to_remove(self):
self.assertCountEqual(
self.source.packages_to_remove(self.currently_installed_packages),
["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"])
Generated
+66 -1
View File
@@ -52,6 +52,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
] ]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]] [[package]]
name = "decman" name = "decman"
version = "0.4.1" version = "0.4.1"
@@ -62,6 +71,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "pytest" },
{ name = "ruff" }, { name = "ruff" },
] ]
@@ -69,7 +79,10 @@ dev = [
requires-dist = [{ name = "requests" }] requires-dist = [{ name = "requests" }]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.14.9" }] dev = [
{ name = "pytest", specifier = ">=8.4.2" },
{ name = "ruff", specifier = ">=0.14.9" },
]
[[package]] [[package]]
name = "idna" name = "idna"
@@ -80,6 +93,58 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
] ]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.32.5" version = "2.32.5"