diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 679e629..26e85bd 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 +``` diff --git a/example/plugin/decman_plugin_example.py b/example/plugin/decman_plugin_example.py new file mode 100644 index 0000000..80b3260 --- /dev/null +++ b/example/plugin/decman_plugin_example.py @@ -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") diff --git a/example/plugin/pyproject.toml b/example/plugin/pyproject.toml new file mode 100644 index 0000000..2b3c09d --- /dev/null +++ b/example/plugin/pyproject.toml @@ -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" diff --git a/pyproject.toml b/pyproject.toml index ba5d969..134ddd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/decman/__init__.py b/src/decman/__init__.py index 50af313..8cabfc5 100644 --- a/src/decman/__init__.py +++ b/src/decman/__init__.py @@ -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], diff --git a/src/decman/app.py b/src/decman/app.py new file mode 100644 index 0000000..90ea2be --- /dev/null +++ b/src/decman/app.py @@ -0,0 +1,5 @@ +import decman + + +def main(): + print(decman.plugins) diff --git a/src/decman/core/error.py b/src/decman/core/error.py index cfddc86..b1460fd 100644 --- a/src/decman/core/error.py +++ b/src/decman/core/error.py @@ -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. diff --git a/src/decman/core/fs.py b/src/decman/core/fs.py index 916a452..6338938 100644 --- a/src/decman/core/fs.py +++ b/src/decman/core/fs.py @@ -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. """ diff --git a/src/decman/core/module.py b/src/decman/core/module.py new file mode 100644 index 0000000..313f260 --- /dev/null +++ b/src/decman/core/module.py @@ -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))}", + ) diff --git a/src/decman/plugins/__init__.py b/src/decman/plugins/__init__.py new file mode 100644 index 0000000..e146509 --- /dev/null +++ b/src/decman/plugins/__init__.py @@ -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 diff --git a/src/decman/plugins/pacman.py b/src/decman/plugins/pacman.py new file mode 100644 index 0000000..941f500 --- /dev/null +++ b/src/decman/plugins/pacman.py @@ -0,0 +1,5 @@ +import decman.plugins as plugins + + +class Pacman(plugins.Plugin): + NAME = "pacman" diff --git a/src/decman/plugins/systemd.py b/src/decman/plugins/systemd.py new file mode 100644 index 0000000..27c84e2 --- /dev/null +++ b/src/decman/plugins/systemd.py @@ -0,0 +1,5 @@ +import decman.plugins as plugins + + +class Systemd(plugins.Plugin): + NAME = "systemd" diff --git a/tests/test_decman_core_files.py b/tests/test_decman_core_fs.py similarity index 100% rename from tests/test_decman_core_files.py rename to tests/test_decman_core_fs.py diff --git a/tests/test_decman_core_module.py b/tests/test_decman_core_module.py new file mode 100644 index 0000000..bd88937 --- /dev/null +++ b/tests/test_decman_core_module.py @@ -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