mirror of
https://github.com/kiviktnm/decman.git
synced 2026-09-19 12:08:28 +00:00
Add main app logic
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
"""
|
||||
Module for running external commands.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import fcntl
|
||||
import os
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
"""
|
||||
Module for decman errors.
|
||||
"""
|
||||
|
||||
|
||||
class SourceError(Exception):
|
||||
"""
|
||||
Error raised manually from the user's source.
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user