Add plugin architecture

This commit is contained in:
Kivi Kaitaniemi
2025-12-13 01:44:41 +02:00
parent 6047cad690
commit aaa4a0ad43
14 changed files with 526 additions and 20 deletions
+19
View File
@@ -39,3 +39,22 @@ Apply fixes:
```sh
uv run ruff check --fix
```
## Installing the example plugin
```sh
uv pip install -e example/plugin/
```
Uninstalling:
```sh
uv pip uninstall decman-plugin-example
```
Making the plugin available/unavailable:
```sh
touch /tmp/example_plugin_available
rm /tmp/example_plugin_available
```
+10
View File
@@ -0,0 +1,10 @@
import os
import decman
class Example(decman.Plugin):
NAME = "example"
def available(self) -> bool:
return os.path.exists("/tmp/example_plugin_available")
+14
View File
@@ -0,0 +1,14 @@
[project]
name = "decman-plugin-example"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
"decman",
]
[project.entry-points."decman.plugins"]
example = "decman_plugin_example:Example"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
+4
View File
@@ -14,6 +14,10 @@ dependencies = [
[project.scripts]
decman = "decman.app:main"
[project.entry-points."decman.plugins"]
systemd = "decman.plugins.systemd:Systemd"
pacman = "decman.plugins.pacman:Pacman"
[dependency-groups]
dev = [
"ruff>=0.14.9",
+39 -2
View File
@@ -4,16 +4,53 @@ import typing
import decman.core.command as command
import decman.core.output as output
# Re-export File and Directory
from decman.core.fs import Directory, File # noqa: F401
# Re-exports
from decman.core.error import SourceError
from decman.core.fs import Directory, File
from decman.core.module import Module
from decman.plugins import Plugin, available_plugins
# Plugin types
from decman.plugins.pacman import Pacman
from decman.plugins.systemd import Systemd
__all__ = [
"SourceError",
"File",
"Directory",
"Module",
"Plugin",
"prg",
"sh",
]
# -----------------------------------------
# Global variables for system configuration
# -----------------------------------------
files: dict[str, File] = {}
directories: dict[str, Directory] = {}
modules: set[Module] = set()
plugins: dict[str, Plugin] = available_plugins()
execution_order: list[str] = [
"fs",
"pacman",
"aur",
"flatpak",
"systemd",
]
# Default plugins get quick access
pacman: None | Pacman = None
systemd: None | Systemd = None
_pacman = plugins.get("pacman", None)
if isinstance(_pacman, Pacman):
pacman = _pacman
_systemd = plugins.get("systemd", None)
if isinstance(_systemd, Systemd):
systemd = _systemd
def prg(
cmd: list[str],
+5
View File
@@ -0,0 +1,5 @@
import decman
def main():
print(decman.plugins)
+22
View File
@@ -3,6 +3,28 @@ Module for decman errors.
"""
class SourceError(Exception):
"""
Error raised manually from the user's source.
"""
def __init__(self, message):
super().__init__(message)
class InvalidOnDisableError(Exception):
"""
Error raised when trying to create a Module with an invalid on_disable method.
"""
def __init__(self, module: str, reason: str):
self.module = module
self.reason = reason
super().__init__(
f"Module '{module}' contains an invalid on_disable method. Reason: {reason}."
)
class UserNotFoundError(Exception):
"""
Raised when a specified user cannot be found in the system.
+18 -18
View File
@@ -21,36 +21,36 @@ class File:
directories are created recursively and assigned the same ownership as the file when specified.
Parameters:
source_file:
``source_file``:
Path to an existing file to copy from. Mutually exclusive with ``content``.
content:
``content``:
In-memory file contents to write. Mutually exclusive with ``source_file``.
bin_file:
``bin_file``:
If ``True``, treat the file as binary. Disables variable substitution and writes bytes
verbatim.
encoding:
``encoding``:
Text encoding used when reading or writing non-binary files.
owner:
``owner``:
System user name to own the file and created parent directories.
group:
``group``:
System group name to own the file and created parent directories.
permissions:
``permissions``:
File mode applied to the target file (e.g. ``0o644``).
Raises:
ValueError
``ValueError``
If both ``source_file`` and ``content`` are ``None`` or if both are set.
UserNotFoundError
``UserNotFoundError``
If ``owner`` does not exist on the system.
GroupNotFoundError
``GroupNotFoundError``
If ``group`` does not exist on the system.
Notes:
@@ -214,30 +214,30 @@ class Directory:
variable substitution before being written.
Parameters:
source_directory:
``source_directory``:
Path to the directory whose contents will be mirrored into the target.
bin_files:
``bin_files``:
If ``True``, treat all files as binary; disables variable substitution and copies bytes
verbatim.
encoding:
``encoding``:
Text encoding used when reading or writing non-binary files.
owner:
``owner``:
System user name to own created files and directories.
group:
``group``:
System group name to own created files and directories.
permissions:
``permissions``:
File mode applied to created or updated files (e.g. ``0o644``).
Raises:
UserNotFoundError
``UserNotFoundError``
If ``owner`` does not exist on the system.
GroupNotFoundError
``GroupNotFoundError``
If ``group`` does not exist on the system.
"""
+152
View File
@@ -0,0 +1,152 @@
import builtins
import dis
import inspect
import os
import textwrap
import types
import typing
import decman.core.error as errors
import decman.core.fs as fs
class Module:
"""
Unit for organizing related files, packages and other configuration.
Inherit this class to create your own modules.
Parameters:
name:
The name of the module. It must be unique.
"""
def __init__(self, name: str) -> None:
self.name = name
self._changed = False
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
m = cls.__dict__.get("on_disable")
if m is None:
return
if not isinstance(m, staticmethod):
raise errors.InvalidOnDisableError(
f"{cls.__module__}.{cls.__name__}",
"on_disable must be declared as @staticmethod",
)
func = m.__func__
_validate_on_disable(f"{cls.__module__}.{cls.__name__}", func)
def before_update(self):
"""
Override this method to run python code before updating the system.
"""
def after_update(self):
"""
Override this method to run python code after updating the system.
"""
def on_enable(self):
"""
Override this method to run python code when this module gets enabled.
"""
def on_change(self):
"""
Override this method to run python code after the contents of this module have been
changed in the source.
"""
@staticmethod
def on_disable():
"""
Override this method to run python code when this module gets disabled.
This code will get copied *as is* to a temporary file. Do not use external variables or
imports. If you must use imports, define them inside this method.
"""
def __hash__(self) -> int:
return hash(self.name)
def __eq__(self, other: object) -> bool:
return isinstance(other, self.__class__) and other.name == self.name
def write_on_disable_script(mod_obj: Module, out_dir: str) -> str | None:
"""
Writes a on_disable script for the given module. Returns the path to that script.
Raises:
OSError
If creating the script file fails.
"""
cls: typing.Type[Module] = type(mod_obj)
# Get the descriptor so we can unwrap staticmethod
desc = cls.__dict__.get("on_disable")
if desc is None:
return None
# unwrap staticmethod to get the real function
if isinstance(desc, staticmethod):
func = desc.__func__
else:
func = desc # already a function
src = inspect.getsource(func)
src = textwrap.dedent(src)
# Build a standalone script that defines the function and calls it
script = f"""#!/usr/bin/env python3
# generated from {cls.__module__}.{cls.__name__}.on_disable
{src}
if __name__ == "__main__":
{func.__name__}()
"""
script_file = fs.File(content=script, permissions=0o755)
script_path = os.path.join(out_dir, f"{mod_obj.name}_on_disable.py")
script_file.copy_to(script_path)
return script_path
def _iter_code_objects(code: types.CodeType):
yield code
for const in code.co_consts:
if isinstance(const, types.CodeType):
yield from _iter_code_objects(const)
def _validate_on_disable(module_type: str, func: types.FunctionType) -> None:
# No args
if inspect.signature(func).parameters:
raise errors.InvalidOnDisableError(module_type, "on_disable must take no parameters")
bad_names: set[str] = set()
for code in _iter_code_objects(func.__code__):
# No closures anywhere (outer or nested)
if code.co_freevars:
raise errors.InvalidOnDisableError(
module_type, "on_disable must not close over outer variables"
)
# No non-builtin globals / nonlocals anywhere
for ins in dis.get_instructions(code):
if ins.opname in ("LOAD_GLOBAL", "LOAD_DEREF"):
name = ins.argval
if not hasattr(builtins, name):
bad_names.add(name)
if bad_names:
raise errors.InvalidOnDisableError(
module_type,
f"on_disable uses nonlocal/global names: {', '.join(sorted(bad_names))}",
)
+53
View File
@@ -0,0 +1,53 @@
import importlib.metadata as metadata
import decman.core.module as module
class Plugin:
"""
A Plugin manages one part of a system.
NAME:
Canonical plugin name.
"""
NAME: str = ""
def available(self) -> bool:
"""
Checks if this plugin can be enabled.
For example, this could check if a required command is available.
Returns true if this plugin can be enabled.
"""
return True
def apply(self, dry_run: bool = False):
"""
Ensures that the state managed by this plugin is present.
Set ``dry_run`` to only print changes applying this plugin would cause.
"""
def process_module(self, module: module.Module):
"""
Processes a module.
"""
def available_plugins() -> dict[str, Plugin]:
"""
Returns all available plugins.
"""
plugins = {}
eps = metadata.entry_points(group="decman.plugins")
for ep in eps:
cls = ep.load()
if not issubclass(cls, Plugin):
continue
instance = cls()
if instance.available():
plugins[cls.NAME] = instance
return plugins
+5
View File
@@ -0,0 +1,5 @@
import decman.plugins as plugins
class Pacman(plugins.Plugin):
NAME = "pacman"
+5
View File
@@ -0,0 +1,5 @@
import decman.plugins as plugins
class Systemd(plugins.Plugin):
NAME = "systemd"
+180
View File
@@ -0,0 +1,180 @@
import stat
import subprocess
import sys
from pathlib import Path
import pytest
import decman.core.error as errors
import decman.core.module as module
def test_module_without_on_disable_is_accepted():
class NoOnDisable(module.Module):
def __init__(self):
super().__init__("no_on_disable")
m = NoOnDisable()
assert m.name == "no_on_disable"
def test_on_disable_must_be_staticmethod():
with pytest.raises(errors.InvalidOnDisableError) as exc:
class NotStatic(module.Module):
def on_disable(): # type: ignore[no-redefined-builtin]
pass
msg = str(exc.value)
assert "on_disable must be declared as @staticmethod" in msg
def test_on_disable_must_take_no_parameters():
with pytest.raises(errors.InvalidOnDisableError) as exc:
class HasArgs(module.Module):
@staticmethod
def on_disable(x): # type: ignore[unused-argument]
pass
msg = str(exc.value)
assert "on_disable must take no parameters" in msg
SOME_CONST = 42 # noqa: F841
def test_on_disable_must_not_use_module_level_globals():
with pytest.raises(errors.InvalidOnDisableError) as exc:
class UsesGlobal(module.Module):
@staticmethod
def on_disable():
# will compile as LOAD_GLOBAL for SOME_CONST
print(SOME_CONST)
msg = str(exc.value)
assert "on_disable uses nonlocal/global names" in msg
assert "SOME_CONST" in msg
def test_on_disable_must_not_close_over_outer_variables():
# closure over outer local -> should be rejected via co_freevars on inner code
with pytest.raises(errors.InvalidOnDisableError) as exc:
class Closure(module.Module):
@staticmethod
def on_disable():
x = 1
def inner():
# closes over x
print(x) # pragma: no cover
inner()
msg = str(exc.value)
assert "must not close over outer variables" in msg
def test_on_disable_nested_function_without_closure_is_allowed():
class NestedNoClosure(module.Module):
def __init__(self):
super().__init__("nested_no_closure")
@staticmethod
def on_disable():
# nested function that only uses arguments / builtins
def inner(msg: str) -> None:
print(msg)
inner("OK")
# If the class definition above passed without raising, validation succeeded.
m = NestedNoClosure()
assert m.name == "nested_no_closure"
def test_on_disable_can_use_builtins_and_imports_inside_function():
class Valid(module.Module):
def __init__(self):
super().__init__("valid")
@staticmethod
def on_disable():
import math
print("sqrt2", round(math.sqrt(2), 3))
v = Valid()
assert v.name == "valid"
def test_write_on_disable_script_returns_none_when_no_on_disable(tmp_path):
class NoOnDisable(module.Module):
def __init__(self):
super().__init__("no_on_disable")
m = NoOnDisable()
script_path = module.write_on_disable_script(m, str(tmp_path))
assert script_path is None
assert not list(tmp_path.iterdir())
def test_write_on_disable_script_creates_executable_script(tmp_path):
class Simple(module.Module):
def __init__(self):
super().__init__("Simple")
@staticmethod
def on_disable():
print("ON_DISABLE_RUN")
m = Simple()
out_dir = tmp_path / "scripts"
out_dir.mkdir()
script_path_str = module.write_on_disable_script(m, str(out_dir))
assert script_path_str is not None
script_path = Path(script_path_str)
assert script_path.exists()
mode = script_path.stat().st_mode
assert mode & stat.S_IXUSR, "script must be executable by owner"
content = script_path.read_text(encoding="utf-8")
assert "generated from" in content
assert "def on_disable" in content
assert 'if __name__ == "__main__":' in content
# Execute the generated script and check its output
proc = subprocess.run(
[sys.executable, str(script_path)],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
assert "ON_DISABLE_RUN" in proc.stdout
def test_write_on_disable_script_uses_module_and_class_in_header(tmp_path):
class HeaderCheck(module.Module):
def __init__(self):
super().__init__("HeaderCheck")
@staticmethod
def on_disable():
print("HEADER_CHECK")
m = HeaderCheck()
script_path_str = module.write_on_disable_script(m, str(tmp_path))
assert script_path_str is not None
script_path = Path(script_path_str)
content = script_path.read_text(encoding="utf-8")
# header should reference original module and class
assert f"{HeaderCheck.__module__}.{HeaderCheck.__name__}.on_disable" in content