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
+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