From 2b1bbdb884b2405a83e6c27ed0130172b3731b0a Mon Sep 17 00:00:00 2001 From: Kivi Kaitaniemi Date: Fri, 12 Dec 2025 22:34:18 +0200 Subject: [PATCH] Better methods for running commands --- DEVELOPMENT.md | 4 +- pyproject.toml | 1 + src/decman/__init__.py | 506 +++--------- src/decman/__main__.py | 3 - src/decman/app.py | 485 ----------- src/decman/config.py | 272 +------ src/decman/core/__init__.py | 0 src/decman/core/command.py | 244 ++++++ src/decman/core/error.py | 43 + src/decman/core/files.py | 352 ++++++++ src/decman/core/output.py | 241 ++++++ src/decman/error.py | 12 - src/decman/lib/__init__.py | 1192 ---------------------------- src/decman/lib/fpm.py | 1135 -------------------------- src/decman/py.typed | 0 tests/__init__.py | 6 - tests/manual/src/f1.txt | 3 - tests/manual/src/f2.sh | 3 - tests/manual/src/srcdir/1 | 3 - tests/manual/src/srcdir/2 | 3 - tests/manual/src/srcdir/image.png | Bin 1923 -> 0 bytes tests/manual/src/srcdir/sub/s1 | 3 - tests/manual/src/srcdir/sub/s2 | 3 - tests/manual/test_file_creation.py | 56 -- tests/test_decman_core_command.py | 70 ++ tests/test_decman_core_files.py | 244 ++++++ tests/test_decman_core_output.py | 191 +++++ tests/test_decman_init.py | 143 ++++ tests/test_package_management.py | 96 --- tests/test_source_resolution.py | 302 ------- uv.lock | 67 +- 31 files changed, 1693 insertions(+), 3990 deletions(-) delete mode 100644 src/decman/__main__.py delete mode 100644 src/decman/app.py create mode 100644 src/decman/core/__init__.py create mode 100644 src/decman/core/command.py create mode 100644 src/decman/core/error.py create mode 100644 src/decman/core/files.py create mode 100644 src/decman/core/output.py delete mode 100644 src/decman/error.py delete mode 100644 src/decman/lib/__init__.py delete mode 100644 src/decman/lib/fpm.py create mode 100644 src/decman/py.typed delete mode 100644 tests/__init__.py delete mode 100644 tests/manual/src/f1.txt delete mode 100644 tests/manual/src/f2.sh delete mode 100644 tests/manual/src/srcdir/1 delete mode 100644 tests/manual/src/srcdir/2 delete mode 100644 tests/manual/src/srcdir/image.png delete mode 100644 tests/manual/src/srcdir/sub/s1 delete mode 100644 tests/manual/src/srcdir/sub/s2 delete mode 100644 tests/manual/test_file_creation.py create mode 100644 tests/test_decman_core_command.py create mode 100644 tests/test_decman_core_files.py create mode 100644 tests/test_decman_core_output.py create mode 100644 tests/test_decman_init.py delete mode 100644 tests/test_package_management.py delete mode 100644 tests/test_source_resolution.py diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 37f0a31..679e629 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -12,10 +12,10 @@ sudo uv run decman ## Testing -Run unit tests: +Run all unit tests (`-s` disables output capturing): ```sh -uv run python -m unittest +uv run pytest -s ``` ## Formatting diff --git a/pyproject.toml b/pyproject.toml index 1926ec0..ba5d969 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ decman = "decman.app:main" [dependency-groups] dev = [ "ruff>=0.14.9", + "pytest>=8.4.2", ] [build-system] diff --git a/src/decman/__init__.py b/src/decman/__init__.py index 8dbfb64..54c4143 100644 --- a/src/decman/__init__.py +++ b/src/decman/__init__.py @@ -1,425 +1,109 @@ -""" -Module for writing system configurations for decman. -""" - -import grp -import os -import pwd -import shutil -import subprocess +import shlex import typing -import decman.error +import decman.core.command as command +import decman.core.output as output -class UserRaisedError(Exception): - """ - Error raised by running source +def prg( + cmd: list[str], + 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: - super().__init__(message) + Args: + 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( sh_cmd: str, 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 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: - env_overrides = {} - - 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] = [] + cmd = ["/bin/sh", "-c", sh_cmd] + return prg( + cmd, user=user, env_overrides=env_overrides, mimic_login=mimic_login, pty=pty, check=check + ) diff --git a/src/decman/__main__.py b/src/decman/__main__.py deleted file mode 100644 index 8175ca8..0000000 --- a/src/decman/__main__.py +++ /dev/null @@ -1,3 +0,0 @@ -import decman.app - -decman.app.main() diff --git a/src/decman/app.py b/src/decman/app.py deleted file mode 100644 index 0f8b7cb..0000000 --- a/src/decman/app.py +++ /dev/null @@ -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 diff --git a/src/decman/config.py b/src/decman/config.py index 057d7ef..d04d4ca 100644 --- a/src/decman/config.py +++ b/src/decman/config.py @@ -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. """ -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 quiet_output: bool = False -suppress_command_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 +color_output: bool = True diff --git a/src/decman/core/__init__.py b/src/decman/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/decman/core/command.py b/src/decman/core/command.py new file mode 100644 index 0000000..68f9971 --- /dev/null +++ b/src/decman/core/command.py @@ -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: ": \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 diff --git a/src/decman/core/error.py b/src/decman/core/error.py new file mode 100644 index 0000000..cfddc86 --- /dev/null +++ b/src/decman/core/error.py @@ -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}'.") diff --git a/src/decman/core/files.py b/src/decman/core/files.py new file mode 100644 index 0000000..c2b04c2 --- /dev/null +++ b/src/decman/core/files.py @@ -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 diff --git a/src/decman/core/output.py b/src/decman/core/output.py new file mode 100644 index 0000000..3f43ea5 --- /dev/null +++ b/src/decman/core/output.py @@ -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.") diff --git a/src/decman/error.py b/src/decman/error.py deleted file mode 100644 index 699fd1f..0000000 --- a/src/decman/error.py +++ /dev/null @@ -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 diff --git a/src/decman/lib/__init__.py b/src/decman/lib/__init__.py deleted file mode 100644 index 5d380a7..0000000 --- a/src/decman/lib/__init__.py +++ /dev/null @@ -1,1192 +0,0 @@ -""" -Library module for decman. -""" - -import json -import os -import pty -import pwd -import shutil -import subprocess -import time -import typing - -import decman -import decman.config as conf -import decman.error as err - -_DECMAN_MSG_TAG = "[\033[1;35mDECMAN\033[m]" -_RED_PREFIX = "\033[91m" -_YELLOW_PREFIX = "\033[93m" -_CYAN_PREFIX = "\033[96m" -_GREEN_PREFIX = "\033[92m" -_GRAY_PREFIX = "\033[90m" -_RESET_SUFFIX = "\033[m" -_SPACING = " " -_CONTINUATION_PREFIX = f"{_DECMAN_MSG_TAG}{_SPACING} " - -INFO = 1 -SUMMARY = 2 - - -def print_continuation(msg: str, level: int = SUMMARY): - """ - Prints a message without a prefix. - """ - if level == SUMMARY or conf.debug_output or not conf.quiet_output: - print(f"{_CONTINUATION_PREFIX}{msg}") - - -def print_error(error_msg: str): - """ - Prints an error message to the user. - """ - - print(f"{_DECMAN_MSG_TAG} {_RED_PREFIX}ERROR{_RESET_SUFFIX}: {error_msg}") - - -def print_warning(msg: str): - """ - Prints a warning to the user. - """ - - print(f"{_DECMAN_MSG_TAG} {_YELLOW_PREFIX}WARNING{_RESET_SUFFIX}: {msg}") - - -def print_summary(msg: str): - """ - Prints a summary message to the user. - """ - - print(f"{_DECMAN_MSG_TAG} {_CYAN_PREFIX}SUMMARY{_RESET_SUFFIX}: {msg}") - - -def print_list( - msg: str, - l: 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(l) == 0: - return - - l = l.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(l) - - if max_line_width is None: - max_line_width = 2**32 # Big enough to basically be unlimited - - if limit_to_term_size: - max_line_width = ( - shutil.get_terminal_size().columns - len(_SPACING) - len(_CONTINUATION_PREFIX) - ) - - lines = [f"{l.pop(0)}"] - index = 0 - elements_in_current_line = 1 - while l: - next_element = l.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(f"{next_element}") - index += 1 - elements_in_current_line = 1 - - for line in lines: - print_continuation(line, level=level) - - print_continuation("", level=level) - - -def print_info(msg: str): - """ - Prints a detailed message to the user if verbose output is not disabled. - """ - if conf.debug_output or not conf.quiet_output: - print(f"{_DECMAN_MSG_TAG} INFO: {msg}") - - -def print_debug(msg: str): - """ - Prints a detailed message to the user if debug messages are enabled. - """ - if conf.debug_output: - print(f"{_DECMAN_MSG_TAG} {_GRAY_PREFIX}DEBUG{_RESET_SUFFIX}: {msg}") - - -def prompt_number( - msg: str, min_num: int, max_num: int, default: typing.Optional[int] = None -) -> int: - """ - Prompts the user for a integer. - """ - while True: - i = input(f"{_DECMAN_MSG_TAG} {_GREEN_PREFIX}PROMPT{_RESET_SUFFIX}: {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) -> int: - """ - Prompts the user for confirmation. - """ - - options_suffix = "(y/n)" - if default is not None: - if default: - options_suffix = "(Y/n)" - else: - options_suffix = "(y/N)" - - while True: - i = input( - f"{_DECMAN_MSG_TAG} {_GREEN_PREFIX}PROMPT{_RESET_SUFFIX} {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.") - - -_STORE_SAVE_DIR = "/var/lib/decman/" -_STORE_SAVE_FILENAME = "/var/lib/decman/store.json" - - -class Store: - """ - Stores information between decman invocations. - - This information is used for example to prevent re-enabling a service. - """ - - def __init__(self): - self.source_file: typing.Optional[str] = None - self.allow_running_source_without_prompt: bool = False - self.enabled_systemd_units: list[str] = [] - self._enabled_user_systemd_units: list[str] = [] - self.enabled_modules: dict[str, str] = {} - self.created_files: list[str] = [] - self.pkgbuild_latest_reviewed_commits: dict[str, str] = {} - self._package_file_cache: dict[str, list[tuple[str, str, int]]] = {} - - def add_enabled_user_systemd_unit(self, user: str, unit: str): - """ - Stores a user unit as enabled. - """ - self._enabled_user_systemd_units.append(f"{user}->{unit}") - - def remove_enabled_user_systemd_unit(self, user: str, unit: str): - """ - Removes a user unit from stored units. - """ - try: - self._enabled_user_systemd_units.remove(f"{user}->{unit}") - except ValueError: - pass - - def is_systemd_used_unit_enabled(self, user: str, unit: str) -> bool: - """ - Returns true if the given user unit is stored as enabled. - """ - return f"{user}->{unit}" in self._enabled_user_systemd_units - - def get_enabled_user_systemd_units(self) -> list[tuple[str, str]]: - """ - Returns all enabled systemd units. - """ - result = [] - for unit_str in self._enabled_user_systemd_units: - unit_l = unit_str.split("->") - user = unit_l[0] - unit = unit_l[1] - result.append((user, unit)) - return result - - def get_package(self, package: str) -> typing.Optional[tuple[str, str]]: - """ - Returns the latest version and path of a package stored in the built packages cache as a - tuple (version, path). - """ - entries = self._package_file_cache.get(package) - if entries is None: - return None - - latest_version = None - latest_path = None - latest_timestamp = 0 - - for entry in entries: - version, path, timestamp = entry - if latest_timestamp < timestamp and os.path.exists(path): - latest_timestamp = timestamp - latest_version = version - latest_path = path - - print_debug(f"Latest file for {package} is '{latest_path}'.") - - if latest_path is None: - return None - - assert latest_version is not None, "If latest_path is set, then latest_version is set." - return (latest_version, latest_path) - - def add_package_to_cache(self, package: str, version: str, path_to_built_pkg: str): - """ - Adds a built package to the package file cache. Tries to remove excess cached packages. - """ - new_entry = (version, path_to_built_pkg, int(time.time())) - entries = self._package_file_cache.get(package, []) - for _, already_cached_path, __ in entries: - if already_cached_path == path_to_built_pkg: - print_debug( - f"Trying to cache {package} version {version}, but the version is already cached: {already_cached_path}" - ) - return - entries.append(new_entry) - self._package_file_cache[package] = entries - self._clean_pkg_cache(package) - - def _clean_pkg_cache(self, package: str): - oldest_path = None - oldest_timestamp = None - index_of_oldest = None - - entries = self._package_file_cache[package] - print_debug(f"Package cache has {len(entries)} entries.") - - if len(entries) <= conf.number_of_packages_stored_in_cache: - print_debug("Old files will not be removed.") - return - - for index, entry in enumerate(entries): - _, path, timestamp = entry - if oldest_timestamp is None or oldest_timestamp > timestamp: - oldest_timestamp = timestamp - oldest_path = path - index_of_oldest = index - - print_debug(f"Oldest cached file for {package} is '{oldest_path}'.") - if oldest_path is None: - return - assert index_of_oldest is not None - - entries.pop(index_of_oldest) - if os.path.exists(oldest_path): - print_debug(f"Removing '{oldest_path}' from the package cache.") - try: - os.remove(oldest_path) - except OSError as e: - print_error(f"{e}") - print_error(f"Failed to remove file '{oldest_path}' from the package cache.") - print_continuation("You'll have to remove the file manually.") - - self._package_file_cache[package] = entries - - def save(self): - """ - Writes the store to a file. - """ - - path = os.path.join(_STORE_SAVE_DIR, _STORE_SAVE_FILENAME) - - print_debug(f"Writing Store to '{path}'.") - - d = { - "source_file": self.source_file, - "allow_running_source_without_prompt": self.allow_running_source_without_prompt, - "enabled_systemd_units": self.enabled_systemd_units, - "enabled_user_systemd_units": self._enabled_user_systemd_units, - "enabled_modules": self.enabled_modules, - "created_files": self.created_files, - "package_file_cache": self._package_file_cache, - "pkgbuild_git_commits": self.pkgbuild_latest_reviewed_commits, - } - - try: - os.makedirs(_STORE_SAVE_DIR, exist_ok=True) - with open(path, "wt", encoding="utf-8") as file: - json.dump(d, file) - except OSError as e: - print_error(f"{e}") - raise err.UserFacingError("Failed to save decman store.") from e - - @staticmethod - def restore() -> "Store": - """ - Reads a saved Store from a file if it exists. - """ - path = os.path.join(_STORE_SAVE_DIR, _STORE_SAVE_FILENAME) - - print_debug(f"Reading Store from '{path}'.") - - try: - store = Store() - - if not os.path.exists(path): - return store - - with open(path, "rt", encoding="utf-8") as file: - d = json.load(file) - - store.source_file = d.get("source_file", None) - store.allow_running_source_without_prompt = d.get( - "allow_running_source_without_prompt", False - ) - store.enabled_systemd_units = d.get( - "enabled_systemd_units", - [], - ) - store._enabled_user_systemd_units = d.get( - "enabled_user_systemd_units", - [], - ) - store.enabled_modules = d.get("enabled_modules", {}) - store.created_files = d.get("created_files", []) - store._package_file_cache = d.get("package_file_cache", {}) - store.pkgbuild_latest_reviewed_commits = d.get( - "pkgbuild_git_commits", - {}, - ) - - return store - except json.JSONDecodeError as e: - print_error(f"{e}") - raise err.UserFacingError("Failed to parse decman store json.") from e - except OSError as e: - print_error(f"{e}") - raise err.UserFacingError("Failed to read saved decman store.") from e - - -class Source: - """ - Configuration that describes a system. - """ - - def __init__( - self, - pacman_packages: set[str], - aur_packages: set[str], - user_packages: set[decman.UserPackage], - ignored_packages: set[str], - systemd_units: set[str], - systemd_user_units: dict[str, set[str]], - files: dict[str, decman.File], - directories: dict[str, decman.Directory], - 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.aur_packages = aur_packages - self.user_packages = user_packages - self.ignored_packages = ignored_packages - self.systemd_units = systemd_units - self.systemd_user_units = systemd_user_units - self.files = files - self.directories = directories - 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): - """ - Runs on_enable of every module that was now enabled. - """ - for module in self.modules: - if module.enabled and module.name not in store.enabled_modules: - module.on_enable() - - def run_on_disable(self, store: Store): - """ - Runs on_disable of every module that was now disabled. - """ - for module in self.modules: - if not module.enabled and module.name in store.enabled_modules: - module.on_disable() - - def run_after_update(self): - """ - Runs after_update of every enabled module. - """ - for module in self.modules: - if module.enabled: - module.after_update() - - def run_after_version_change(self, store: Store): - """ - Runs after_version_change of every enabled module that has it's version changed. - """ - for module in self.modules: - if module.enabled and module.version != store.enabled_modules.get( - module.name, module.version - ): - module.after_version_change() - elif module.enabled and module.name not in store.enabled_modules: - module.after_version_change() - - def create_all_files(self, only_print: bool) -> list[str]: - """ - Creates all files and returns them. The files created are based on the specified files, - directories and modules. - """ - created_files = [] - - def install_files( - files: dict[str, decman.File], - variables: typing.Optional[dict[str, str]] = None, - ): - for target, file in files.items(): - created_files.append(target) - - if only_print: - continue - - try: - print_debug(f"Installing file to {target}.") - file.copy_to(target, variables) - except OSError as e: - print_error(f"{e}") - raise err.UserFacingError(f"Failed to install file to {target}.") from e - - def install_dirs( - dirs: dict[str, decman.Directory], - variables: typing.Optional[dict[str, str]] = None, - ): - for target, directory in dirs.items(): - try: - print_debug(f"Installing directory to {target}.") - created_files.extend(directory.copy_to(target, variables, only_print)) - except OSError as e: - print_error(f"{e}") - raise err.UserFacingError(f"Failed to install directory to {target}.") from e - - install_files(self.files) - install_dirs(self.directories) - - for module in self.modules: - if module.enabled: - install_files(module.files(), module.file_variables()) - install_dirs(module.directories(), module.file_variables()) - - return created_files - - def all_file_targets(self) -> list[str]: - """ - Returns all file targets combined. - """ - all_files = [] - all_files.extend(self.files.keys()) - - for module in self.modules: - if module.enabled: - all_files.extend(module.files().keys()) - - return all_files - - def all_directory_targets(self) -> list[str]: - """ - Returns all directory targets combined. - """ - all_dirs = [] - all_dirs.extend(self.directories.keys()) - - for module in self.modules: - if module.enabled: - all_dirs.extend(module.directories().keys()) - - return all_dirs - - def files_to_remove(self, store: Store, created_files: list[str]) -> list[str]: - """ - Returns all files that should be removed. - """ - to_remove = [] - for path in store.created_files: - if path not in created_files: - to_remove.append(path) - return to_remove - - def units_to_enable(self, store: Store) -> list[str]: - """ - Returns all systemd units that should be enabled. - """ - result = [] - for unit in self._all_units(): - if unit not in store.enabled_systemd_units: - result.append(unit) - return result - - def units_to_disable(self, store: Store) -> list[str]: - """ - Returns all systemd units that should be disabled. - """ - result = [] - for unit in store.enabled_systemd_units: - if unit not in self._all_units(): - result.append(unit) - return result - - def user_units_to_enable(self, store: Store) -> dict[str, list[str]]: - """ - Returns all user systemd units that should be enabled. - """ - result = {} - for user, units in self._all_user_units().items(): - for unit in units: - if not store.is_systemd_used_unit_enabled(user, unit): - entry = result.get(user, []) - entry.append(unit) - result[user] = entry - return result - - def user_units_to_disable(self, store: Store) -> dict[str, list[str]]: - """ - Returns all user systemd units that should be disabled. - """ - result = {} - for user, unit in store.get_enabled_user_systemd_units(): - if unit not in self._all_user_units().get(user, set()): - entry = result.get(user, []) - entry.append(unit) - result[user] = entry - return result - - def packages_to_remove(self, currently_installed_packages: list[str]) -> list[str]: - """ - Returns all packages that should be removed. This includes pacman, aur and user packages. - """ - result = [] - for pkg in currently_installed_packages: - if pkg in self.ignored_packages: - continue - if pkg not in self._all_pkgs(): - result.append(pkg) - return result - - def pacman_packages_to_install(self, currently_installed_packages: list[str]) -> list[str]: - """ - Returns all pacman packages that should be installed. - """ - result = [] - for pkg in self._all_pacman_pkgs(): - if pkg in self.ignored_packages: - continue - if pkg not in currently_installed_packages: - result.append(pkg) - return result - - def foreign_packages_to_install(self, currently_installed_packages: list[str]) -> list[str]: - """ - Returns all aur and user packages that should be installed. - """ - result = [] - for pkg in self._all_foreign_pkgs(): - if pkg in self.ignored_packages: - continue - if pkg not in currently_installed_packages: - result.append(pkg) - 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]]: - """ - Returns all enabled modules and their versions. - """ - result = [] - for module in self.modules: - if module.enabled: - result.append((module.name, module.version)) - return result - - def all_user_pkgs(self) -> set[decman.UserPackage]: - """ - Returns all active UserPackages. - """ - result = set() - result.update(self.user_packages) - for module in self.modules: - if module.enabled: - result.update(module.user_packages()) - return result - - def _all_pacman_pkgs(self) -> set[str]: - result = set() - result.update(self.pacman_packages) - for module in self.modules: - if module.enabled: - result.update(module.pacman_packages()) - 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]: - result = set() - result.update(self.aur_packages) - result.update(map(lambda p: p.pkgname, self.user_packages)) - for module in self.modules: - if module.enabled: - result.update(module.aur_packages()) - result.update(map(lambda p: p.pkgname, module.user_packages())) - return result - - def _all_pkgs(self) -> set[str]: - result = set() - result.update(self._all_pacman_pkgs()) - result.update(self._all_foreign_pkgs()) - return result - - def _all_units(self) -> set[str]: - result = set() - result.update(self.systemd_units) - for module in self.modules: - if module.enabled: - result.update(module.systemd_units()) - return result - - def _all_user_units(self) -> dict[str, set[str]]: - result = self.systemd_user_units - for module in [m for m in self.modules if m.enabled]: - module_user_units: dict[str, list[str]] = module.systemd_user_units() - for user in module_user_units.keys(): - if user not in result: - result[user] = set() - result[user].update(module_user_units[user]) - return result - - -class Pacman: - """ - Interface for interacting with pacman. - """ - - def __init__(self): - self._installable = {} - - def get_installed(self) -> list[str]: - """ - Returns a list of installed packages. - """ - - try: - packages = ( - subprocess.run( - conf.commands.list_pkgs(), - check=True, - stdout=subprocess.PIPE, - ) - .stdout.decode() - .strip() - .split("\n") - ) - return packages - except subprocess.CalledProcessError as error: - raise err.UserFacingError( - f"Failed to get installed packages using '{error.cmd}'. Output: {error.stdout}." - ) from error - - def is_installable(self, dep: str) -> bool: - """ - Returns True if a dependency can be installed using pacman. - """ - if dep in self._installable: - return self._installable[dep] - - result = ( - subprocess.run( - conf.commands.is_installable(dep), check=False, capture_output=True - ).returncode - == 0 - ) - self._installable[dep] = result - return result - - def get_versioned_foreign_packages(self) -> list[tuple[str, str]]: - """ - Returns a list of installed packages and their versions that aren't from pacman databases, - basically AUR packages. - """ - try: - output = ( - subprocess.run( - conf.commands.list_foreign_pkgs_versioned(), - check=True, - stdout=subprocess.PIPE, - ) - .stdout.decode() - .strip() - .split("\n") - ) - except subprocess.CalledProcessError as error: - raise err.UserFacingError( - f"Failed to get foreign packages using '{error.cmd}'. Output: {error.stdout}." - ) from error - - try: - return [(line.split(" ")[0], line.split(" ")[1]) for line in output] - except IndexError as error: - raise err.UserFacingError( - f"Failed to parse foreign packages from pacman output. Output: {output}" - ) from error - - def install(self, packages: list[str]): - """ - Installs the given packages. - """ - if not packages: - return - - returncode, output = echo_and_capture_command(conf.commands.install_pkgs(packages)) - if returncode != 0: - raise err.UserFacingError( - f"Failed to install packages using pacman. Process exited with code {returncode}." - ) - if conf.print_pacman_output_highlights: - print_highlighted_pacman_messages(output) - - try: - subprocess.run( - conf.commands.set_as_explicitly_installed(packages), - check=True, - capture_output=conf.suppress_command_output, - ) - except subprocess.CalledProcessError as error: - raise err.UserFacingError( - "Failed to set packages as explicitly installed using pacman." - ) from error - - def install_dependencies(self, deps: list[str]): - """ - Installs the given dependencies. - """ - if not deps: - return - - returncode, output = echo_and_capture_command(conf.commands.install_deps(deps)) - if returncode != 0: - raise err.UserFacingError( - f"Failed to install packages as dependencies using pacman. Process exited with code {returncode}." - ) - if conf.print_pacman_output_highlights: - print_highlighted_pacman_messages(output) - - def install_files(self, files: list[str], as_explicit: list[str]): - """ - Installs the given files first as dependencies. Then the packages listed in as_explicit are - installed explicitly. - """ - if not files: - return - - returncode, output = echo_and_capture_command(conf.commands.install_files(files)) - if returncode != 0: - raise err.UserFacingError( - f"Failed to install package files using pacman. Process exited with code {returncode}." - ) - if conf.print_pacman_output_highlights: - print_highlighted_pacman_messages(output) - - try: - if as_explicit: - subprocess.run( - conf.commands.set_as_explicitly_installed(as_explicit), - check=True, - capture_output=conf.suppress_command_output, - ) - except subprocess.CalledProcessError as error: - if conf.suppress_command_output: - print_error("Output:") - print_continuation(error.output) - raise err.UserFacingError( - "Failed to set packages as explicitly installed using pacman." - ) from error - - def upgrade(self): - """ - Upgrades all packages. - """ - returncode, output = echo_and_capture_command(conf.commands.upgrade()) - if returncode != 0: - raise err.UserFacingError( - f"Failed to upgrade packages using pacman. Process exited with code {returncode}." - ) - if conf.print_pacman_output_highlights: - print_highlighted_pacman_messages(output) - - def remove(self, packages: list[str]): - """ - Removes the given packages. - """ - if not packages: - return - - returncode, output = echo_and_capture_command(conf.commands.remove(packages)) - if returncode != 0: - raise err.UserFacingError( - f"Failed to remove packages using pacman. Process exited with code {returncode}." - ) - if conf.print_pacman_output_highlights: - print_highlighted_pacman_messages(output) - - -def print_highlighted_pacman_messages(output: str): - """ - Prints lines that contain pacman output keywords. - """ - print_summary("Pacman output highlights:") - lines = output.split("\n") - for index, line in enumerate(lines): - for keyword in conf.pacman_output_keywords: - if keyword.lower() in line.lower(): - print_summary(f"lines: {index}-{index + 2}") - if index >= 1: - print_continuation(lines[index - 1]) - print_continuation(line) - if index + 1 < len(lines): - print_continuation(lines[index + 1]) - print_continuation("") - - # Break, as to not print the same line again if it contains multiple keywords - break - - -def echo_and_capture_command(program: list[str]) -> tuple[int, str]: - """ - Runs the given CLI program and arguments. - - Returns a tuple containing the return code of the program as well as all output of the program. - """ - - output = "" - - def read(fd): - nonlocal output - buffer = os.read(fd, 1024) - output += buffer.decode(encoding="utf-8") - return buffer - - returncode = os.waitstatus_to_exitcode(pty.spawn(program, read)) - - return (returncode, output) - - -def get_user_info(username: str) -> tuple[int, int]: - info = pwd.getpwnam(username) - return (info.pw_uid, info.pw_gid) - - -class Flatpak: - def __init__(self) -> None: - pass - - def get_installed(self, as_user: bool = False, which_user: str = "") -> list[str]: - """ - Return all of the installed applications. Dependencies and runtimes are exluded since they will not be explicitly installed and thus flatpak will manage them. - """ - 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: - """ - Interface for interacting with systemd. - """ - - def __init__(self, state: Store): - self.state = state - - def enable_units(self, units: list[str]): - """ - Enables the given units. - """ - if not units: - return - - try: - subprocess.run( - conf.commands.enable_units(units), - check=True, - capture_output=conf.suppress_command_output, - ) - except subprocess.CalledProcessError as error: - raise err.UserFacingError(f"Failed to enable systemd units: {units}") from error - self.state.enabled_systemd_units += units - - def disable_units(self, units: list[str]): - """ - Disables the given units. - """ - if not units: - return - - try: - subprocess.run( - conf.commands.disable_units(units), - check=True, - capture_output=conf.suppress_command_output, - ) - except subprocess.CalledProcessError as error: - raise err.UserFacingError(f"Failed to disable systemd units: {units}") from error - for unit in units: - try: - self.state.enabled_systemd_units.remove(unit) - except ValueError: - pass - - def enable_user_units(self, units: list[str], user: str): - """ - Enables the given units for the given user. - """ - if not units: - return - - try: - subprocess.run( - conf.commands.enable_user_units(units, user), - check=True, - capture_output=conf.suppress_command_output, - ) - except subprocess.CalledProcessError as error: - raise err.UserFacingError( - f"Failed to enable systemd units: {units} for {user}." - ) from error - - for unit in units: - self.state.add_enabled_user_systemd_unit(user, unit) - - def disable_user_units(self, units: list[str], user: str): - """ - Disables the given units for the given user. - """ - if not units: - return - - try: - subprocess.run( - conf.commands.disable_user_units(units, user), - check=True, - capture_output=conf.suppress_command_output, - ) - except subprocess.CalledProcessError as error: - raise err.UserFacingError( - f"Failed to disable systemd units: {units} for {user}." - ) from error - - for unit in units: - self.state.remove_enabled_user_systemd_unit(user, unit) diff --git a/src/decman/lib/fpm.py b/src/decman/lib/fpm.py deleted file mode 100644 index f0ad349..0000000 --- a/src/decman/lib/fpm.py +++ /dev/null @@ -1,1135 +0,0 @@ -""" -Module for interacting with the AUR. - -Optional dependencies are ignored when installing AUR packages. - -Terminology: - -- package (pkg): name of an package from pacman repos or AUR -- dependency (dep): (virtual) package required when building and running a package -- dependency package (dep pkg): dependency that has been resolved to a package name -- all dependencies: normal dependencies and build dependencies combined -""" - -import os -import re -import shutil -import subprocess -import typing - -import requests - -import decman -import decman.config as conf -import decman.error as err -import decman.lib as l - - -def strip_dependency(dep: str) -> str: - """ - Removes version spefications from a dependency name. - """ - rx = re.compile("(=.*|>.*|<.*)") - return rx.sub("", dep) - - -def is_devel(package: str) -> bool: - """ - Returns True if the given package is a devel package. - """ - devel_suffixes = [ - "-git", - "-hg", - "-bzr", - "-svn", - "-cvs", - "-darcs", - ] - for suffix in devel_suffixes: - if package.endswith(suffix): - return True - return False - - -class PackageInfo: - """ - Simplified information about an package. - - In case of AUR packages, these are fetched from AUR RPC. - """ - - def __init__( - self, - pkgname: str, - 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.pkgbase = pkgbase - self.version = version - self.provides = provides - self.git_url = git_url - - self.foreign_dependencies_stripped = [] - self.foreign_make_dependencies_stripped = [] - self.foreign_check_dependencies_stripped = [] - self.pacman_dependencies = [] - self.pacman_make_dependencies = [] - self.pacman_check_dependencies = [] - - for dep in dependencies: - if pacman.is_installable(dep): - self.pacman_dependencies.append(dep) - else: - self.foreign_dependencies_stripped.append(strip_dependency(dep)) - - for make_dep in make_dependencies: - if pacman.is_installable(make_dep): - self.pacman_make_dependencies.append(make_dep) - else: - self.foreign_make_dependencies_stripped.append(strip_dependency(make_dep)) - - for check_dep in check_dependencies: - if pacman.is_installable(check_dep): - self.pacman_check_dependencies.append(check_dep) - else: - self.foreign_check_dependencies_stripped.append(strip_dependency(check_dep)) - - def pkg_file_prefix(self) -> str: - """ - Returns the beginning of the file created from building this package. - """ - return f"{self.pkgname}-{self.version}" - - @staticmethod - def from_user_package(user_package: decman.UserPackage, pacman: l.Pacman) -> "PackageInfo": - """ - Converts a UserPackage to PackageInfo - """ - return PackageInfo( - pkgname=user_package.pkgname, - pkgbase=user_package.pkgbase, - version=user_package.version, - provides=user_package.provides, - dependencies=user_package.dependencies, - make_dependencies=user_package.make_dependencies, - check_dependencies=user_package.check_dependencies, - git_url=user_package.git_url, - pacman=pacman, - ) - - -class ForeignPackage: - """ - Class used to keep track of foreign recursive dependency packages of an foreign package. - """ - - def __init__(self, name: str): - self.name = name - self._all_recursive_foreign_deps = set() - - def __eq__(self, value: object, /) -> bool: - if isinstance(value, self.__class__): - return ( - self.name == value.name - and self._all_recursive_foreign_deps == value._all_recursive_foreign_deps - ) - return False - - def __hash__(self) -> int: - return self.name.__hash__() - - def __repr__(self) -> str: - return f"{self.name}: {{{' '.join(self._all_recursive_foreign_deps)}}}" - - def __str__(self) -> str: - return f"{self.name}" - - def add_foreign_dependency_packages(self, package_names: typing.Iterable[str]): - """ - Adds dependencies to the package. - """ - self._all_recursive_foreign_deps.update(package_names) - - def get_all_recursive_foreign_dep_pkgs(self) -> set[str]: - """ - Returns all dependencies and sub-dependencies of the package. - """ - return set(self._all_recursive_foreign_deps) - - -class DepNode: - """ - A Node of the DepGraph - """ - - def __init__(self, package: ForeignPackage) -> None: - self.parents: dict[str, DepNode] = {} - self.children: dict[str, DepNode] = {} - self.pkg = package - - def is_pkgname_in_parents_recursive(self, pkgname: str) -> bool: - """ - Returns True if the given package name is in the parents of this DepNode. - """ - for name, parent in self.parents.items(): - if name == pkgname or parent.is_pkgname_in_parents_recursive(pkgname): - return True - return False - - -class DepGraph: - """ - Represents a graph between foreign packages - """ - - def __init__(self): - self.package_nodes: dict[str, DepNode] = {} - self._childless_node_names = set() - - def add_requirement(self, child_pkgname: str, parent_pkgname: typing.Optional[str]): - """ - 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. - """ - child_node = self.package_nodes.get(child_pkgname, DepNode(ForeignPackage(child_pkgname))) - self.package_nodes[child_pkgname] = child_node - - if len(child_node.children) == 0: - self._childless_node_names.add(child_pkgname) - - if parent_pkgname is None: - return - - parent_node = self.package_nodes[parent_pkgname] - - if parent_node.is_pkgname_in_parents_recursive(child_pkgname): - raise err.UserFacingError( - f"Foreign package dependency cycle detected involving '{child_pkgname}' \ -and '{parent_pkgname}'. Foreign package dependencies are also required \ -during package building and therefore dependency cycles cannot be handled." - ) - - parent_node.children[child_pkgname] = child_node - child_node.parents[parent_pkgname] = parent_node - - if parent_pkgname in self._childless_node_names: - self._childless_node_names.remove(parent_pkgname) - - def get_and_remove_outer_dep_pkgs(self) -> list[ForeignPackage]: - """ - Returns all childless nodes of the dependency package graph and removes them. - """ - new_childless_node_names = set() - result = [] - for childless_node_name in self._childless_node_names: - childless_node = self.package_nodes[childless_node_name] - - for parent in childless_node.parents.values(): - new_deps = childless_node.pkg.get_all_recursive_foreign_dep_pkgs() - new_deps.add(childless_node.pkg.name) - parent.pkg.add_foreign_dependency_packages(new_deps) - del parent.children[childless_node_name] - if len(parent.children) == 0: - new_childless_node_names.add(parent.pkg.name) - - result.append(childless_node.pkg) - self._childless_node_names = new_childless_node_names - return result - - -class ExtendedPackageSearch: - """ - Allows searcing for packages / providers from the AUR as well as user defined sources. - - Results are cached and user defined packages are preferred. - """ - - def __init__(self, pacman: l.Pacman): - self._pacman = pacman - self._package_info_cache: dict[str, PackageInfo] = {} - self._dep_provider_cache: dict[str, PackageInfo] = {} - self._known_providers_cache: dict[str, list[str]] = {} - self._user_packages: list[PackageInfo] = [] - - def add_user_pkg(self, user_pkg: PackageInfo): - """ - Adds the given package to user packages. - """ - 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]): - """ - Tried caching the given packages. Virtual packages may not be cached. - - This can be used before calling get_package_info or find_provider multiple individual - times, because then those methods don't have to make new AUR RPC requests. - """ - - packages = list(filter(lambda p: p not in self._package_info_cache, packages)) - - if len(packages) == 0: - return - - l.print_debug(f"Trying to cache {packages}.") - - max_pkgs_per_request = 200 - - while packages: - to_request = map(lambda p: f"arg[]={p}", packages[:max_pkgs_per_request]) - packages = packages[max_pkgs_per_request:] - - url = f"https://aur.archlinux.org/rpc/v5/info?{'&'.join(to_request)}" - l.print_debug(f"Request URL = {url}") - - try: - request = requests.get(url, timeout=conf.aur_rpc_timeout) - d = request.json() - - if d["type"] == "error": - raise err.UserFacingError(f"AUR RPC returned error: {d['error']}") - - for result in d["results"]: - pkgname = result["Name"] - - if pkgname in self._package_info_cache: - continue - - for user_package in self._user_packages: - if user_package.pkgname == pkgname: - l.print_debug(f"'{pkgname}' found in user packages.") - self._cache_pkg(user_package) - break - else: # if not in user_packages then: - info = PackageInfo( - pkgname=result["Name"], - pkgbase=result["PackageBase"], - version=result["Version"], - dependencies=result.get("Depends", []), - make_dependencies=result.get("MakeDepends", []), - check_dependencies=result.get("CheckDepends", []), - provides=result.get("Provides", []), - git_url=f"https://aur.archlinux.org/{result['PackageBase']}.git", - pacman=self._pacman, - ) - self._cache_pkg(info) - - l.print_debug("Request completed.") - except (requests.RequestException, KeyError) as e: - l.print_error(f"{e}") - raise err.UserFacingError( - f"Failed to fetch package information for {packages} from AUR RPC." - ) from e - - def get_package_info(self, package: str) -> typing.Optional[PackageInfo]: - """ - Returns information about a package. - - If the package is not user defined, fetches information from the AUR. - Returns None if no such AUR package exists. - """ - l.print_debug(f"Getting info for package '{package}'.") - - if package in self._package_info_cache: - l.print_debug(f"'{package}' found in cache.") - return self._package_info_cache[package] - - for user_package in self._user_packages: - if user_package.pkgname == package: - l.print_debug(f"'{package}' found in user packages.") - self._cache_pkg(user_package) - return user_package - - url = f"https://aur.archlinux.org/rpc/v5/info/{package}" - l.print_debug(f"Requesting info for '{package}' from AUR. URL = {url}") - try: - request = requests.get(url, timeout=conf.aur_rpc_timeout) - d = request.json() - - if d["type"] == "error": - raise err.UserFacingError(f"AUR RPC returned error: {d['error']}") - - if d["resultcount"] == 0: - l.print_debug(f"'{package}' not found.") - return None - - l.print_debug(f"'{package}' found from AUR.") - - result = d["results"][0] - info = PackageInfo( - pkgname=result["Name"], - pkgbase=result["PackageBase"], - version=result["Version"], - dependencies=result.get("Depends", []), - make_dependencies=result.get("MakeDepends", []), - check_dependencies=result.get("CheckDepends", []), - provides=result.get("Provides", []), - git_url=f"https://aur.archlinux.org/{result['PackageBase']}.git", - pacman=self._pacman, - ) - - self._cache_pkg(info) - - return info - except (requests.RequestException, KeyError) as e: - l.print_error(f"{e}") - raise err.UserFacingError( - f"Failed to fetch package information for {package} from AUR RPC." - ) from e - - def find_provider(self, stripped_dependency: str) -> typing.Optional[PackageInfo]: - """ - Finds a provider for a dependency. - - May prompt the user to select if multiple are available. - """ - l.print_debug(f"Finding provider for '{stripped_dependency}'.") - - if stripped_dependency in self._dep_provider_cache: - l.print_debug(f"'{stripped_dependency}' found in cache.") - return self._dep_provider_cache[stripped_dependency] - - l.print_debug("Are there exact name matches?") - - exact_name_match = self.get_package_info(stripped_dependency) - - if exact_name_match is not None: - l.print_debug("Exact name match found.") - self._dep_provider_cache[stripped_dependency] = exact_name_match - return exact_name_match - - l.print_debug("No exact name matches found. Finding providers.") - - known_pkg_results = self._known_providers_cache.get(stripped_dependency, []) - for user_package in self._user_packages: - if ( - stripped_dependency in user_package.provides - and stripped_dependency not in known_pkg_results - ): - known_pkg_results.append(user_package.pkgname) - - if len(known_pkg_results) == 1: - pkg = self.get_package_info(known_pkg_results[0]) - assert pkg is not None - l.print_debug( - f"Single provider for '{stripped_dependency}' found in known packages: '{pkg}'." - ) - self._dep_provider_cache[stripped_dependency] = pkg - return pkg - - if len(known_pkg_results) > 1: - return self._choose_provider(stripped_dependency, known_pkg_results, "user packages") - - url = f"https://aur.archlinux.org/rpc/v5/search/{stripped_dependency}?by=provides" - l.print_debug(f"Requesting providers for '{stripped_dependency}' from AUR. URL = {url}") - try: - request = requests.get(url, timeout=conf.aur_rpc_timeout) - d = request.json() - - if d["type"] == "error": - raise err.UserFacingError(f"AUR RPC returned error: {d['error']}") - - if d["resultcount"] == 0: - l.print_debug(f"'{stripped_dependency}' not found.") - return None - - results = list(map(lambda r: r["Name"], d["results"])) - - if len(results) == 1: - pkgname = results[0] - l.print_debug( - f"Single provider for '{stripped_dependency}' found from AUR: '{pkgname}'" - ) - info = self.get_package_info(pkgname) - return info - - return self._choose_provider(stripped_dependency, results, "AUR") - except (requests.RequestException, KeyError) as e: - l.print_error(f"{e}") - raise err.UserFacingError( - f"Failed to search for {stripped_dependency} from AUR RPC." - ) from e - - def _choose_provider( - self, dep: str, possible_providers: list[str], where: str - ) -> typing.Optional[PackageInfo]: - min_selection = 1 - max_selection = len(possible_providers) - l.print_summary(f"Found {len(possible_providers)} providers for {dep} from {where}.") - - providers = "Providers: " - for index, name in enumerate(possible_providers): - providers += f"{index + 1}:{name} " - l.print_summary(providers) - - selection = l.prompt_number( - f"Select a provider [{min_selection}-{max_selection}] (default: {min_selection}): ", - min_selection, - max_selection, - default=min_selection, - ) - - info = self.get_package_info(possible_providers[selection - 1]) - if info is not None: - self._dep_provider_cache[dep] = info - return info - - -class ResolvedDependencies: - """ - Result of dependency resolution. - """ - - def __init__(self): - self.pacman_deps: set[str] = set() - self.foreign_pkgs: set[str] = set() - self.foreign_dep_pkgs: set[str] = set() - self.foreign_build_dep_pkgs: set[str] = set() - self.build_order: list[str] = [] - self.packages: dict[str, ForeignPackage] = {} - self._pkgbases_to_pkgs: dict[str, set[str]] = {} - self._pkgs_to_pkgbases: dict[str, str] = {} - - def add_pkgbase_info(self, pkgname: str, pkgbase: str): - """ - Adds information about a which package belongs in which package base. - """ - pkgs = self._pkgbases_to_pkgs.get(pkgbase, set()) - pkgs.add(pkgname) - self._pkgbases_to_pkgs[pkgbase] = pkgs - self._pkgs_to_pkgbases[pkgname] = pkgbase - - def get_pkgbase(self, pkgname: str) -> str: - """ - Returns the package base of an package. - """ - return self._pkgs_to_pkgbases[pkgname] - - def get_pkgs_with_common_pkgbase(self, pkgname: str) -> set[str]: - """ - Returns all packages that have the same package base as the given package. - """ - pkgbase = self._pkgs_to_pkgbases[pkgname] - return self._pkgbases_to_pkgs[pkgbase] - - def all_pkgbases(self) -> list[str]: - """ - Returns all pkgbases. - """ - return list(self._pkgbases_to_pkgs) - - def get_some_pkgname(self, pkgbase: str) -> str: - """ - Returns some package name that the given pkgbase has. - """ - return list(self._pkgbases_to_pkgs[pkgbase])[0] - - -class ForeignPackageManager: - """ - Class for dealing with foreign packages. - """ - - def __init__(self, store: l.Store, pacman: l.Pacman, search: ExtendedPackageSearch): - self._store = store - self._pacman = pacman - self._search = search - - def upgrade( - self, - upgrade_devel: bool = False, - force: bool = False, - ignored_pkgs: typing.Optional[set[str]] = None, - ): - """ - Upgrades all foreign packages. - """ - if ignored_pkgs is None: - ignored_pkgs = set() - - l.print_summary("Determining foreign packages to upgrade.") - - all_foreign_pkgs = self._pacman.get_versioned_foreign_packages() - all_explicit_pkgs = set(self._pacman.get_installed()) - l.print_debug(f"Foreign packages to check for upgrades: {all_foreign_pkgs}") - - self._search.try_caching_packages(list(map(lambda p: p[0], all_foreign_pkgs))) - - as_explicit = [] - as_deps = [] - for pkg, ver in all_foreign_pkgs: - if pkg in ignored_pkgs: - continue - - info = self._search.get_package_info(pkg) - if info is None: - raise err.UserFacingError( - f"Failed to find '{pkg}' from AUR or user provided packages." - ) - - if self.should_upgrade_package(pkg, ver, info.version, upgrade_devel): - if pkg in all_explicit_pkgs: - as_explicit.append(pkg) - else: - as_deps.append(pkg) - - l.print_debug(f"The following foreign packages will be upgraded: {' '.join(as_explicit)}") - - self.install(as_explicit, as_deps, force) - - def install( - self, - foreign_pkgs: list[str], - foreign_dep_pkgs: typing.Optional[list[str]] = None, - force: bool = False, - ): - """ - Installs the given foreign packages and their dependencies (both pacman/AUR). - """ - - if foreign_dep_pkgs is None: - foreign_dep_pkgs = [] - - if len(foreign_pkgs) == 0 and len(foreign_dep_pkgs) == 0: - return - - resolved_dependencies = self.resolve_dependencies(foreign_pkgs, foreign_dep_pkgs) - - l.print_list( - "The following foreign packages will be installed explicitly:", - list(resolved_dependencies.foreign_pkgs), - level=l.SUMMARY, - ) - - l.print_list( - "The following foreign packages will be installed as dependencies:", - list(resolved_dependencies.foreign_dep_pkgs), - level=l.SUMMARY, - ) - - l.print_list( - "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), - level=l.SUMMARY, - ) - - if not l.prompt_confirm("Proceed?", default=True): - raise err.UserFacingError("Installing aborted.") - - l.print_summary("Installing foreign package dependencies from pacman.") - self._pacman.install_dependencies(list(resolved_dependencies.pacman_deps)) - - try: - with PackageBuilder(self._search, self._store, resolved_dependencies) as builder: - while resolved_dependencies.build_order: - to_build = resolved_dependencies.build_order.pop(0) - - pkgbase = resolved_dependencies.get_pkgbase(to_build) - package_names = resolved_dependencies.get_pkgs_with_common_pkgbase(to_build) - - packages = [ - resolved_dependencies.packages[pkgname] for pkgname in package_names - ] - - builder.build_packages(pkgbase, packages, force) - except (subprocess.CalledProcessError, OSError) as e: - l.print_error(f"{e}") - raise err.UserFacingError("Failed to build packages.") from e - - packages_to_install = list(resolved_dependencies.foreign_pkgs) - packages_to_install += list(resolved_dependencies.foreign_dep_pkgs) - - package_files_to_install = [] - for pkg in packages_to_install: - built_pkg = self._store.get_package(pkg) - assert built_pkg is not None - _, path = built_pkg - package_files_to_install.append(path) - - if package_files_to_install or force: - l.print_summary("Installing foreign packages.") - self._pacman.install_files( - package_files_to_install, - as_explicit=list(resolved_dependencies.foreign_pkgs), - ) - else: - l.print_summary("No packages to install.") - - def resolve_dependencies( - self, - foreign_pkgs: list[str], - foreign_dep_pkgs: typing.Optional[list[str]] = None, - ) -> ResolvedDependencies: - """ - Resolves foreign dependencies of foreign packages. - """ - - l.print_info("Resolving foreign package dependencies.") - l.print_debug(f"Packages: {foreign_pkgs}") - - if foreign_dep_pkgs is None: - foreign_dep_pkgs = [] - - result = ResolvedDependencies() - result.foreign_pkgs = set(foreign_pkgs) - result.foreign_dep_pkgs = set(foreign_dep_pkgs) - - graph = DepGraph() - - for name in foreign_pkgs + foreign_dep_pkgs: - graph.add_requirement(name, None) - - seen_packages = set(foreign_pkgs + foreign_dep_pkgs) - to_process = foreign_pkgs + foreign_dep_pkgs - total_processed = 0 - - self._search.try_caching_packages(to_process) - - def process_dep(pkgname: str, depname: str, add_to: set[str]): - dep_info = self._search.find_provider(depname) - - if dep_info is None: - raise err.UserFacingError( - f"Failed to find '{depname}' from AUR or user provided packages." - ) - - add_to.add(dep_info.pkgname) - - l.print_debug(f"Adding dependency {dep_info.pkgname} to package {pkgname}.") - graph.add_requirement(dep_info.pkgname, pkgname) - if dep_info.pkgname not in seen_packages: - to_process.append(dep_info.pkgname) - seen_packages.add(dep_info.pkgname) - - while to_process: - pkgname = to_process.pop() - - info = self._search.get_package_info(pkgname) - if info is None: - raise err.UserFacingError( - f"Failed to find '{pkgname}' from AUR or user provided packages." - ) - - result.pacman_deps.update(info.pacman_dependencies) - result.add_pkgbase_info(pkgname, info.pkgbase) - - build_deps = ( - info.foreign_make_dependencies_stripped + info.foreign_check_dependencies_stripped - ) - - self._search.try_caching_packages(info.foreign_dependencies_stripped + build_deps) - - for depname in info.foreign_dependencies_stripped: - process_dep(pkgname, depname, result.foreign_dep_pkgs) - - for depname in build_deps: - process_dep(pkgname, depname, result.foreign_build_dep_pkgs) - - total_processed += 1 - l.print_info(f"Progress: {total_processed}/{len(seen_packages)}.") - - l.print_info("Determining build order.") - - while True: - to_add = graph.get_and_remove_outer_dep_pkgs() - - if len(to_add) == 0: - break - - for pkg in to_add: - if pkg not in result.packages: - l.print_debug(f"Adding {pkg} to build_order.") - result.build_order.append(pkg.name) - result.packages[pkg.name] = pkg - - return result - - def should_upgrade_package( - self, - package: str, - installed_version: str, - fetched_version: str, - upgrade_devel=False, - ) -> bool: - """ - Returns True if a package should be upgraded. - """ - - if upgrade_devel and is_devel(package): - l.print_debug(f"Package {package} is devel package. It should be upgraded.") - return True - - try: - result = int( - subprocess.run( - conf.commands.compare_versions(installed_version, fetched_version), - check=True, - stdout=subprocess.PIPE, - ).stdout.decode() - ) - should_upgrade = result < 0 - l.print_debug( - f"Installed version is: {installed_version}. Available version is {fetched_version}. Should upgrade: {should_upgrade}" - ) - return should_upgrade - except (ValueError, subprocess.CalledProcessError) as error: - l.print_error(f"{error}") - raise err.UserFacingError("Failed to compare versions using vercmp.") from error - - -class PackageBuilder: - """ - Used for building packages in a chroot. - """ - - always_included_packages = ["base-devel", "git"] - - def __init__( - self, - search: ExtendedPackageSearch, - store: l.Store, - resolved_deps: ResolvedDependencies, - ): - self._search = search - self._store = store - self._resolved_deps = resolved_deps - self.chroot_wd_dir = os.path.join(conf.build_dir, "chroot") - self.chroot_dir = os.path.join(self.chroot_wd_dir, "root") - self.pkgbase_dir_map = {} - self.original_wd = "" - self._pkgs_in_chroot = set(PackageBuilder.always_included_packages) - self._pkgs_in_chroot.update(resolved_deps.pacman_deps) - - def __enter__(self): - self.store_wd() - self.create_build_environment() - - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.restore_wd() - self.remove_build_environment() - - def store_wd(self): - """ - Remembers the current working directory as the original working directory. - """ - self.original_wd = os.getcwd() - - def restore_wd(self): - """ - Returns to the original working directory. - """ - os.chdir(self.original_wd) - - def create_build_environment(self): - """ - Creates a new chroot and clones all PKGBUILDS. - """ - l.print_info("Creating a build environment..") - - if os.path.exists(conf.build_dir): - l.print_info("Removing previous build directory.") - self.remove_build_environment() - - l.print_info("Getting all PKGBUILDS.") - - # Set up PKGBUILDS - for pkgbase in self._resolved_deps.all_pkgbases(): - pkgbuild_dir = os.path.join(conf.build_dir, pkgbase) - self.pkgbase_dir_map[pkgbase] = pkgbuild_dir - os.makedirs(pkgbuild_dir) - os.chdir(pkgbuild_dir) - - git_url_info = self._search.get_package_info( - self._resolved_deps.get_some_pkgname(pkgbase) - ) - - # Because all dependencies and packages should be resolved during the creation - # of ResolvedDependencies. git_url should not be None. - assert git_url_info is not None - git_url = git_url_info.git_url - - l.print_debug(f"Git URL for '{pkgbase}' is '{git_url}'") - self._git_clone_and_review_pkgbuild(pkgbase, git_url) - shutil.chown(pkgbuild_dir, user=conf.makepkg_user) - - l.print_info("Creating a new chroot.") - os.makedirs(self.chroot_wd_dir) - - # Remove GNUPGHOME from mkarchroot environment variables since it may interfere with - # the chroot creation - mkarchroot_env_vars = os.environ.copy() - try: - del mkarchroot_env_vars["GNUPGHOME"] - l.print_debug("Removed GNUPGHOME variable from mkarchroot environment.") - except KeyError: - pass - - subprocess.run( - conf.commands.make_chroot(self.chroot_dir, list(self._pkgs_in_chroot)), - env=mkarchroot_env_vars, - check=True, - capture_output=conf.suppress_command_output, - ) - - def remove_build_environment(self): - """ - Deletes the build environment. - """ - shutil.rmtree(conf.build_dir) - - def build_packages(self, package_base: str, packages: list[ForeignPackage], force: bool): - """ - Builds package(s) with the same package base. - - Set force to true to force rebuilds of packages that are already cached - """ - - package_names = list(map(lambda p: p.name, packages)) - - # Rebuild is only needed if at least one package is not in the cache. - - if self._are_all_pkgs_cached(packages) and not force: - l.print_info(f"Skipped building '{' '.join(package_names)}'. Already up to date.") - return - - l.print_info(f"Building '{' '.join(package_names)}'.") - - chroot_new_pacman_pkgs, chroot_pkg_files = self._get_chroot_packages(packages) - - pkgbuild_dir = self.pkgbase_dir_map[package_base] - os.chdir(pkgbuild_dir) - - l.print_debug(f"Chroot dir is: '{self.chroot_dir}', pkgbuild dir is '{pkgbuild_dir}'.") - - l.print_info("Installing build dependencies to chroot.") - - subprocess.run( - conf.commands.install_chroot_packages( - self.chroot_dir, - chroot_new_pacman_pkgs + PackageBuilder.always_included_packages, - ), - check=True, - capture_output=conf.suppress_command_output, - ) - - l.print_info("Making package.") - - subprocess.run( - conf.commands.make_chroot_pkg(self.chroot_wd_dir, conf.makepkg_user, chroot_pkg_files), - check=True, - capture_output=conf.quiet_output, - ) - - for pkgname in package_names: - file = self._find_pkgfile(pkgname, pkgbuild_dir) - - dest = shutil.copy(file, conf.pkg_cache_dir) - - pkg_info = self._search.get_package_info(pkgname) - - # Because all dependencies and packages should be resolved during the creation - # of ResolvedDependencies. git_url should not be None. - assert pkg_info is not None - version = pkg_info.version - - l.print_debug(f"Adding '{pkgname}', version: '{version}' to cache as file '{dest}'.") - - self._store.add_package_to_cache(pkgname, version, dest) - - l.print_info("Removing build dependencies from chroot.") - - if len(chroot_new_pacman_pkgs) != 0: - to_remove = [] - for p in chroot_new_pacman_pkgs: - if p not in self._pkgs_in_chroot: - real_pkgname = ( - subprocess.run( - conf.commands.resolve_real_name(self.chroot_dir, p), - check=True, - 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)}'.") - - def _are_all_pkgs_cached(self, pkgs: list[ForeignPackage]) -> bool: - for pkg in pkgs: - cache_entry = self._store.get_package(pkg.name) - if cache_entry is None: - return False - cached_version, _ = cache_entry - - pkg_info = self._search.get_package_info(pkg.name) - - # Because all dependencies and packages should be resolved during the creation - # of ResolvedDependencies. git_url should not be None. - assert pkg_info is not None - fetched_version = pkg_info.version - - if cached_version != fetched_version or is_devel(pkg.name): - return False - return True - - def _get_chroot_packages( - self, pkgs_to_build: list[ForeignPackage] - ) -> tuple[list[str], list[str]]: - """ - Returns a tuple of pacman build dependencies and built foreign pkgs files that are needed - in the chroot before building. pkgs_to_build share the same pkgbase. - """ - chroot_pacman_build_deps = set() - chroot_foreign_pkgs = set() - - def add_to_pacman_build_deps(deps: list[str]): - for dep in deps: - if dep not in self._resolved_deps.pacman_deps: - chroot_pacman_build_deps.add(dep) - - for pkg in pkgs_to_build: - info = self._search.get_package_info(pkg.name) - # Because all dependencies and packages should be resolved during the creation - # of ResolvedDependencies. git_url should not be None. - assert info is not None - - add_to_pacman_build_deps(info.pacman_make_dependencies) - add_to_pacman_build_deps(info.pacman_check_dependencies) - - foreign_deps = pkg.get_all_recursive_foreign_dep_pkgs() - chroot_foreign_pkgs.update(foreign_deps) - - # Add pacman deps of foreign packages - for dep in foreign_deps: - dep_info = self._search.get_package_info(dep) - # Because all dependencies and packages should be resolved during the creation - # of ResolvedDependencies. git_url should not be None. - assert dep_info is not None - - add_to_pacman_build_deps(dep_info.pacman_make_dependencies) - add_to_pacman_build_deps(dep_info.pacman_check_dependencies) - - # Packages with the same pkgbase might depend on each other, - # but they don't need to be installed for the build to succeed. - for pkg in pkgs_to_build: - if pkg.name in chroot_foreign_pkgs: - chroot_foreign_pkgs.remove(pkg.name) - - chroot_foreign_pkg_files = [] - - for foreign_pkg in chroot_foreign_pkgs: - entry = self._store.get_package(foreign_pkg) - assert entry is not None, ( - "Build order determines that the dependencies are built \ -before and thus are found in the cache." - ) - - _, file = entry - - chroot_foreign_pkg_files.append(file) - - return (list(chroot_pacman_build_deps), chroot_foreign_pkg_files) - - def _find_pkgfile(self, pkgname: str, pkgbuild_dir: str) -> str: - # HACK: Because we don't know the pkgarch we can't be sure what is the build result. - # Instead: we just try with pre- and postfixes. - - matches = [] - - info = self._search.get_package_info(pkgname) - assert info is not None - prefix = info.pkg_file_prefix() - - for file in os.scandir(pkgbuild_dir): - if file.is_file() and file.name.startswith(prefix): - for ext in conf.valid_pkgexts: - if file.name.endswith(ext): - matches.append(file.path) - continue - - if len(matches) != 1: - raise err.UserFacingError( - f"Failed to build package '{pkgname}', because the pkg file cannot be determined. Possible files are: {matches}" - ) - - return matches[0] - - def _git_clone_and_review_pkgbuild(self, pkgbase: str, git_url: str): - """ - Clones an PKGBUILD to the current directory. - - The user is prompted to review the PKGBUILD and confirm if the package should be built. - """ - try: - subprocess.run( - conf.commands.git_clone(git_url, "."), - check=True, - capture_output=conf.suppress_command_output, - ) - - if l.prompt_confirm(f"Review PKGBUILD or show diff for {pkgbase}?", default=True): - latest_reviewed_commit = 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("."): - if file.is_file() and not file.name.startswith("."): - subprocess.run(conf.commands.review_file(file.path), check=True) - else: - subprocess.run(conf.commands.git_diff(latest_reviewed_commit), check=True) - - if l.prompt_confirm("Build this package?", default=True): - commit_id = ( - subprocess.run( - conf.commands.git_get_commit_id(), - check=True, - capture_output=True, - ) - .stdout.decode() - .strip() - ) - self._store.pkgbuild_latest_reviewed_commits[pkgbase] = commit_id - else: - raise err.UserFacingError("Building aborted.") - - except subprocess.CalledProcessError as error: - if conf.suppress_command_output: - l.print_error("Output:") - l.print_continuation(error.output) - raise err.UserFacingError( - f"Failed to clone and review PKGBUILD from {git_url}" - ) from error diff --git a/src/decman/py.typed b/src/decman/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 7e068a7..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -import os -import sys - -_SRC_PATH = os.path.join(os.path.dirname(__file__), "../src/") - -sys.path.append(_SRC_PATH) diff --git a/tests/manual/src/f1.txt b/tests/manual/src/f1.txt deleted file mode 100644 index a99c708..0000000 --- a/tests/manual/src/f1.txt +++ /dev/null @@ -1,3 +0,0 @@ -Simple text file with a %variable% - -twice: %another_variable% diff --git a/tests/manual/src/f2.sh b/tests/manual/src/f2.sh deleted file mode 100644 index 6b4a69e..0000000 --- a/tests/manual/src/f2.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash -# This file should be executable. -echo "Hello, world!" diff --git a/tests/manual/src/srcdir/1 b/tests/manual/src/srcdir/1 deleted file mode 100644 index e8183f0..0000000 --- a/tests/manual/src/srcdir/1 +++ /dev/null @@ -1,3 +0,0 @@ -1 -1 -1 diff --git a/tests/manual/src/srcdir/2 b/tests/manual/src/srcdir/2 deleted file mode 100644 index 083edaa..0000000 --- a/tests/manual/src/srcdir/2 +++ /dev/null @@ -1,3 +0,0 @@ -2 -2 -2 diff --git a/tests/manual/src/srcdir/image.png b/tests/manual/src/srcdir/image.png deleted file mode 100644 index 7245bb99891f3b3c07261c881a71f3324612ff3c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1923 zcmV-}2YmR6P)0gDgKsWKZRo7z_sb`bKP~5f3PM6cz!j z$ztR4Lq1k{QP%u`Icc2v=djYdWxSlqt5&jgv$Q>C47D`?l_z;_qqK8p5h*Ezhtb!^ zM<22K6>94M?i$awEhHtO)8TYtX{YieFYoppP>x(cATE~e+n9VWIvu^en9b~XiIx_C z>NBX-m`;)R9ECf0a4}9Nsc8UzH4z>L@L~b!>CBjpR?GXx*|Y^9JCn>z;>V#mkD9<(29x9itK&~6gi}LpGnDlLF)N!4(`v(I0?0nfVrcc9Yq^F0&NBH%t zer0&l#N^3DMPmD$LxOeXTY{b*JHyQ{O4hDO>u z0Gv)58fj?c{CR0~UkM8rpiyHsW4Du>OhLXh^ul&hQ_*UU6CJ-!1fkP8Ux zEF$ebbUHe$XtkuKQe5aeV0}GyJHYz&%t=G7254&oSd&HGvrPCFK|y@+1(A^?C;Kir zu(<;0VZbHJQC^fwm$>jD7K@hyeWsDIl+aN6`x!fyl`H(sXW;@=LAcyRN0FODLIRbQ z06{^_p9iq=5i}ayZYqv@kKpB0Hk~3Tm!>9w{(f?D$;su7w*cm(k&*)N=cByw76%Rj zj2lO84uH`JV6*Z1K{A%}^UdVH=p&#qxq!=;k(2~bc8rW=9DNU9!UVE2eFS`Z17Cg# z5EqNdL{$|)%clUDtI=qnc`1^H}vn#M-DySdUfq#14^7jW8*>t)SZfU0U%JPb%( z_D6@6yywYy2=hfwngHVB0GeB*H8}kbze)~ZuC=*=^lMLr7z5y2@Jed z_1rE!UDl`p3w z@pU}4p5S1dPI`L@565ER(Y16~WlL73FjrtbR4CcQbb_&CaXRVgq2*K7uJh3ig+(dc zL2N8B28>2rE*ctfIeF{}A89DOI|3HWXIHVby>0uzZOU_$%Z0^)!GKy#dj}6?NTd7q zFl#1`>x5`1uVBkI*@nwrmH%{v(s$^-ibg|F5Y|rVIqAu#_{#~pyNQe-Dv~R0lve;$ zRrAifv{{IVAU+;PALq`IU*J2S9OZypw^LV-!$F)8hl9F$HgA=NN=vzV6_pCyQf^;K zPDZN*h>m8)bYfx#hJO1RC40F18BQmmp>%dpRZWLgsbpmea|PBzg_1oE9HhMiw;PQH z)s1VELN}0Klu47AIz{>qs!}m^3R9-|n}!_IDb8QO;h>@6rbj!E=4Po~|NalZdU;@f zbqzp>2A7NX%KVy05JpG!8ER_=F4%);wY+(N+8TcUrjIy7``b6-9$&}zv%Ty;QcB6* zTb|7j-PXz97K#d)K8@&TdU|}ny&dIKIZQ`Xl(+q1w>*`hyRDP|=5RQ;c8%_CFGF#0 zjL~8?^Y$P8dZ-fwK@bE%5ClOG1VIo4K@bE%5ClOG1VIpn>AwUV1>FEwT@?TT002ov JPDHLkV1i-gk7xh@ diff --git a/tests/manual/src/srcdir/sub/s1 b/tests/manual/src/srcdir/sub/s1 deleted file mode 100644 index 72f1e00..0000000 --- a/tests/manual/src/srcdir/sub/s1 +++ /dev/null @@ -1,3 +0,0 @@ -s1 -s1 -s1 diff --git a/tests/manual/src/srcdir/sub/s2 b/tests/manual/src/srcdir/sub/s2 deleted file mode 100644 index 2502ee5..0000000 --- a/tests/manual/src/srcdir/sub/s2 +++ /dev/null @@ -1,3 +0,0 @@ -s2 -s2 -s2 diff --git a/tests/manual/test_file_creation.py b/tests/manual/test_file_creation.py deleted file mode 100644 index 4faeb50..0000000 --- a/tests/manual/test_file_creation.py +++ /dev/null @@ -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") diff --git a/tests/test_decman_core_command.py b/tests/test_decman_core_command.py new file mode 100644 index 0000000..2ac45fe --- /dev/null +++ b/tests/test_decman_core_command.py @@ -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 diff --git a/tests/test_decman_core_files.py b/tests/test_decman_core_files.py new file mode 100644 index 0000000..b9a8c1c --- /dev/null +++ b/tests/test_decman_core_files.py @@ -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 diff --git a/tests/test_decman_core_output.py b/tests/test_decman_core_output.py new file mode 100644 index 0000000..eb2e89c --- /dev/null +++ b/tests/test_decman_core_output.py @@ -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 diff --git a/tests/test_decman_init.py b/tests/test_decman_init.py new file mode 100644 index 0000000..71e48e7 --- /dev/null +++ b/tests/test_decman_init.py @@ -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 diff --git a/tests/test_package_management.py b/tests/test_package_management.py deleted file mode 100644 index efb3b37..0000000 --- a/tests/test_package_management.py +++ /dev/null @@ -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(), []) diff --git a/tests/test_source_resolution.py b/tests/test_source_resolution.py deleted file mode 100644 index 528448f..0000000 --- a/tests/test_source_resolution.py +++ /dev/null @@ -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"]) diff --git a/uv.lock b/uv.lock index 4070e46..659dbe7 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ] +[[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]] name = "decman" version = "0.4.1" @@ -62,6 +71,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pytest" }, { name = "ruff" }, ] @@ -69,7 +79,10 @@ dev = [ requires-dist = [{ name = "requests" }] [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]] 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" }, ] +[[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]] name = "requests" version = "2.32.5"