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
|
```sh
|
||||||
uv run ruff check --fix
|
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]
|
[project.scripts]
|
||||||
decman = "decman.app:main"
|
decman = "decman.app:main"
|
||||||
|
|
||||||
|
[project.entry-points."decman.plugins"]
|
||||||
|
systemd = "decman.plugins.systemd:Systemd"
|
||||||
|
pacman = "decman.plugins.pacman:Pacman"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff>=0.14.9",
|
"ruff>=0.14.9",
|
||||||
|
|||||||
+39
-2
@@ -4,16 +4,53 @@ import typing
|
|||||||
import decman.core.command as command
|
import decman.core.command as command
|
||||||
import decman.core.output as output
|
import decman.core.output as output
|
||||||
|
|
||||||
# Re-export File and Directory
|
# Re-exports
|
||||||
from decman.core.fs import Directory, File # noqa: F401
|
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__ = [
|
__all__ = [
|
||||||
|
"SourceError",
|
||||||
"File",
|
"File",
|
||||||
"Directory",
|
"Directory",
|
||||||
|
"Module",
|
||||||
|
"Plugin",
|
||||||
"prg",
|
"prg",
|
||||||
"sh",
|
"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(
|
def prg(
|
||||||
cmd: list[str],
|
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):
|
class UserNotFoundError(Exception):
|
||||||
"""
|
"""
|
||||||
Raised when a specified user cannot be found in the system.
|
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.
|
directories are created recursively and assigned the same ownership as the file when specified.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
source_file:
|
``source_file``:
|
||||||
Path to an existing file to copy from. Mutually exclusive with ``content``.
|
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``.
|
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
|
If ``True``, treat the file as binary. Disables variable substitution and writes bytes
|
||||||
verbatim.
|
verbatim.
|
||||||
|
|
||||||
encoding:
|
``encoding``:
|
||||||
Text encoding used when reading or writing non-binary files.
|
Text encoding used when reading or writing non-binary files.
|
||||||
|
|
||||||
owner:
|
``owner``:
|
||||||
System user name to own the file and created parent directories.
|
System user name to own the file and created parent directories.
|
||||||
|
|
||||||
group:
|
``group``:
|
||||||
System group name to own the file and created parent directories.
|
System group name to own the file and created parent directories.
|
||||||
|
|
||||||
permissions:
|
``permissions``:
|
||||||
File mode applied to the target file (e.g. ``0o644``).
|
File mode applied to the target file (e.g. ``0o644``).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError
|
``ValueError``
|
||||||
If both ``source_file`` and ``content`` are ``None`` or if both are set.
|
If both ``source_file`` and ``content`` are ``None`` or if both are set.
|
||||||
|
|
||||||
UserNotFoundError
|
``UserNotFoundError``
|
||||||
If ``owner`` does not exist on the system.
|
If ``owner`` does not exist on the system.
|
||||||
|
|
||||||
GroupNotFoundError
|
``GroupNotFoundError``
|
||||||
If ``group`` does not exist on the system.
|
If ``group`` does not exist on the system.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
@@ -214,30 +214,30 @@ class Directory:
|
|||||||
variable substitution before being written.
|
variable substitution before being written.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
source_directory:
|
``source_directory``:
|
||||||
Path to the directory whose contents will be mirrored into the target.
|
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
|
If ``True``, treat all files as binary; disables variable substitution and copies bytes
|
||||||
verbatim.
|
verbatim.
|
||||||
|
|
||||||
encoding:
|
``encoding``:
|
||||||
Text encoding used when reading or writing non-binary files.
|
Text encoding used when reading or writing non-binary files.
|
||||||
|
|
||||||
owner:
|
``owner``:
|
||||||
System user name to own created files and directories.
|
System user name to own created files and directories.
|
||||||
|
|
||||||
group:
|
``group``:
|
||||||
System group name to own created files and directories.
|
System group name to own created files and directories.
|
||||||
|
|
||||||
permissions:
|
``permissions``:
|
||||||
File mode applied to created or updated files (e.g. ``0o644``).
|
File mode applied to created or updated files (e.g. ``0o644``).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
UserNotFoundError
|
``UserNotFoundError``
|
||||||
If ``owner`` does not exist on the system.
|
If ``owner`` does not exist on the system.
|
||||||
|
|
||||||
GroupNotFoundError
|
``GroupNotFoundError``
|
||||||
If ``group`` does not exist on the system.
|
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