Add main app logic

This commit is contained in:
Kivi Kaitaniemi
2025-12-13 07:03:16 +02:00
parent 7c34b3c7d2
commit 364bf3d7b9
9 changed files with 648 additions and 13 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ directories: dict[str, Directory] = {}
modules: set[Module] = set()
plugins: dict[str, Plugin] = available_plugins()
execution_order: list[str] = [
"fs",
"files",
"pacman",
"aur",
"flatpak",
+252 -1
View File
@@ -1,5 +1,256 @@
import argparse
import os
import sys
import decman
import decman.config as conf
import decman.core.error as errors
import decman.core.file_manager as file_manager
import decman.core.module as _module
import decman.core.output as output
import decman.core.store as _store
_STORE_FILE = "/var/lib/decman/store.json"
def main():
print(decman.plugins)
"""
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("--skip", nargs="*", type=str, help="skip the following execution steps")
parser.add_argument(
"--only", nargs="*", type=str, help="run only the following execution steps"
)
parser.add_argument(
"--no-hooks",
action="store_true",
default=False,
help="don't run hook methods for modules",
)
parser.add_argument(
"--params", nargs="*", type=str, help="additional parameters passed to pluging"
)
args = parser.parse_args()
if os.getuid() != 0:
output.print_error("Not running as root. Please run decman as root.")
sys.exit(1)
original_wd = os.getcwd()
failed = False
try:
with _store.Store(_STORE_FILE, args.dry_run) as store:
try:
_execute_source(store, args)
failed = run_decman(store, args)
except OSError as error:
output.print_error(
f"Unexpected OSError while running decman: {error.strerror or str(error)}"
)
output.print_traceback()
except errors.SourceError as error:
output.print_error(f"Error raised manually in the source: {error}")
output.print_traceback()
except errors.CommandFailedError as error:
output.print_error(f"{error}")
output.print_traceback()
except errors.InvalidOnDisableError as error:
output.print_error(f"Invalid source. {error}")
output.print_traceback()
except OSError as error:
output.print_error(f"Failed to access decman store file '{_STORE_FILE}': {error.strerror}.")
output.print_error("This may cause already completed operations to run again.")
output.print_traceback()
except Exception as error:
output.print_error(f"Unexpected error while running decman: {error}")
output.print_traceback()
finally:
os.chdir(original_wd)
if failed:
sys.exit(1)
def run_decman(store: _store.Store, args: argparse.Namespace) -> bool:
# Ensure execution order is correct. Remove steps not executed now.
execution_order = []
if args.only:
output.print_debug("Argument '--only' is set. Pruning execution steps.")
for step in decman.execution_order:
if step in args.only:
output.print_debug(f"Adding {step} to execution order.")
execution_order.append(step)
else:
execution_order = decman.execution_order
for skip in args.skip:
output.print_debug(f"Skipping step {skip}.")
execution_order.remove(skip)
output.print_debug(f"Execution order is: {', '.join(execution_order)}.")
# Find newly enabled and disabled modules.
store.ensure("enabled_modules", [])
store.ensure("module_on_disable_scripts", {})
new_modules = []
disabled_modules = []
for module in decman.modules:
if module.name not in store["enabled_modules"]:
new_modules.append(module.name)
for module_name in store["enabled_modules"]:
if module_name not in decman.modules:
disabled_modules.append(module_name)
output.print_debug(f"New modules are: {', '.join(new_modules)}.")
output.print_debug(f"Disabled modules are: {', '.join(disabled_modules)}.")
# Disable hooks should be run before anything else because they might depend on packages that
# are going to get removed.
if not args.no_hooks:
output.print_summary("Running 'before update' -hooks.")
for module in decman.modules:
output.print_info(f"Running 'before update' for {module.name}.")
if not args.dry_run:
module.before_update()
if disabled_modules:
output.print_summary("Running 'on disable' -scripts.")
for disabled_module in disabled_modules:
on_disable_script = store["module_on_disable_scripts"].get(disabled_module, None)
if on_disable_script:
output.print_info(f"Running 'on disable' for {disabled_module}.")
if not args.dry_run:
decman.prg([on_disable_script])
store["enabled_modules"].remove(disabled_module)
store["module_on_disable_scripts"].pop(disabled_module)
# Run main execution order
for step in execution_order:
output.print_debug(f"Running step '{step}'.")
match step:
case "files":
if not file_manager.update_files(
store, decman.modules, decman.files, decman.directories, dry_run=args.dry_run
):
return False
case plugin_name:
plugin = decman.plugins.get(plugin_name, None)
if plugin:
plugin.process_modules(store, decman.modules)
if not plugin.apply(store, dry_run=args.dry_run, params=args.params):
return False
else:
output.print_warning(
f"Plugin '{plugin_name}' configured in execution_order\
but not found in available plugins."
)
# On enable and on change should be ran last since they might depend on effects caused by
# execution steps.
if not args.no_hooks:
output.print_summary("Running 'on enable' -hooks.")
for module in decman.modules:
if module.name in new_modules:
output.print_info(f"Running 'on enable' for {module.name}.")
if not args.dry_run:
module.on_enable()
store["enabled_modules"].append(module.name)
try:
script = _module.write_on_disable_script(
module, conf.module_on_disable_scripts_dir
)
if script:
store["module_on_disable_scripts"][module.name] = script
except OSError as error:
output.print_error(
f"Failed to create 'on disable' script for module {module.name}:\
{error.strerror or str(error)}."
)
output.print_warning(
"This script will NOT be created when decman runs the next time."
)
output.print_summary("Running 'on change' -hooks.")
for module in decman.modules:
if module._changed:
output.print_info(f"Running 'on change' for {module.name}.")
if not args.dry_run:
module.on_change()
output.print_summary("Running 'after update' -hooks.")
for module in decman.modules:
output.print_info(f"Running 'after update' for {module.name}.")
if not args.dry_run:
module.after_update()
return True
def _execute_source(store: _store.Store, args: argparse.Namespace):
source = store.get("source_file", None)
source_changed = False
if args.source is not None:
source = args.source
source_changed = True
if source is None:
output.print_error(
"Source was not specified. Please specify a source with the '--source' argument."
)
output.print_info("Decman will remember the previously specified source.")
sys.exit(1)
if source_changed or not store.get("allow_running_source_without_prompt", False):
output.print_warning(f"Decman will run the file '{source}' as root!")
output.print_warning(
"Only proceed if you trust the file completely. The file can also import other files."
)
if not output.prompt_confirm("Proceed?", default=False):
sys.exit(1)
if output.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 error:
output.print_error(f"Failed to read source '{source_path}': {error.strerror or str(error)}")
sys.exit(1)
os.chdir(source_dir)
sys.path.append(".")
exec(content)
+3
View File
@@ -23,3 +23,6 @@ variable to an instance of your class. Look in the example directory for an exam
debug_output: bool = False
quiet_output: bool = False
color_output: bool = True
pkg_cache_dir: str = "/var/cache/decman"
module_on_disable_scripts_dir: str = "/var/lib/decman/scripts/"
-4
View File
@@ -1,7 +1,3 @@
"""
Module for running external commands.
"""
import errno
import fcntl
import os
-5
View File
@@ -1,8 +1,3 @@
"""
Module for decman errors.
"""
class SourceError(Exception):
"""
Error raised manually from the user's source.
+27
View File
@@ -15,6 +15,33 @@ def update_files(
directories: dict[str, fs.Directory],
dry_run: bool = False,
) -> bool:
"""
Apply the desired file and directory state.
Installs common and module-provided files and directories, tracks all checked paths, detects
changes, removes files no longer managed, and updates the store.
On failure, no removals are performed and the store is left unchanged.
Arguments:
store:
Persistent store used to track managed file paths.
modules:
Enabled modules providing additional files and directories.
files:
Common files to install (target path -> File).
directories:
Common directories to install (target path -> Directory).
dry_run:
If True, perform change detection only without modifying the filesystem.
Returns:
True if all operations completed successfully, False if installation failed.
"""
output.print_summary("Installing files.")
all_checked_files = []
+12
View File
@@ -44,22 +44,34 @@ class Module:
def before_update(self):
"""
Override this method to run python code before updating the system.
Handle errors within this function. If an error should abort running decman,
raise SourceError or CommandFailedError.
"""
def after_update(self):
"""
Override this method to run python code after updating the system.
Handle errors within this function. If an error should abort running decman,
raise SourceError or CommandFailedError.
"""
def on_enable(self):
"""
Override this method to run python code when this module gets enabled.
Handle errors within this function. If an error should abort running decman,
raise SourceError or CommandFailedError.
"""
def on_change(self):
"""
Override this method to run python code after the contents of this module have been
changed in the source.
Handle errors within this function. If an error should abort running decman,
raise SourceError or CommandFailedError.
"""
@staticmethod
+4 -2
View File
@@ -24,7 +24,9 @@ class Plugin:
"""
return True
def apply(self, store: cstore.Store, dry_run: bool = False) -> bool:
def apply(
self, store: cstore.Store, dry_run: bool = False, params: list[str] | None = None
) -> bool:
"""
Ensures that the state managed by this plugin is present.
@@ -37,7 +39,7 @@ class Plugin:
"""
return True
def process_module(self, store: cstore.Store, module: module.Module):
def process_modules(self, store: cstore.Store, modules: set[module.Module]):
"""
Processes a module.
"""
+349
View File
@@ -0,0 +1,349 @@
import argparse
import types
import pytest
import decman.app as app # adjust if run_decman lives elsewhere
class DummyStore:
def __init__(self, enabled=None, scripts=None):
self._data = {}
if enabled is not None:
self._data["enabled_modules"] = list(enabled)
if scripts is not None:
self._data["module_on_disable_scripts"] = dict(scripts)
def __getitem__(self, key):
return self._data[key]
def __setitem__(self, key, value):
self._data[key] = value
def ensure(self, key, default):
self._data.setdefault(key, default)
class DummyModule:
def __init__(self, name):
self.name = name
self._changed = False
self.before_update_called = False
self.on_enable_called = False
self.on_change_called = False
self.after_update_called = False
def before_update(self):
self.before_update_called = True
def on_enable(self):
self.on_enable_called = True
def on_change(self):
self.on_change_called = True
def after_update(self):
self.after_update_called = True
@staticmethod
def on_disable():
print("Disabled")
class DummyPlugin:
def __init__(self, apply_result=True):
self.process_modules_called = False
self.apply_called_with = None
self.apply_result = apply_result
def process_modules(self, store, modules):
self.process_modules_called = True
def apply(self, store, dry_run=False, params=None):
self.apply_called_with = dry_run
return self.apply_result
def make_args(
only=None,
skip=None,
dry_run=False,
no_hooks=False,
):
return argparse.Namespace(
only=only, skip=skip or [], dry_run=dry_run, no_hooks=no_hooks, params=[]
)
@pytest.fixture
def no_op_output(monkeypatch):
ns = types.SimpleNamespace(
print_debug=lambda *a, **k: None,
print_summary=lambda *a, **k: None,
print_info=lambda *a, **k: None,
print_warning=lambda *a, **k: None,
)
monkeypatch.setattr(app, "output", ns)
return ns
@pytest.fixture
def base_decman(monkeypatch):
# Ensure decman attribute exists on app and has the fields we need
dm = types.SimpleNamespace()
dm.execution_order = []
dm.modules = []
dm.files = []
dm.directories = []
dm.plugins = {}
dm.prg_calls = []
def prg(cmd):
dm.prg_calls.append(cmd)
dm.prg = prg
monkeypatch.setattr(app, "decman", dm)
return dm
@pytest.fixture
def file_manager(monkeypatch):
fm = types.SimpleNamespace()
fm.update_files_calls = []
fm.result = True
def update_files(store, modules, files, directories, dry_run=False):
fm.update_files_calls.append(
dict(
store=store,
modules=list(modules),
files=list(files),
directories=list(directories),
dry_run=dry_run,
)
)
return fm.result
fm.update_files = update_files
monkeypatch.setattr(app, "file_manager", fm)
return fm
def test_execution_order_only_and_skip(no_op_output, base_decman, file_manager):
base_decman.execution_order = ["files", "plugin_a", "plugin_b"]
args = make_args(
only=["files", "plugin_b"],
skip=["plugin_b"],
dry_run=True,
no_hooks=True,
)
store = DummyStore()
plugin = DummyPlugin(apply_result=False)
base_decman.plugins = {"plugin_b": plugin}
result = app.run_decman(store, args)
assert result is True
# Should have run only "files"
assert len(file_manager.update_files_calls) == 1
assert file_manager.update_files_calls[0]["dry_run"] is True
def test_returns_false_when_update_files_fails_and_skips_plugins(
no_op_output, base_decman, file_manager
):
base_decman.execution_order = ["files", "plugin_a"]
plugin = DummyPlugin(apply_result=True)
base_decman.plugins = {"plugin_a": plugin}
file_manager.result = False # update_files fails
args = make_args(dry_run=False, no_hooks=True)
store = DummyStore()
result = app.run_decman(store, args)
assert result is False
# update_files called once
assert len(file_manager.update_files_calls) == 1
# plugin should never be touched
assert plugin.process_modules_called is False
assert plugin.apply_called_with is None
def test_plugin_failure_returns_false(no_op_output, base_decman, file_manager):
base_decman.execution_order = ["plugin_a"]
plugin = DummyPlugin(apply_result=False)
base_decman.plugins = {"plugin_a": plugin}
args = make_args(dry_run=False, no_hooks=True)
store = DummyStore()
result = app.run_decman(store, args)
assert result is False
assert plugin.process_modules_called is True
assert plugin.apply_called_with is False
# No file updates
assert file_manager.update_files_calls == []
def test_disabled_modules_run_on_disable_script(no_op_output, base_decman, file_manager):
# enabled_modules contains a module that no longer exists
store = DummyStore(
enabled=["present", "old_mod"],
scripts={"old_mod": "/tmp/on_disable.sh"},
)
# Only "present" exists now, so "old_mod" is disabled
base_decman.modules = [DummyModule("present")]
base_decman.execution_order = []
args = make_args(dry_run=False, no_hooks=False)
result = app.run_decman(store, args)
assert result is True
# prg should be called with the script for old_mod
assert base_decman.prg_calls == [["/tmp/on_disable.sh"]]
assert store["enabled_modules"] == ["present"]
assert store["module_on_disable_scripts"] == {}
def test_on_disable_not_run_in_dry_run(no_op_output, base_decman, file_manager):
store = DummyStore(
enabled=["present", "old_mod"],
scripts={"old_mod": "/tmp/on_disable.sh"},
)
base_decman.modules = [DummyModule("present")]
base_decman.execution_order = []
args = make_args(dry_run=True, no_hooks=True)
result = app.run_decman(store, args)
assert result is True
# dry_run: on_disable scripts must not be executed
assert base_decman.prg_calls == []
def test_hooks_called_for_new_and_changed_modules(
no_op_output, base_decman, file_manager, monkeypatch, tmp_path
):
m1 = DummyModule("mod1")
m2 = DummyModule("mod2")
m1._changed = True
m2._changed = False
base_decman.modules = [m1, m2]
base_decman.execution_order = [] # no steps, just hooks
monkeypatch.setattr("decman.config.module_on_disable_scripts_dir", tmp_path)
# Only mod2 was previously enabled, so mod1 is "new"
store = DummyStore(enabled=["mod2"])
args = make_args(dry_run=False, no_hooks=False)
result = app.run_decman(store, args)
assert result is True
# before_update for all modules
assert m1.before_update_called is True
assert m2.before_update_called is True
# on_enable only for new module (mod1)
assert m1.on_enable_called is True
assert m2.on_enable_called is False
# on_change only for modules with _changed
assert m1.on_change_called is True
assert m2.on_change_called is False
# after_update for all modules
assert m1.after_update_called is True
assert m2.after_update_called is True
assert store["enabled_modules"] == ["mod2", "mod1"]
assert store["module_on_disable_scripts"] == {"mod1": str(tmp_path / "mod1_on_disable.py")}
def test_hooks_not_called_when_no_hooks(no_op_output, base_decman, file_manager):
m1 = DummyModule("mod1")
m1._changed = True
base_decman.modules = [m1]
base_decman.execution_order = []
store = DummyStore(enabled=["mod1"])
args = make_args(dry_run=False, no_hooks=True)
result = app.run_decman(store, args)
assert result is True
assert m1.before_update_called is False
assert m1.on_enable_called is False
assert m1.on_change_called is False
assert m1.after_update_called is False
def test_dry_run_skips_all_hooks_but_runs_steps_with_flag(no_op_output, base_decman, file_manager):
m1 = DummyModule("mod1")
m1._changed = True
base_decman.modules = [m1]
base_decman.execution_order = ["files", "plugin_a"]
plugin = DummyPlugin(apply_result=True)
base_decman.plugins = {"plugin_a": plugin}
store = DummyStore()
args = make_args(dry_run=True, no_hooks=False)
result = app.run_decman(store, args)
assert result is True
# Steps executed with dry_run=True
assert len(file_manager.update_files_calls) == 1
assert file_manager.update_files_calls[0]["dry_run"] is True
assert plugin.process_modules_called is True
assert plugin.apply_called_with is True
# All hooks skipped due to dry_run
assert m1.before_update_called is False
assert m1.on_enable_called is False
assert m1.on_change_called is False
assert m1.after_update_called is False
def test_missing_plugin_emits_warning_but_continues(base_decman, file_manager, monkeypatch):
warnings = []
def warn(msg):
warnings.append(msg)
out = types.SimpleNamespace(
print_debug=lambda *a, **k: None,
print_summary=lambda *a, **k: None,
print_info=lambda *a, **k: None,
print_warning=warn,
)
monkeypatch.setattr(app, "output", out)
base_decman.execution_order = ["unknown_plugin"]
base_decman.plugins = {} # none available
store = DummyStore()
args = make_args(dry_run=True, no_hooks=True)
result = app.run_decman(store, args)
assert result is True
assert any("unknown_plugin" in w for w in warnings)