Files
falyx/tests/test_hook_manager.py
Roland Thomas efe3f5fd99 feat(core): clone commands and actions when binding runtimes
Add clone support across Action types and Command so commands can be safely
registered or runner-bound without mutating the original instances.

- clone BaseAction implementations across simple, composite, IO, prompt, file,
  HTTP, process, and signal actions
- bind cloned commands in Falyx.add_command_from_command() and CommandRunner
- preserve local never_prompt settings when cloning actions
- rename shared runtime state from options to options_manager for consistency
- seed root and execution option namespaces consistently
- apply scoped root and namespace option overrides during routing and dispatch
- improve namespace completion by delegating option suggestions to FalyxParser
- enrich missing-value errors and error hints
2026-06-07 13:04:35 -04:00

227 lines
6.8 KiB
Python

from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
from falyx.hook_manager import HookManager, HookType
def make_context(*, name: str = "DemoAction", exception: Exception | None = None) -> Any:
return SimpleNamespace(name=name, exception=exception, events=[])
def test_hook_type_choices_aliases_and_string_representation() -> None:
assert HookType.choices() == [
HookType.BEFORE,
HookType.ON_SUCCESS,
HookType.ON_ERROR,
HookType.AFTER,
HookType.ON_TEARDOWN,
]
assert HookType(" before ") is HookType.BEFORE
assert HookType("success") is HookType.ON_SUCCESS
assert HookType(" ERROR ") is HookType.ON_ERROR
assert HookType("teardown") is HookType.ON_TEARDOWN
assert str(HookType.AFTER) == "after"
@pytest.mark.parametrize("bad_value", [7, object()])
def test_hook_type_rejects_non_string_missing_values(bad_value: object) -> None:
with pytest.raises(ValueError, match="Invalid HookType"):
HookType(bad_value)
def test_hook_type_rejects_unknown_string_with_valid_choices() -> None:
with pytest.raises(ValueError) as exc_info:
HookType("not-a-hook")
message = str(exc_info.value)
assert "Invalid HookType: 'not-a-hook'" in message
assert "before" in message
assert "on_success" in message
assert "on_error" in message
assert "after" in message
assert "on_teardown" in message
def test_manager_initializes_all_hook_buckets_and_registers_aliases() -> None:
manager = HookManager()
assert set(manager._hooks) == set(HookType)
assert all(hooks == [] for hooks in manager._hooks.values())
def before_hook(context: Any) -> None:
context.events.append("before")
def success_hook(context: Any) -> None:
context.events.append("success")
manager.register(HookType.BEFORE, before_hook)
manager.register("success", success_hook)
assert manager._hooks[HookType.BEFORE] == [before_hook]
assert manager._hooks[HookType.ON_SUCCESS] == [success_hook]
def test_register_rejects_invalid_hook_type() -> None:
manager = HookManager()
def hook(context: Any) -> None:
context.events.append("never-called")
with pytest.raises(ValueError, match="Invalid HookType"):
manager.register("missing-phase", hook)
@pytest.mark.asyncio
async def test_trigger_runs_sync_and_async_hooks_in_registration_order() -> None:
manager = HookManager()
context = make_context()
def sync_first(ctx: Any) -> None:
ctx.events.append("sync-first")
async def async_second(ctx: Any) -> None:
ctx.events.append("async-second")
def sync_third(ctx: Any) -> None:
ctx.events.append("sync-third")
manager.register("before", sync_first)
manager.register(HookType.BEFORE, async_second)
manager.register("before", sync_third)
await manager.trigger(HookType.BEFORE, context)
assert context.events == ["sync-first", "async-second", "sync-third"]
@pytest.mark.asyncio
async def test_trigger_rejects_unsupported_runtime_hook_type() -> None:
manager = HookManager()
with pytest.raises(ValueError, match="Unsupported hook type"):
await manager.trigger("not-a-hook", make_context()) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_trigger_logs_and_continues_after_non_error_hook_failure() -> None:
manager = HookManager()
context = make_context()
def failing_hook(ctx: Any) -> None:
ctx.events.append("failing")
raise RuntimeError("hook exploded")
def surviving_hook(ctx: Any) -> None:
ctx.events.append("surviving")
manager.register(HookType.BEFORE, failing_hook)
manager.register(HookType.BEFORE, surviving_hook)
await manager.trigger(HookType.BEFORE, context)
assert context.events == ["failing", "surviving"]
@pytest.mark.asyncio
async def test_trigger_on_error_hook_failure_reraises_original_context_exception() -> (
None
):
manager = HookManager()
original_error = ValueError("original failure")
context = make_context(exception=original_error)
def failing_error_hook(ctx: Any) -> None:
ctx.events.append("error-hook")
raise RuntimeError("error hook failed")
manager.register("error", failing_error_hook)
with pytest.raises(ValueError) as exc_info:
await manager.trigger(HookType.ON_ERROR, context)
assert exc_info.value is original_error
assert isinstance(exc_info.value.__cause__, RuntimeError)
assert str(exc_info.value.__cause__) == "error hook failed"
assert context.events == ["error-hook"]
@pytest.mark.asyncio
async def test_trigger_on_error_requires_context_exception_when_hook_fails() -> None:
manager = HookManager()
context = make_context(exception=None)
def failing_error_hook(ctx: Any) -> None:
raise RuntimeError("error hook failed")
manager.register(HookType.ON_ERROR, failing_error_hook)
with pytest.raises(AssertionError, match="Context exception should be set"):
await manager.trigger(HookType.ON_ERROR, context)
def test_clear_removes_one_hook_bucket_or_all_buckets() -> None:
manager = HookManager()
def before_hook(context: Any) -> None:
context.events.append("before")
def after_hook(context: Any) -> None:
context.events.append("after")
manager.register("before", before_hook)
manager.register("after", after_hook)
manager.clear(HookType.BEFORE)
assert manager._hooks[HookType.BEFORE] == []
assert manager._hooks[HookType.AFTER] == [after_hook]
manager.clear()
assert all(hooks == [] for hooks in manager._hooks.values())
def test_string_representation_lists_registered_hook_names_and_empty_buckets() -> None:
manager = HookManager()
def before_hook(context: Any) -> None:
context.events.append("before")
manager.register("before", before_hook)
text = str(manager)
assert text.startswith("<HookManager>")
assert "before: before_hook" in text
assert "on_success: —" in text
assert "on_error: —" in text
assert "after: —" in text
assert "on_teardown: —" in text
def test_copy_copies_hook_lists_without_sharing_list_objects() -> None:
manager = HookManager()
def first_hook(context: Any) -> None:
context.events.append("first")
def second_hook(context: Any) -> None:
context.events.append("second")
manager.register("teardown", first_hook)
clone = manager.copy()
assert clone is not manager
assert clone._hooks[HookType.ON_TEARDOWN] == [first_hook]
assert clone._hooks[HookType.ON_TEARDOWN] is not manager._hooks[HookType.ON_TEARDOWN]
clone.register("teardown", second_hook)
assert manager._hooks[HookType.ON_TEARDOWN] == [first_hook]
assert clone._hooks[HookType.ON_TEARDOWN] == [first_hook, second_hook]