mirror of
https://github.com/kiviktnm/decman.git
synced 2026-09-19 12:08:28 +00:00
Add plugin architecture
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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
@@ -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],
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import decman
|
||||
|
||||
|
||||
def main():
|
||||
print(decman.plugins)
|
||||
@@ -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
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
@@ -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))}",
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
import decman.plugins as plugins
|
||||
|
||||
|
||||
class Pacman(plugins.Plugin):
|
||||
NAME = "pacman"
|
||||
@@ -0,0 +1,5 @@
|
||||
import decman.plugins as plugins
|
||||
|
||||
|
||||
class Systemd(plugins.Plugin):
|
||||
NAME = "systemd"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user