mirror of
https://github.com/kiviktnm/decman.git
synced 2026-09-19 12:08:28 +00:00
Better methods for running commands
This commit is contained in:
+2
-2
@@ -12,10 +12,10 @@ sudo uv run decman
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Run unit tests:
|
Run all unit tests (`-s` disables output capturing):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uv run python -m unittest
|
uv run pytest -s
|
||||||
```
|
```
|
||||||
|
|
||||||
## Formatting
|
## Formatting
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ decman = "decman.app:main"
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff>=0.14.9",
|
"ruff>=0.14.9",
|
||||||
|
"pytest>=8.4.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
+94
-410
@@ -1,425 +1,109 @@
|
|||||||
"""
|
import shlex
|
||||||
Module for writing system configurations for decman.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import grp
|
|
||||||
import os
|
|
||||||
import pwd
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
import decman.error
|
import decman.core.command as command
|
||||||
|
import decman.core.output as output
|
||||||
|
|
||||||
|
|
||||||
class UserRaisedError(Exception):
|
def prg(
|
||||||
"""
|
cmd: list[str],
|
||||||
Error raised by running source
|
user: typing.Optional[str] = None,
|
||||||
|
env_overrides: typing.Optional[dict[str, str]] = None,
|
||||||
|
mimic_login: bool = False,
|
||||||
|
pty: bool = True,
|
||||||
|
check: bool = True,
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
|
Shortcut for running a command. Returns the output of that command.
|
||||||
|
|
||||||
def __init__(self, message) -> None:
|
Args:
|
||||||
super().__init__(message)
|
cmd:
|
||||||
|
Command to execute.
|
||||||
|
|
||||||
|
user:
|
||||||
|
User name to run the command as. If set, the command is executed after dropping
|
||||||
|
privileges to this user.
|
||||||
|
|
||||||
|
env_overrides:
|
||||||
|
Environment variables to override or add for the command execution.
|
||||||
|
These values are merged on top of the current process environment.
|
||||||
|
|
||||||
|
mimic_login:
|
||||||
|
If mimic_login is True, will set the following environment variables according to the
|
||||||
|
given user's passwd file details. This only happens when user is set.
|
||||||
|
- HOME
|
||||||
|
- USER
|
||||||
|
- LOGNAME
|
||||||
|
- SHELL
|
||||||
|
|
||||||
|
pty:
|
||||||
|
If True, run the command inside a pseudo-terminal (PTY). This enables interactive
|
||||||
|
behavior and terminal-dependent programs. If False, run the command without a PTY
|
||||||
|
using standard subprocess execution.
|
||||||
|
|
||||||
|
check:
|
||||||
|
If True, raise CommandFailedError when the command exits with a non-zero status.
|
||||||
|
If False, print a warning when encountering a non-zero exit code.
|
||||||
|
"""
|
||||||
|
if pty:
|
||||||
|
result = command.pty_run(
|
||||||
|
cmd, user=user, env_overrides=env_overrides, mimic_login=mimic_login
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = command.run(cmd, user=user, env_overrides=env_overrides, mimic_login=mimic_login)
|
||||||
|
|
||||||
|
if check:
|
||||||
|
# This raises an error if the command failed exiting the function early
|
||||||
|
result = command.check_run_result(cmd, result)
|
||||||
|
|
||||||
|
code, command_output = result
|
||||||
|
if code != 0:
|
||||||
|
output.print_warning(f"Command '{shlex.join(cmd)}' returned with an exit code {code}.")
|
||||||
|
|
||||||
|
return command_output
|
||||||
|
|
||||||
|
|
||||||
def sh(
|
def sh(
|
||||||
sh_cmd: str,
|
sh_cmd: str,
|
||||||
user: typing.Optional[str] = None,
|
user: typing.Optional[str] = None,
|
||||||
env_overrides: typing.Optional[dict[str, str]] = None,
|
env_overrides: typing.Optional[dict[str, str]] = None,
|
||||||
):
|
mimic_login: bool = False,
|
||||||
|
pty: bool = True,
|
||||||
|
check: bool = True,
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Shortcut for running a shell command.
|
Shortcut for running a shell command. Returns the output of that command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sh_cmd:
|
||||||
|
Shell command to execute. The command is passed to the system shell /bin/sh.
|
||||||
|
|
||||||
|
user:
|
||||||
|
User name to run the command as. If set, the command is executed after dropping
|
||||||
|
privileges to this user.
|
||||||
|
|
||||||
|
env_overrides:
|
||||||
|
Environment variables to override or add for the command execution.
|
||||||
|
These values are merged on top of the current process environment.
|
||||||
|
|
||||||
|
mimic_login:
|
||||||
|
If mimic_login is True, will set the following environment variables according to the
|
||||||
|
given user's passwd file details. This only happens when user is set.
|
||||||
|
- HOME
|
||||||
|
- USER
|
||||||
|
- LOGNAME
|
||||||
|
- SHELL
|
||||||
|
|
||||||
|
pty:
|
||||||
|
If True, run the command inside a pseudo-terminal (PTY). This enables interactive
|
||||||
|
behavior and terminal-dependent programs. If False, run the command without a PTY
|
||||||
|
using standard subprocess execution.
|
||||||
|
|
||||||
|
check:
|
||||||
|
If True, raise CommandFailedError when the command exits with a non-zero status.
|
||||||
|
If False, print a warning when encountering a non-zero exit code.
|
||||||
"""
|
"""
|
||||||
if env_overrides is None:
|
cmd = ["/bin/sh", "-c", sh_cmd]
|
||||||
env_overrides = {}
|
return prg(
|
||||||
|
cmd, user=user, env_overrides=env_overrides, mimic_login=mimic_login, pty=pty, check=check
|
||||||
env = os.environ.copy()
|
|
||||||
for var, val in env_overrides.items():
|
|
||||||
env[var] = val
|
|
||||||
|
|
||||||
if user is None:
|
|
||||||
try:
|
|
||||||
subprocess.run(sh_cmd, shell=True, check=True, env=env)
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
raise decman.error.UserFacingError(
|
|
||||||
f"Running user defined shell command '{sh_cmd}' failed."
|
|
||||||
) from e
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
uid = pwd.getpwnam(user).pw_uid
|
|
||||||
gid = pwd.getpwnam(user).pw_gid
|
|
||||||
except KeyError as e:
|
|
||||||
raise decman.error.UserFacingError(
|
|
||||||
f"Running user defined shell command failed because the user {user} doesn't exist."
|
|
||||||
) from e
|
|
||||||
|
|
||||||
with subprocess.Popen(sh_cmd, shell=True, group=gid, user=uid, env=env) as process:
|
|
||||||
if process.wait() != 0:
|
|
||||||
raise decman.error.UserFacingError(
|
|
||||||
f"Running user shell command '{sh_cmd}' as {user} failed."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def prg(
|
|
||||||
command: list[str],
|
|
||||||
user: typing.Optional[str] = None,
|
|
||||||
env_overrides: typing.Optional[dict[str, str]] = None,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Shortcut for running a program.
|
|
||||||
"""
|
|
||||||
if env_overrides is None:
|
|
||||||
env_overrides = {}
|
|
||||||
|
|
||||||
env = os.environ.copy()
|
|
||||||
for var, val in env_overrides.items():
|
|
||||||
env[var] = val
|
|
||||||
|
|
||||||
if user is None:
|
|
||||||
try:
|
|
||||||
subprocess.run(command, check=True, env=env)
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
raise decman.error.UserFacingError(
|
|
||||||
f"Running user defined program '{command}' failed."
|
|
||||||
) from e
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
uid = pwd.getpwnam(user).pw_uid
|
|
||||||
gid = pwd.getpwnam(user).pw_gid
|
|
||||||
except KeyError as e:
|
|
||||||
raise decman.error.UserFacingError(
|
|
||||||
f"Running user defined program failed because the user {user} doesn't exist."
|
|
||||||
) from e
|
|
||||||
|
|
||||||
with subprocess.Popen(command, group=gid, user=uid, env=env) as process:
|
|
||||||
if process.wait() != 0:
|
|
||||||
raise decman.error.UserFacingError(
|
|
||||||
f"Running user program '{command}' as {user} failed."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class File:
|
|
||||||
"""
|
|
||||||
A simple file that gets copied to the target.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
source_file: typing.Optional[str] = None,
|
|
||||||
content: typing.Optional[str] = None,
|
|
||||||
bin_file: bool = False,
|
|
||||||
encoding: str = "utf-8",
|
|
||||||
owner: typing.Optional[str] = None,
|
|
||||||
group: typing.Optional[str] = None,
|
|
||||||
permissions: int = 0o644,
|
|
||||||
):
|
|
||||||
if source_file is None and content is None:
|
|
||||||
raise ValueError("Both source_file and content cannot be None.")
|
|
||||||
|
|
||||||
if source_file is not None and content is not None:
|
|
||||||
raise ValueError("Both source_file and content cannot be set.")
|
|
||||||
|
|
||||||
self.source_file = source_file
|
|
||||||
self.content = content
|
|
||||||
self.permissions = permissions
|
|
||||||
self.bin_file = bin_file
|
|
||||||
self.encoding = encoding
|
|
||||||
self.uid = None
|
|
||||||
self.gid = None
|
|
||||||
|
|
||||||
if owner is not None:
|
|
||||||
self.uid = pwd.getpwnam(owner).pw_uid
|
|
||||||
self.gid = pwd.getpwnam(owner).pw_gid
|
|
||||||
|
|
||||||
if group is not None:
|
|
||||||
self.gid = grp.getgrnam(group).gr_gid
|
|
||||||
|
|
||||||
def copy_to(self, target: str, variables: typing.Optional[dict[str, str]] = None):
|
|
||||||
"""
|
|
||||||
Copies the contents of this file to the target file.
|
|
||||||
"""
|
|
||||||
if variables is None:
|
|
||||||
variables = {}
|
|
||||||
|
|
||||||
target_directory = os.path.dirname(target)
|
|
||||||
|
|
||||||
def create_missing_dirs(dirct: str, uid: typing.Optional[int], gid: typing.Optional[int]):
|
|
||||||
if not os.path.isdir(dirct):
|
|
||||||
parent_dir = os.path.dirname(dirct)
|
|
||||||
if not os.path.isdir(parent_dir):
|
|
||||||
create_missing_dirs(parent_dir, uid, gid)
|
|
||||||
os.mkdir(dirct)
|
|
||||||
|
|
||||||
if uid is not None:
|
|
||||||
assert gid is not None, "If uid is set, then gid is set."
|
|
||||||
os.chown(dirct, uid, gid)
|
|
||||||
|
|
||||||
create_missing_dirs(target_directory, self.uid, self.gid)
|
|
||||||
|
|
||||||
self._write_content(target, variables)
|
|
||||||
|
|
||||||
if self.uid is not None:
|
|
||||||
assert self.gid is not None, "If uid is set, then gid is set."
|
|
||||||
os.chown(target, self.uid, self.gid)
|
|
||||||
|
|
||||||
os.chmod(target, self.permissions)
|
|
||||||
|
|
||||||
def _write_content(self, target: str, variables: dict[str, str]):
|
|
||||||
if self.source_file is not None and (self.bin_file or len(variables) == 0):
|
|
||||||
shutil.copy(self.source_file, target)
|
|
||||||
elif self.bin_file and self.content is not None:
|
|
||||||
with open(target, "wb") as file:
|
|
||||||
file.write(self.content.encode(encoding=self.encoding))
|
|
||||||
elif self.source_file is not None:
|
|
||||||
with open(self.source_file, "rt", encoding=self.encoding) as src:
|
|
||||||
content = src.read()
|
|
||||||
|
|
||||||
for var, value in variables.items():
|
|
||||||
content = content.replace(var, value)
|
|
||||||
|
|
||||||
with open(target, "wt", encoding=self.encoding) as file:
|
|
||||||
file.write(content)
|
|
||||||
else:
|
|
||||||
assert self.content is not None, "Content should be set since source_file was not set."
|
|
||||||
content = self.content
|
|
||||||
for var, value in variables.items():
|
|
||||||
content = content.replace(var, value)
|
|
||||||
|
|
||||||
with open(target, "wt", encoding=self.encoding) as file:
|
|
||||||
file.write(content)
|
|
||||||
|
|
||||||
|
|
||||||
class Directory:
|
|
||||||
"""
|
|
||||||
Contents of this directory will be copied to the target.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
source_directory: str,
|
|
||||||
bin_files: bool = False,
|
|
||||||
encoding: str = "utf-8",
|
|
||||||
owner: typing.Optional[str] = None,
|
|
||||||
group: typing.Optional[str] = None,
|
|
||||||
permissions: int = 0o644,
|
|
||||||
):
|
|
||||||
self.source_directory = source_directory
|
|
||||||
self.bin_files = bin_files
|
|
||||||
self.encoding = encoding
|
|
||||||
self.permissions = permissions
|
|
||||||
|
|
||||||
self.owner = owner
|
|
||||||
self.group = group
|
|
||||||
self.uid = None
|
|
||||||
self.gid = None
|
|
||||||
|
|
||||||
if owner is not None:
|
|
||||||
self.uid = pwd.getpwnam(owner).pw_uid
|
|
||||||
self.gid = pwd.getpwnam(owner).pw_gid
|
|
||||||
|
|
||||||
if group is not None:
|
|
||||||
self.gid = grp.getgrnam(group).gr_gid
|
|
||||||
|
|
||||||
def copy_to(
|
|
||||||
self,
|
|
||||||
target_directory: str,
|
|
||||||
variables: typing.Optional[dict[str, str]] = None,
|
|
||||||
only_print: bool = False,
|
|
||||||
) -> list[str]:
|
|
||||||
"""
|
|
||||||
Copies the files in this directory to the target directory.
|
|
||||||
|
|
||||||
Returns all created files.
|
|
||||||
"""
|
|
||||||
created = []
|
|
||||||
original_wd = os.getcwd()
|
|
||||||
try:
|
|
||||||
os.chdir(self.source_directory)
|
|
||||||
for src_dir, _, src_files in os.walk("."):
|
|
||||||
for src_file in src_files:
|
|
||||||
src_path = os.path.join(src_dir, src_file)
|
|
||||||
file = File(
|
|
||||||
source_file=src_path,
|
|
||||||
bin_file=self.bin_files,
|
|
||||||
encoding=self.encoding,
|
|
||||||
owner=self.owner,
|
|
||||||
group=self.group,
|
|
||||||
permissions=self.permissions,
|
|
||||||
)
|
|
||||||
target = os.path.normpath(os.path.join(target_directory, src_path))
|
|
||||||
created.append(target)
|
|
||||||
|
|
||||||
if not only_print:
|
|
||||||
file.copy_to(target, variables)
|
|
||||||
finally:
|
|
||||||
os.chdir(original_wd)
|
|
||||||
return created
|
|
||||||
|
|
||||||
|
|
||||||
class UserPackage:
|
|
||||||
"""
|
|
||||||
Defines a custom package.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
pkgname: str,
|
|
||||||
version: str,
|
|
||||||
dependencies: list[str],
|
|
||||||
git_url: str,
|
|
||||||
pkgbase: typing.Optional[str] = None,
|
|
||||||
provides: typing.Optional[list[str]] = None,
|
|
||||||
make_dependencies: typing.Optional[list[str]] = None,
|
|
||||||
check_dependencies: typing.Optional[list[str]] = None,
|
|
||||||
):
|
|
||||||
if pkgbase is None:
|
|
||||||
pkgbase = pkgname
|
|
||||||
if provides is None:
|
|
||||||
provides = []
|
|
||||||
if make_dependencies is None:
|
|
||||||
make_dependencies = []
|
|
||||||
if check_dependencies is None:
|
|
||||||
check_dependencies = []
|
|
||||||
|
|
||||||
self.pkgname = pkgname
|
|
||||||
self.pkgbase = pkgbase
|
|
||||||
self.version = version
|
|
||||||
self.provides = provides
|
|
||||||
self.dependencies = dependencies
|
|
||||||
self.make_dependencies = make_dependencies
|
|
||||||
self.check_dependencies = check_dependencies
|
|
||||||
self.git_url = git_url
|
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
|
||||||
return self.pkgname.__hash__()
|
|
||||||
|
|
||||||
def __eq__(self, value: object, /) -> bool:
|
|
||||||
if isinstance(value, self.__class__):
|
|
||||||
return value.pkgname == self.pkgname
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class Module:
|
|
||||||
"""
|
|
||||||
Collection of connected packages, services and files.
|
|
||||||
|
|
||||||
Inherit this class to create your own modules.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, name: str, enabled: bool, version: str):
|
|
||||||
self.name = name
|
|
||||||
self.enabled = enabled
|
|
||||||
self.version = version
|
|
||||||
|
|
||||||
def on_enable(self):
|
|
||||||
"""
|
|
||||||
Override this method to run python code when this module gets enabled.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def on_disable(self):
|
|
||||||
"""
|
|
||||||
Override this method to run python code when this module gets disabled.
|
|
||||||
|
|
||||||
Note! If this module is simply removed, the code will not exacute. Instead set enabled to
|
|
||||||
False.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def after_update(self):
|
|
||||||
"""
|
|
||||||
Override this method to run python code after updating the system. If this module is
|
|
||||||
disabled, this will not run.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def after_version_change(self):
|
|
||||||
"""
|
|
||||||
Override this method to run python code after the version of this module has changed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def files(self) -> dict[str, File]:
|
|
||||||
"""
|
|
||||||
Override this method to return files that should be installed as a part of this module.
|
|
||||||
"""
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def directories(self) -> dict[str, Directory]:
|
|
||||||
"""
|
|
||||||
Override this method to return directories that should be installed as a part of this module.
|
|
||||||
"""
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def file_variables(self) -> dict[str, str]:
|
|
||||||
"""
|
|
||||||
Override this method to return variables that should replaced with a new value inside
|
|
||||||
this module's text files.
|
|
||||||
"""
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def pacman_packages(self) -> list[str]:
|
|
||||||
"""
|
|
||||||
Override this method to return pacman packages that should be installed as a part of this
|
|
||||||
Module.
|
|
||||||
"""
|
|
||||||
return []
|
|
||||||
|
|
||||||
def user_packages(self) -> list[UserPackage]:
|
|
||||||
"""
|
|
||||||
Override this method to return user packages that should be installed as a part of this
|
|
||||||
Module.
|
|
||||||
"""
|
|
||||||
return []
|
|
||||||
|
|
||||||
def aur_packages(self) -> list[str]:
|
|
||||||
"""
|
|
||||||
Override this method to return AUR packages that should be installed as a part of this
|
|
||||||
Module.
|
|
||||||
"""
|
|
||||||
return []
|
|
||||||
|
|
||||||
def flatpak_packages(self) -> list[str]:
|
|
||||||
"""
|
|
||||||
Override this method to return flatpak packages that should be installed to the system installation as a part of this
|
|
||||||
Module.
|
|
||||||
"""
|
|
||||||
return []
|
|
||||||
|
|
||||||
def flatpak_user_packages(self) -> dict[str, list[str]]:
|
|
||||||
"""
|
|
||||||
Override this method to return flatpak packages that should be installed to the user installation as a part of this
|
|
||||||
Module.
|
|
||||||
"""
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def systemd_units(self) -> list[str]:
|
|
||||||
"""
|
|
||||||
Override this method to return systemd units that should be enabled as a part of this
|
|
||||||
Module.
|
|
||||||
"""
|
|
||||||
return []
|
|
||||||
|
|
||||||
def systemd_user_units(self) -> dict[str, list[str]]:
|
|
||||||
"""
|
|
||||||
Override this method to return systemd user units that should be enabled as a part of this
|
|
||||||
Module.
|
|
||||||
"""
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
|
||||||
return self.name.__hash__()
|
|
||||||
|
|
||||||
def __eq__(self, value: object, /) -> bool:
|
|
||||||
if isinstance(value, self.__class__):
|
|
||||||
return value.name == self.name
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
packages: list[str] = []
|
|
||||||
aur_packages: list[str] = []
|
|
||||||
user_packages: list[UserPackage] = []
|
|
||||||
ignored_packages: list[str] = []
|
|
||||||
enabled_systemd_units: list[str] = []
|
|
||||||
enabled_systemd_user_units: dict[str, list[str]] = {}
|
|
||||||
files: dict[str, File] = {}
|
|
||||||
directories: dict[str, Directory] = {}
|
|
||||||
modules: list[Module] = []
|
|
||||||
flatpak_packages: list[str] = []
|
|
||||||
flatpak_user_packages: dict[str, list[str]] = {}
|
|
||||||
ignored_flatpak_packages: list[str] = []
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import decman.app
|
|
||||||
|
|
||||||
decman.app.main()
|
|
||||||
@@ -1,485 +0,0 @@
|
|||||||
# pyright: reportUnusedCallResult=false
|
|
||||||
"""
|
|
||||||
Module containing the CLI Application.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import pwd
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
import decman
|
|
||||||
import decman.config as conf
|
|
||||||
import decman.error as err
|
|
||||||
import decman.lib as l
|
|
||||||
from decman.lib import fpm
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""
|
|
||||||
Main entry for the CLI app
|
|
||||||
"""
|
|
||||||
|
|
||||||
sys.pycache_prefix = os.path.join(conf.pkg_cache_dir, "python/")
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
prog="decman",
|
|
||||||
description="Declarative package & configuration manager for Arch Linux",
|
|
||||||
epilog="See more help at: https://github.com/kiviktnm/decman",
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument("--source", action="store", help="python file containing configuration")
|
|
||||||
parser.add_argument(
|
|
||||||
"--print",
|
|
||||||
"--dry-run",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="print what would happen as a result of running decman",
|
|
||||||
)
|
|
||||||
parser.add_argument("--debug", action="store_true", default=False, help="show debug output")
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-packages",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="don't upgrade any packages (including foreign packages)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-foreign-packages",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="don't upgrade foreign packages",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-flatpaks",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="don't upgrade flatpak packages",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-files", action="store_true", default=False, help="don't install any files"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-systemd-units",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="don't enable/disable systemd units",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-commands",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="don't run user specified commands",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--upgrade-devel",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="upgrade devel packages",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--force-build",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="force building of packages that are already cached",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if not _is_root():
|
|
||||||
l.print_error("Not running as root. Please run decman as root.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
original_wd = os.getcwd()
|
|
||||||
|
|
||||||
try:
|
|
||||||
store = l.Store.restore()
|
|
||||||
except err.UserFacingError as error:
|
|
||||||
l.print_error(error.user_facing_msg)
|
|
||||||
for line in traceback.format_exc().splitlines():
|
|
||||||
l.print_debug(line)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
errored = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
opts = _set_up(store, args)
|
|
||||||
# Override debug_output if cli option is used
|
|
||||||
if args.debug:
|
|
||||||
conf.debug_output = True
|
|
||||||
conf.suppress_command_output = False
|
|
||||||
# When print cli option is used, show info output
|
|
||||||
if args.print:
|
|
||||||
conf.quiet_output = False
|
|
||||||
Core(store, opts).run()
|
|
||||||
except err.UserFacingError as error:
|
|
||||||
l.print_error(error.user_facing_msg)
|
|
||||||
for line in traceback.format_exc().splitlines():
|
|
||||||
l.print_debug(line)
|
|
||||||
errored = True
|
|
||||||
except decman.UserRaisedError as user_error:
|
|
||||||
l.print_error(f"Error encountered while running the source: {user_error}")
|
|
||||||
errored = True
|
|
||||||
|
|
||||||
# Save even when an error has occurred, since this avoids repeating steps like building pkgs.
|
|
||||||
try:
|
|
||||||
store.save()
|
|
||||||
except err.UserFacingError as error:
|
|
||||||
l.print_error(error.user_facing_msg)
|
|
||||||
for line in traceback.format_exc().splitlines():
|
|
||||||
l.print_debug(line)
|
|
||||||
errored = True
|
|
||||||
|
|
||||||
os.chdir(original_wd)
|
|
||||||
if errored:
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
|
|
||||||
def _set_up(store: l.Store, args):
|
|
||||||
source = store.source_file
|
|
||||||
source_changed = False
|
|
||||||
if args.source is not None:
|
|
||||||
source = args.source
|
|
||||||
source_changed = True
|
|
||||||
|
|
||||||
if source is None:
|
|
||||||
l.print_error(
|
|
||||||
"Source was not specified. Please specify a source with the '--source' argument."
|
|
||||||
)
|
|
||||||
l.print_info("Decman will remember the previously specified source.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if source_changed or not store.allow_running_source_without_prompt:
|
|
||||||
l.print_warning(f"Decman will run the file '{source}' as root!")
|
|
||||||
l.print_warning(
|
|
||||||
"Only proceed if you trust the file completely. The file can also import other files."
|
|
||||||
)
|
|
||||||
|
|
||||||
if not l.prompt_confirm("Proceed?", default=False):
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if l.prompt_confirm("Remember this choice?", default=False):
|
|
||||||
store.allow_running_source_without_prompt = True
|
|
||||||
|
|
||||||
source_path = os.path.abspath(source)
|
|
||||||
source_dir = os.path.dirname(source_path)
|
|
||||||
store.source_file = source_path
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(source_path, "rt", encoding="utf-8") as file:
|
|
||||||
content = file.read()
|
|
||||||
except OSError as e:
|
|
||||||
raise err.UserFacingError(f"Failed to read source file '{store.source_file}'.") from e
|
|
||||||
|
|
||||||
os.chdir(source_dir)
|
|
||||||
sys.path.append(".")
|
|
||||||
exec(content)
|
|
||||||
|
|
||||||
return (
|
|
||||||
args.print,
|
|
||||||
not args.no_packages,
|
|
||||||
not args.no_foreign_packages,
|
|
||||||
not args.no_flatpaks,
|
|
||||||
not args.no_files,
|
|
||||||
not args.no_systemd_units,
|
|
||||||
not args.no_commands,
|
|
||||||
args.upgrade_devel,
|
|
||||||
args.force_build,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Core:
|
|
||||||
"""
|
|
||||||
Contains the main logic of decman.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, store: l.Store, opts):
|
|
||||||
(
|
|
||||||
self.only_print,
|
|
||||||
self.update_packages,
|
|
||||||
self.update_foreign_packages,
|
|
||||||
self.update_flatpaks,
|
|
||||||
self.update_files,
|
|
||||||
self.update_units,
|
|
||||||
self.run_commands,
|
|
||||||
self.upgrade_devel,
|
|
||||||
self.force_build,
|
|
||||||
) = opts
|
|
||||||
|
|
||||||
if conf.enable_flatpak and not shutil.which("flatpak"):
|
|
||||||
l.print_error(
|
|
||||||
"Flatpaks have been enabled in the source file, but the flatpak command could not be found. Either disable flatpaks or make sure that flatpak is installed and can be accessed by decman. Exiting."
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
self.store = store
|
|
||||||
self.source = _resolve_source()
|
|
||||||
self.pacman = l.Pacman()
|
|
||||||
self.flatpak = l.Flatpak()
|
|
||||||
self.systemctl = l.Systemd(store)
|
|
||||||
self.fpkg_search = fpm.ExtendedPackageSearch(self.pacman)
|
|
||||||
|
|
||||||
for upkg in self.source.all_user_pkgs():
|
|
||||||
self.fpkg_search.add_user_pkg(fpm.PackageInfo.from_user_package(upkg, self.pacman))
|
|
||||||
|
|
||||||
self.fpm = fpm.ForeignPackageManager(store, self.pacman, self.fpkg_search)
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
"""
|
|
||||||
Run the main logic of decman.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.update_units:
|
|
||||||
self._disable_units()
|
|
||||||
|
|
||||||
if self.update_files:
|
|
||||||
self._create_and_remove_files()
|
|
||||||
|
|
||||||
if self.update_packages:
|
|
||||||
self._remove_pkgs()
|
|
||||||
self._upgrade_pkgs()
|
|
||||||
self._install_pkgs()
|
|
||||||
|
|
||||||
if self.update_units:
|
|
||||||
self._enable_units()
|
|
||||||
|
|
||||||
if self.run_commands:
|
|
||||||
self._run_modules()
|
|
||||||
all_enabled_modules = {}
|
|
||||||
for mod, version in self.source.all_enabled_modules():
|
|
||||||
all_enabled_modules[mod] = version
|
|
||||||
# Enabled modules are really only stored for commands,
|
|
||||||
# so they can be set only when the commands were exacuted.
|
|
||||||
self.store.enabled_modules = all_enabled_modules
|
|
||||||
|
|
||||||
def _disable_units(self):
|
|
||||||
to_disable = self.source.units_to_disable(self.store)
|
|
||||||
l.print_list("Disabling systemd units:", to_disable)
|
|
||||||
if to_disable:
|
|
||||||
l.print_info("Disabled systemd units won't be stopped automatically.")
|
|
||||||
if not self.only_print:
|
|
||||||
self.systemctl.disable_units(to_disable)
|
|
||||||
|
|
||||||
user_units_to_disable = self.source.user_units_to_disable(self.store)
|
|
||||||
for user, units in user_units_to_disable.items():
|
|
||||||
l.print_list(f"Disabling systemd units for {user}:", units)
|
|
||||||
if not self.only_print:
|
|
||||||
self.systemctl.disable_user_units(units, user)
|
|
||||||
|
|
||||||
def _remove_pkgs(self):
|
|
||||||
"""
|
|
||||||
Remove pacman and flatpak packages
|
|
||||||
"""
|
|
||||||
# pacman
|
|
||||||
currently_installed = self.pacman.get_installed()
|
|
||||||
to_remove = self.source.packages_to_remove(currently_installed)
|
|
||||||
|
|
||||||
currently_installed_flatpak = self.flatpak.get_installed()
|
|
||||||
to_remove_flatpak = self.source.flatpak_packages_to_remove(currently_installed_flatpak)
|
|
||||||
|
|
||||||
l.print_list("Removing pacman packages:", to_remove)
|
|
||||||
|
|
||||||
if conf.enable_flatpak and self.update_flatpaks:
|
|
||||||
l.print_list("Removing flatpak packages:", to_remove_flatpak)
|
|
||||||
self._remove_user_flatpaks(only_print=True)
|
|
||||||
|
|
||||||
if self.only_print:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.pacman.remove(to_remove)
|
|
||||||
|
|
||||||
# flatpak
|
|
||||||
if conf.enable_flatpak and self.update_flatpaks:
|
|
||||||
self.flatpak.remove(to_remove_flatpak)
|
|
||||||
self._remove_user_flatpaks()
|
|
||||||
|
|
||||||
def _remove_user_flatpaks(self, only_print: bool = False):
|
|
||||||
# Get all non system users (users that have uid >= 1000), also ignore nobody
|
|
||||||
users = [
|
|
||||||
u.pw_name for u in pwd.getpwall() if u.pw_uid >= 1000 and u.pw_name not in ("nobody",)
|
|
||||||
]
|
|
||||||
# Add root to users
|
|
||||||
users.append("root")
|
|
||||||
for user in users:
|
|
||||||
currently_installed_flatpak = self.flatpak.get_installed(as_user=True, which_user=user)
|
|
||||||
to_remove_flatpak = self.source.flatpak_packages_to_remove(
|
|
||||||
currently_installed_flatpak, as_user=True, which_user=user
|
|
||||||
)
|
|
||||||
l.print_list(
|
|
||||||
f"Removing flatpak packages from user installation for user {user}",
|
|
||||||
to_remove_flatpak,
|
|
||||||
)
|
|
||||||
|
|
||||||
if only_print:
|
|
||||||
continue
|
|
||||||
|
|
||||||
self.flatpak.remove(to_remove_flatpak, True, user)
|
|
||||||
|
|
||||||
def _upgrade_pkgs(self):
|
|
||||||
"""
|
|
||||||
Upgrade pacman, fpm and flatpak packages
|
|
||||||
"""
|
|
||||||
# flatpak + fpm
|
|
||||||
l.print_summary("Upgrading packages.")
|
|
||||||
if self.only_print:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.pacman.upgrade()
|
|
||||||
if conf.enable_fpm and self.update_foreign_packages:
|
|
||||||
self.fpm.upgrade(self.upgrade_devel, self.force_build, self.source.ignored_packages)
|
|
||||||
|
|
||||||
# flatpak
|
|
||||||
if conf.enable_flatpak and self.update_flatpaks:
|
|
||||||
l.print_summary("Upgrading flatpak packages.")
|
|
||||||
self.flatpak.upgrade()
|
|
||||||
users = [
|
|
||||||
u.pw_name
|
|
||||||
for u in pwd.getpwall()
|
|
||||||
if u.pw_uid >= 1000 and u.pw_name not in ("nobody",)
|
|
||||||
]
|
|
||||||
# Add root to users
|
|
||||||
users.append("root")
|
|
||||||
for user in users:
|
|
||||||
l.print_summary(f"Upgrading flatpak packages for {user}.")
|
|
||||||
self.flatpak.upgrade(True, user)
|
|
||||||
|
|
||||||
def _install_pkgs(self):
|
|
||||||
"""
|
|
||||||
Installs all pacman, fpm, and flatpak packages.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# pacman + fpm
|
|
||||||
currently_installed = self.pacman.get_installed()
|
|
||||||
to_install_pacman = self.source.pacman_packages_to_install(currently_installed)
|
|
||||||
to_install_fpm = self.source.foreign_packages_to_install(currently_installed)
|
|
||||||
|
|
||||||
# flatpak
|
|
||||||
currently_installed_flatpak = self.flatpak.get_installed()
|
|
||||||
to_install_flatpak = self.source.flatpak_packages_to_install(currently_installed_flatpak)
|
|
||||||
|
|
||||||
l.print_list("Installing pacman packages:", to_install_pacman)
|
|
||||||
|
|
||||||
# fpm prints a summary so no need to print it twice
|
|
||||||
if self.only_print:
|
|
||||||
l.print_list("Installing foreign packages:", to_install_fpm)
|
|
||||||
|
|
||||||
if conf.enable_flatpak and self.update_flatpaks:
|
|
||||||
l.print_list("Installing flatpak packages:", to_install_flatpak)
|
|
||||||
|
|
||||||
if self.only_print:
|
|
||||||
self._install_user_flatpaks(only_print=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.pacman.install(to_install_pacman)
|
|
||||||
if conf.enable_fpm and self.update_foreign_packages:
|
|
||||||
self.fpm.install(to_install_fpm, force=self.force_build)
|
|
||||||
|
|
||||||
if conf.enable_flatpak and self.update_flatpaks:
|
|
||||||
self.flatpak.install(to_install_flatpak)
|
|
||||||
# Print summary before the action
|
|
||||||
self._install_user_flatpaks(only_print=True)
|
|
||||||
self._install_user_flatpaks()
|
|
||||||
|
|
||||||
def _install_user_flatpaks(self, only_print: bool = False):
|
|
||||||
# Get all non system users (users that have uid >= 1000), also ignore nobody
|
|
||||||
users = [
|
|
||||||
u.pw_name for u in pwd.getpwall() if u.pw_uid >= 1000 and u.pw_name not in ("nobody",)
|
|
||||||
]
|
|
||||||
# Add root to users
|
|
||||||
users.append("root")
|
|
||||||
for user in users:
|
|
||||||
currently_installed_flatpak = self.flatpak.get_installed(as_user=True, which_user=user)
|
|
||||||
to_install_flatpak = self.source.flatpak_packages_to_install(
|
|
||||||
currently_installed_flatpak, as_user=True, which_user=user
|
|
||||||
)
|
|
||||||
|
|
||||||
if only_print:
|
|
||||||
l.print_list(
|
|
||||||
f"Installing flatpak packages to user installation for user {user}",
|
|
||||||
to_install_flatpak,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
self.flatpak.install(to_install_flatpak, True, user)
|
|
||||||
|
|
||||||
def _create_and_remove_files(self):
|
|
||||||
l.print_summary("Installing files.")
|
|
||||||
|
|
||||||
all_created = self.source.create_all_files(self.only_print)
|
|
||||||
to_remove = self.source.files_to_remove(self.store, all_created)
|
|
||||||
|
|
||||||
l.print_list("Ensured files are up to date:", all_created, elements_per_line=1)
|
|
||||||
l.print_list("Removing files:", to_remove, elements_per_line=1)
|
|
||||||
|
|
||||||
if self.only_print:
|
|
||||||
return
|
|
||||||
|
|
||||||
for file in to_remove:
|
|
||||||
try:
|
|
||||||
os.remove(file)
|
|
||||||
except OSError as e:
|
|
||||||
l.print_error(f"{e}")
|
|
||||||
l.print_warning(f"Failed to remove file: {file}")
|
|
||||||
|
|
||||||
self.store.created_files = all_created
|
|
||||||
|
|
||||||
def _enable_units(self):
|
|
||||||
to_enable = self.source.units_to_enable(self.store)
|
|
||||||
l.print_list("Enabling systemd units:", to_enable)
|
|
||||||
if to_enable:
|
|
||||||
l.print_info("Enabled systemd units won't be started automatically.")
|
|
||||||
if not self.only_print:
|
|
||||||
self.systemctl.enable_units(to_enable)
|
|
||||||
|
|
||||||
user_units_to_enable = self.source.user_units_to_enable(self.store)
|
|
||||||
for user, units in user_units_to_enable.items():
|
|
||||||
l.print_list(f"Enabling systemd units for {user}:", units)
|
|
||||||
if not self.only_print:
|
|
||||||
self.systemctl.enable_user_units(units, user)
|
|
||||||
|
|
||||||
def _run_modules(self):
|
|
||||||
l.print_summary("Running on enable hooks.")
|
|
||||||
if not self.only_print:
|
|
||||||
self.source.run_on_enable(self.store)
|
|
||||||
|
|
||||||
l.print_summary("Running after version change hooks.")
|
|
||||||
if not self.only_print:
|
|
||||||
self.source.run_after_version_change(self.store)
|
|
||||||
|
|
||||||
l.print_summary("Running on disable hooks.")
|
|
||||||
if not self.only_print:
|
|
||||||
self.source.run_on_disable(self.store)
|
|
||||||
|
|
||||||
l.print_summary("Running after update hooks.")
|
|
||||||
if not self.only_print:
|
|
||||||
self.source.run_after_update()
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_source() -> l.Source:
|
|
||||||
enabled_systemd_user_units = {}
|
|
||||||
for user, units in decman.enabled_systemd_user_units.items():
|
|
||||||
enabled_systemd_user_units[user] = set(units)
|
|
||||||
|
|
||||||
flatpak_user_packages = {}
|
|
||||||
for user, pkgs in decman.flatpak_user_packages.items():
|
|
||||||
flatpak_user_packages[user] = set(pkgs)
|
|
||||||
|
|
||||||
return l.Source(
|
|
||||||
pacman_packages=set(decman.packages),
|
|
||||||
aur_packages=set(decman.aur_packages),
|
|
||||||
user_packages=set(decman.user_packages),
|
|
||||||
ignored_packages=set(decman.ignored_packages),
|
|
||||||
systemd_units=set(decman.enabled_systemd_units),
|
|
||||||
systemd_user_units=enabled_systemd_user_units,
|
|
||||||
files=decman.files,
|
|
||||||
directories=decman.directories,
|
|
||||||
modules=set(decman.modules),
|
|
||||||
flatpak_packages=set(decman.flatpak_packages),
|
|
||||||
flatpak_user_packages=flatpak_user_packages,
|
|
||||||
ignored_flatpak_packages=set(decman.ignored_flatpak_packages),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_root() -> bool:
|
|
||||||
return os.geteuid() == 0
|
|
||||||
+1
-271
@@ -20,276 +20,6 @@ To change the defalts, create a new child class of the Commands-class and set th
|
|||||||
variable to an instance of your class. Look in the example directory for an example.
|
variable to an instance of your class. Look in the example directory for an example.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import typing
|
|
||||||
|
|
||||||
|
|
||||||
class Commands:
|
|
||||||
"""
|
|
||||||
Default commands.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def list_pkgs(self) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command outputs a newline seperated list of explicitly installed packages.
|
|
||||||
"""
|
|
||||||
return ["pacman", "-Qeq", "--color=never"]
|
|
||||||
|
|
||||||
def list_flatpak_pkgs(self, as_user: bool = False) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command outputs a newline separated list of installed flatpak application ids
|
|
||||||
The first line just says 'Application ID' so this one is ignored.
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
"flatpak",
|
|
||||||
"list",
|
|
||||||
"--app",
|
|
||||||
"--user" if as_user else "--system",
|
|
||||||
"--columns",
|
|
||||||
"application",
|
|
||||||
]
|
|
||||||
|
|
||||||
def list_foreign_pkgs_versioned(self) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command outputs a newline seperated list of installed packages and their
|
|
||||||
versions that are not from pacman repositories.
|
|
||||||
"""
|
|
||||||
return ["pacman", "-Qm", "--color=never"]
|
|
||||||
|
|
||||||
def install_pkgs(self, pkgs: list[str]) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command installs the given packages from pacman repositories.
|
|
||||||
"""
|
|
||||||
return ["pacman", "-S", "--color=always", "--needed"] + pkgs
|
|
||||||
|
|
||||||
def install_flatpak_pkgs(self, pkgs: list[str], as_user: bool = False) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command installs all listed packages, and their dependencies/runtimes automatically.
|
|
||||||
"""
|
|
||||||
return ["flatpak", "install", "-y", "--user" if as_user else "--system"] + pkgs
|
|
||||||
|
|
||||||
def install_files(self, pkg_files: list[str]) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command installs the given packages files.
|
|
||||||
"""
|
|
||||||
return ["pacman", "-U", "--color=always", "--asdeps"] + pkg_files
|
|
||||||
|
|
||||||
def set_as_explicitly_installed(self, pkgs: list[str]) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command installs sets the given as explicitly installed.
|
|
||||||
"""
|
|
||||||
return ["pacman", "-D", "--color=always", "--asexplicit"] + pkgs
|
|
||||||
|
|
||||||
def install_deps(self, deps: list[str]) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command installs the given packages from pacman repositories.
|
|
||||||
The packages are installed as dependencies.
|
|
||||||
"""
|
|
||||||
return ["pacman", "-S", "--color=always", "--needed", "--asdeps"] + deps
|
|
||||||
|
|
||||||
def is_installable(self, pkg: str) -> list[str]:
|
|
||||||
"""
|
|
||||||
This command exits with code 0 when a package is installable from pacman repositories.
|
|
||||||
"""
|
|
||||||
return ["pacman", "-Sddp", pkg]
|
|
||||||
|
|
||||||
def upgrade(self) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command upgrades all pacman packages.
|
|
||||||
"""
|
|
||||||
return ["pacman", "-Syu", "--color=always"]
|
|
||||||
|
|
||||||
def upgrade_flatpak(self, as_user: bool = False) -> list[str]:
|
|
||||||
"""
|
|
||||||
Updates all installed flatpak REFs including runtimes and dependencies.
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
"flatpak",
|
|
||||||
"update",
|
|
||||||
"--noninteractive",
|
|
||||||
"-y",
|
|
||||||
"--user" if as_user else "--system",
|
|
||||||
]
|
|
||||||
|
|
||||||
def remove(self, pkgs: list[str]) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command removes the given packages and their dependencies
|
|
||||||
(that aren't required by other packages).
|
|
||||||
"""
|
|
||||||
return ["pacman", "-Rs", "--color=always"] + pkgs
|
|
||||||
|
|
||||||
def remove_flatpak(self, pkgs: list[str], as_user: bool = False) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command will remove the listed REFs. Unused dependencies might be kept, but to remove them another command needs to be run.
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
"flatpak",
|
|
||||||
"remove",
|
|
||||||
"--noninteractive",
|
|
||||||
"-y",
|
|
||||||
"--user" if as_user else "--system",
|
|
||||||
] + pkgs
|
|
||||||
|
|
||||||
def remove_unused_flatpak(self, as_user: bool = False) -> list[str]:
|
|
||||||
"""
|
|
||||||
This will remove all unused flatpak dependencies and runtimes.
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
"flatpak",
|
|
||||||
"remove",
|
|
||||||
"--noninteractive",
|
|
||||||
"-y",
|
|
||||||
"--unused",
|
|
||||||
"--user" if as_user else "--system",
|
|
||||||
]
|
|
||||||
|
|
||||||
def enable_units(self, units: list[str]) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command enables the given systemd units.
|
|
||||||
"""
|
|
||||||
return ["systemctl", "enable"] + units
|
|
||||||
|
|
||||||
def disable_units(self, units: list[str]) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command disables the given systemd units.
|
|
||||||
"""
|
|
||||||
return ["systemctl", "disable"] + units
|
|
||||||
|
|
||||||
def enable_user_units(self, units: list[str], user: str) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command enables the given systemd units for the user.
|
|
||||||
"""
|
|
||||||
return ["systemctl", "--user", "-M", f"{user}@", "enable"] + units
|
|
||||||
|
|
||||||
def disable_user_units(self, units: list[str], user: str) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command disables the given systemd units for the user.
|
|
||||||
"""
|
|
||||||
return ["systemctl", "--user", "-M", f"{user}@", "disable"] + units
|
|
||||||
|
|
||||||
def compare_versions(self, installed_version: str, new_version: str) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command outputs -1 when the installed version is older than the new version.
|
|
||||||
"""
|
|
||||||
return ["vercmp", installed_version, new_version]
|
|
||||||
|
|
||||||
def git_clone(self, repo: str, dest: str) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command clones a git repository to the the given destination.
|
|
||||||
"""
|
|
||||||
return ["git", "clone", repo, dest]
|
|
||||||
|
|
||||||
def git_diff(self, from_commit: str) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command outputs the difference between the given commit and
|
|
||||||
the current state of the repository.
|
|
||||||
"""
|
|
||||||
return ["git", "diff", from_commit]
|
|
||||||
|
|
||||||
def git_get_commit_id(self) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command outputs the current commit id.
|
|
||||||
"""
|
|
||||||
return ["git", "rev-parse", "HEAD"]
|
|
||||||
|
|
||||||
def git_log_commit_ids(self) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command outputs commit hashes of the repository.
|
|
||||||
"""
|
|
||||||
return ["git", "log", "--format=format:%H"]
|
|
||||||
|
|
||||||
def review_file(self, file: str) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command outputs a file for the user to see.
|
|
||||||
"""
|
|
||||||
return ["less", file]
|
|
||||||
|
|
||||||
def make_chroot(self, chroot_dir: str, with_pkgs: list[str]) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command creates a new arch chroot to the chroot directory and installs the
|
|
||||||
given packages there.
|
|
||||||
"""
|
|
||||||
return ["mkarchroot", chroot_dir] + with_pkgs
|
|
||||||
|
|
||||||
def install_chroot_packages(self, chroot_dir: str, packages: list[str]):
|
|
||||||
"""
|
|
||||||
Running this command installs the given packages to the given chroot.
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
"arch-nspawn",
|
|
||||||
chroot_dir,
|
|
||||||
"pacman",
|
|
||||||
"-S",
|
|
||||||
"--needed",
|
|
||||||
"--noconfirm",
|
|
||||||
] + packages
|
|
||||||
|
|
||||||
def resolve_real_name(self, chroot_dir: str, pkg: str) -> list[str]:
|
|
||||||
"""
|
|
||||||
This command prints a real name of a package. For example, it prints the package which provides a virtual package.
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
"arch-nspawn",
|
|
||||||
chroot_dir,
|
|
||||||
"pacman",
|
|
||||||
"-Sddp",
|
|
||||||
"--print-format=%n",
|
|
||||||
pkg,
|
|
||||||
]
|
|
||||||
|
|
||||||
def remove_chroot_packages(self, chroot_dir: str, packages: list[str]):
|
|
||||||
"""
|
|
||||||
Running this command removes the given packages from the given chroot.
|
|
||||||
"""
|
|
||||||
return ["arch-nspawn", chroot_dir, "pacman", "-Rsu", "--noconfirm"] + packages
|
|
||||||
|
|
||||||
def make_chroot_pkg(
|
|
||||||
self, chroot_wd_dir: str, user: str, pkgfiles_to_install: list[str]
|
|
||||||
) -> list[str]:
|
|
||||||
"""
|
|
||||||
Running this command creates a package file using the given chroot.
|
|
||||||
The package is created as the user and the pkg_files_to_install are installed
|
|
||||||
in the chroot before the package is created.
|
|
||||||
"""
|
|
||||||
makechrootpkg_cmd = ["makechrootpkg", "-c", "-r", chroot_wd_dir, "-U", user]
|
|
||||||
|
|
||||||
for pkgfile in pkgfiles_to_install:
|
|
||||||
makechrootpkg_cmd += ["-I", pkgfile]
|
|
||||||
|
|
||||||
return makechrootpkg_cmd
|
|
||||||
|
|
||||||
|
|
||||||
commands: Commands = Commands()
|
|
||||||
debug_output: bool = False
|
debug_output: bool = False
|
||||||
quiet_output: bool = False
|
quiet_output: bool = False
|
||||||
suppress_command_output: bool = True
|
color_output: bool = True
|
||||||
|
|
||||||
valid_pkgexts: list[str] = [
|
|
||||||
".pkg.tar",
|
|
||||||
".pkg.tar.gz",
|
|
||||||
".pkg.tar.bz2",
|
|
||||||
".pkg.tar.xz",
|
|
||||||
".pkg.tar.zst",
|
|
||||||
".pkg.tar.lzo",
|
|
||||||
".pkg.tar.lrz",
|
|
||||||
".pkg.tar.lz4",
|
|
||||||
".pkg.tar.lz",
|
|
||||||
".pkg.tar.Z",
|
|
||||||
]
|
|
||||||
|
|
||||||
pacman_output_keywords: list[str] = [
|
|
||||||
"pacsave",
|
|
||||||
"pacnew",
|
|
||||||
# These cause too many false positives IMO
|
|
||||||
# "warning",
|
|
||||||
# "error",
|
|
||||||
# "note",
|
|
||||||
]
|
|
||||||
print_pacman_output_highlights: bool = True
|
|
||||||
|
|
||||||
makepkg_user: str = "nobody"
|
|
||||||
build_dir: str = "/tmp/decman/build"
|
|
||||||
pkg_cache_dir: str = "/var/cache/decman"
|
|
||||||
aur_rpc_timeout: typing.Optional[int] = 30
|
|
||||||
enable_fpm: bool = True
|
|
||||||
enable_flatpak: bool = False
|
|
||||||
number_of_packages_stored_in_cache: int = 3
|
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"""
|
||||||
|
Module for running external commands.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import errno
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import pwd
|
||||||
|
import select
|
||||||
|
import shutil
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import termios
|
||||||
|
import tty
|
||||||
|
import typing
|
||||||
|
|
||||||
|
import decman.core.error as errors
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_info(user: str) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Returns UID and GID of the given user.
|
||||||
|
|
||||||
|
If the user doesn't exist, raises UserNotFoundError.
|
||||||
|
"""
|
||||||
|
info = _get_passwd(user)
|
||||||
|
return info.pw_uid, info.pw_gid
|
||||||
|
|
||||||
|
|
||||||
|
def pty_run(
|
||||||
|
command: list[str],
|
||||||
|
user: None | str = None,
|
||||||
|
env_overrides: None | dict[str, str] = None,
|
||||||
|
mimic_login: bool = False,
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
"""
|
||||||
|
Runs a given command with the given arguments in a pseudo TTY. The command can be ran as
|
||||||
|
the given user and environment variables can be overridden manually.
|
||||||
|
|
||||||
|
If mimic_login is True, will set the following environment variables according to the given
|
||||||
|
user's passwd file details. This only happens when user is set.
|
||||||
|
- HOME
|
||||||
|
- USER
|
||||||
|
- LOGNAME
|
||||||
|
- SHELL
|
||||||
|
|
||||||
|
If the given command is empty, returns (0, "").
|
||||||
|
|
||||||
|
Returns the return code of the command and the output as a string.
|
||||||
|
|
||||||
|
If the user doesn't exist, raises UserNotFoundError.
|
||||||
|
If forking the process fails or stdin is not a TTY, raises OSError.
|
||||||
|
"""
|
||||||
|
if not command:
|
||||||
|
return 0, ""
|
||||||
|
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
raise OSError(errno.ENOTTY, "Stdin is not a TTY.")
|
||||||
|
|
||||||
|
env = _build_env(user=user, env_overrides=env_overrides, mimic_login=mimic_login)
|
||||||
|
|
||||||
|
pid, master_fd = pty.fork()
|
||||||
|
if pid == 0:
|
||||||
|
_exec_in_child(command, env, user)
|
||||||
|
|
||||||
|
return _run_parent(master_fd, pid)
|
||||||
|
|
||||||
|
|
||||||
|
def run(
|
||||||
|
command: list[str],
|
||||||
|
user: None | str = None,
|
||||||
|
env_overrides: None | dict[str, str] = None,
|
||||||
|
mimic_login: bool = False,
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
"""
|
||||||
|
Runs a given command with the given arguments. The command can be ran as the given user and
|
||||||
|
environment variables can be overridden manually.
|
||||||
|
|
||||||
|
If mimic_login is True, will set the following environment variables according to the given
|
||||||
|
user's passwd file details. This only happens when user is set.
|
||||||
|
- HOME
|
||||||
|
- USER
|
||||||
|
- LOGNAME
|
||||||
|
- SHELL
|
||||||
|
|
||||||
|
If the given command is empty, returns (0, "").
|
||||||
|
|
||||||
|
Returns the return code of the command and the output as a string.
|
||||||
|
|
||||||
|
If the user doesn't exist, raises UserNotFoundError.
|
||||||
|
"""
|
||||||
|
if not command:
|
||||||
|
return 0, ""
|
||||||
|
|
||||||
|
env = _build_env(user=user, env_overrides=env_overrides, mimic_login=mimic_login)
|
||||||
|
uid, gid = None, None
|
||||||
|
|
||||||
|
if user:
|
||||||
|
uid, gid = get_user_info(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
command, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, user=uid, group=gid
|
||||||
|
)
|
||||||
|
stdout, _ = process.communicate()
|
||||||
|
except OSError as error:
|
||||||
|
# Mirror PTY behavior: "<cmd>: <error>\n" and errno-based exit code
|
||||||
|
msg = error.strerror or str(error)
|
||||||
|
output = f"{command[0]}: {msg}\n"
|
||||||
|
code = error.errno if error.errno and error.errno < 128 else 127
|
||||||
|
return code, output
|
||||||
|
|
||||||
|
return process.returncode, stdout.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def check_run_result(command: list[str], result: tuple[int, str]) -> tuple[int, str]:
|
||||||
|
"""
|
||||||
|
Validates the result of a command execution.
|
||||||
|
|
||||||
|
If the command exited with a non-zero return code, raises CommandFailedError
|
||||||
|
containing the original command and its captured output.
|
||||||
|
|
||||||
|
Otherwise, returns the result unchanged.
|
||||||
|
"""
|
||||||
|
code, output = result
|
||||||
|
if code != 0:
|
||||||
|
raise errors.CommandFailedError(command, output)
|
||||||
|
return code, output
|
||||||
|
|
||||||
|
|
||||||
|
def _build_env(
|
||||||
|
user: None | str,
|
||||||
|
env_overrides: None | dict[str, str],
|
||||||
|
mimic_login: bool,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
|
||||||
|
if mimic_login and user:
|
||||||
|
pw = _get_passwd(user)
|
||||||
|
env.update(
|
||||||
|
{
|
||||||
|
"HOME": pw.pw_dir,
|
||||||
|
"USER": pw.pw_name,
|
||||||
|
"LOGNAME": pw.pw_name,
|
||||||
|
"SHELL": pw.pw_shell,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if env_overrides:
|
||||||
|
env.update(env_overrides)
|
||||||
|
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_in_child(command: list[str], env: dict[str, str], user: None | str) -> typing.NoReturn:
|
||||||
|
try:
|
||||||
|
if user:
|
||||||
|
uid, gid = get_user_info(user=user)
|
||||||
|
os.setgid(gid)
|
||||||
|
os.setuid(uid)
|
||||||
|
|
||||||
|
os.execve(command[0], command, env)
|
||||||
|
except OSError as error:
|
||||||
|
try:
|
||||||
|
os.write(2, f"{command[0]}: {error.strerror}\n".encode())
|
||||||
|
except OSError:
|
||||||
|
# Not much can be done, if outputting the failure state fails
|
||||||
|
pass
|
||||||
|
code = error.errno if (error.errno and error.errno < 128) else 127
|
||||||
|
os._exit(code)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_parent(master_fd: int, pid: int) -> tuple[int, str]:
|
||||||
|
stdin_fd = sys.stdin.fileno()
|
||||||
|
stdout_fd = sys.stdout.fileno()
|
||||||
|
|
||||||
|
# Put stdin into raw mode and save previous termios attributes.
|
||||||
|
old_tattr = termios.tcgetattr(stdin_fd)
|
||||||
|
tty.setraw(stdin_fd)
|
||||||
|
|
||||||
|
# Set PTY window size to match the current terminal size.
|
||||||
|
# We accept that resizing the real terminal causes issues here, it doesn't need to be handeled
|
||||||
|
rows, columns = shutil.get_terminal_size()
|
||||||
|
winsz = struct.pack("HHHH", rows, columns, 0, 0)
|
||||||
|
fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsz)
|
||||||
|
|
||||||
|
try:
|
||||||
|
output_bytes = _relay_pty(master_fd, stdin_fd, stdout_fd)
|
||||||
|
finally:
|
||||||
|
# Restore stdin termios attributes.
|
||||||
|
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_tattr)
|
||||||
|
os.close(master_fd)
|
||||||
|
|
||||||
|
_, status = os.waitpid(pid, 0)
|
||||||
|
exitcode = os.waitstatus_to_exitcode(status)
|
||||||
|
output = output_bytes.decode("utf-8", errors="replace").replace("\r\n", "\n")
|
||||||
|
return exitcode, output
|
||||||
|
|
||||||
|
|
||||||
|
def _relay_pty(master_fd: int, stdin_fd: int, stdout_fd: int) -> bytes:
|
||||||
|
"""
|
||||||
|
Drive interactive I/O between stdin/stdout and the PTY, capturing output.
|
||||||
|
"""
|
||||||
|
output_chunks: list[bytes] = []
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Wait until process or stdin has data
|
||||||
|
rlist, _, _ = select.select([master_fd, stdin_fd], [], [])
|
||||||
|
|
||||||
|
# Capture and echo child process
|
||||||
|
if master_fd in rlist:
|
||||||
|
try:
|
||||||
|
data = os.read(master_fd, 1024)
|
||||||
|
except OSError:
|
||||||
|
# Child process probably exited, EOF
|
||||||
|
break
|
||||||
|
|
||||||
|
output_chunks.append(data)
|
||||||
|
try:
|
||||||
|
os.write(stdout_fd, data)
|
||||||
|
except OSError:
|
||||||
|
# stdout closed, ignore
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Forward stdin
|
||||||
|
if stdin_fd in rlist:
|
||||||
|
try:
|
||||||
|
data = os.read(stdin_fd, 1024)
|
||||||
|
os.write(master_fd, data)
|
||||||
|
except OSError:
|
||||||
|
# Either stdin EOF -> no data to pass
|
||||||
|
# or child died -> wait for master_fd to handle
|
||||||
|
pass
|
||||||
|
|
||||||
|
return b"".join(output_chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_passwd(user: str) -> pwd.struct_passwd:
|
||||||
|
try:
|
||||||
|
return pwd.getpwnam(user)
|
||||||
|
except KeyError as error:
|
||||||
|
raise errors.UserNotFoundError(user) from error
|
||||||
@@ -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}'.")
|
||||||
@@ -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
|
||||||
@@ -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.")
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
"""
|
|
||||||
Errors used by decman.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class UserFacingError(Exception):
|
|
||||||
"""
|
|
||||||
Execution of an important step failed and the program shouldn't continue.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, user_facing_msg: str):
|
|
||||||
self.user_facing_msg = user_facing_msg
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
_SRC_PATH = os.path.join(os.path.dirname(__file__), "../src/")
|
|
||||||
|
|
||||||
sys.path.append(_SRC_PATH)
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
Simple text file with a %variable%
|
|
||||||
|
|
||||||
twice: %another_variable%
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# This file should be executable.
|
|
||||||
echo "Hello, world!"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
1
|
|
||||||
1
|
|
||||||
1
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
2
|
|
||||||
2
|
|
||||||
2
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,3 +0,0 @@
|
|||||||
s1
|
|
||||||
s1
|
|
||||||
s1
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
s2
|
|
||||||
s2
|
|
||||||
s2
|
|
||||||
@@ -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")
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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(), [])
|
|
||||||
@@ -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"])
|
|
||||||
@@ -52,6 +52,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decman"
|
name = "decman"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -62,6 +71,7 @@ dependencies = [
|
|||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -69,7 +79,10 @@ dev = [
|
|||||||
requires-dist = [{ name = "requests" }]
|
requires-dist = [{ name = "requests" }]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [{ name = "ruff", specifier = ">=0.14.9" }]
|
dev = [
|
||||||
|
{ name = "pytest", specifier = ">=8.4.2" },
|
||||||
|
{ name = "ruff", specifier = ">=0.14.9" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
@@ -80,6 +93,58 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "25.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.32.5"
|
version = "2.32.5"
|
||||||
|
|||||||
Reference in New Issue
Block a user