From d26ef8f4eb8d2b64641905c31f4db2b2aa8e26b6 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:19:20 -0500 Subject: [PATCH] chore: refactor event domain layout Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/__init__.py | 2 +- .../{events.py => events/__init__.py} | 64 ++++++++++++------- .../event.py => events/command_run.py} | 15 ++--- tests/specify_cli/events/__init__.py | 1 + .../events/test_command_run.py} | 6 +- .../events}/test_events.py | 2 +- 6 files changed, 52 insertions(+), 38 deletions(-) rename src/specify_cli/{events.py => events/__init__.py} (98%) rename src/specify_cli/{commands/event.py => events/command_run.py} (86%) create mode 100644 tests/specify_cli/events/__init__.py rename tests/{test_event_command.py => specify_cli/events/test_command_run.py} (95%) rename tests/{integrations => specify_cli/events}/test_events.py (99%) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ccc2be67f3..1267135385 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -539,7 +539,7 @@ def version( # ===== Event Commands ===== -from .commands.event import register as _register_event_cmds # noqa: E402 +from .events import register as _register_event_cmds # noqa: E402 _register_event_cmds(app) # Re-export selected helpers to preserve the public import surface. diff --git a/src/specify_cli/events.py b/src/specify_cli/events/__init__.py similarity index 98% rename from src/specify_cli/events.py rename to src/specify_cli/events/__init__.py index 3632661711..b78e5da841 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events/__init__.py @@ -1,9 +1,15 @@ -"""Agent runtime events for integrations. +"""Agent runtime events and the ``specify event`` command group. Provides: - ``resolve_events`` — layered event resolution (CLI flag → YAML override → extension-declared → built-in). - ``collect_extension_events`` — scan installed extension.yml files for ``events:``. - ``install_integration_events`` / ``remove_integration_events`` — entry points called from ``IntegrationBase.setup()`` / ``teardown()``. + +The singular CLI namespace intentionally maps to the plural Python package. +``specify_cli.events`` is an established domain API and monkeypatch boundary, +so preserving it takes precedence over matching the CLI spelling on disk. +``command_run`` contains the nested CLI adapter; this module owns the domain +API, Typer application, and registration. """ from __future__ import annotations @@ -21,13 +27,20 @@ from typing import TYPE_CHECKING, Any import yaml +import typer if TYPE_CHECKING: - from .integrations.base import IntegrationBase - from .integrations.manifest import IntegrationManifest + from ..integrations.base import IntegrationBase + from ..integrations.manifest import IntegrationManifest logger = logging.getLogger(__name__) +event_app = typer.Typer( + name="event", + help="Manage and execute event-driven commands", + add_completion=False, +) + # -- Constants ------------------------------------------------------------- # Generated hook dispatchers refuse to delegate unless this name is True. @@ -526,7 +539,7 @@ def _find_command_template(command_name: str, project_root: Path) -> tuple[Path # on-disk fallback below. disabled_ids = _disabled_extension_ids(project_root) try: - from .extensions import ExtensionManager + from ..extensions import ExtensionManager manager = ExtensionManager(project_root) for ext_id in sorted(manager.registry.keys()): if ext_id in disabled_ids: @@ -572,7 +585,7 @@ def _find_command_template(command_name: str, project_root: Path) -> tuple[Path # templates/commands). The previous bespoke inspect.getfile() math # pointed at core_pack/templates/commands, which never exists in a # wheel build (force-include maps templates/commands -> core_pack/commands). - from ._assets import _locate_core_pack, _repo_root + from .._assets import _locate_core_pack, _repo_root core_pack = _locate_core_pack() candidate_dirs = [ core_pack / "commands" if core_pack is not None else None, @@ -626,7 +639,7 @@ def _resolve_event_command_argv( ``.py``, the platform shell otherwise). Returns ``None`` if no runnable script is declared. """ - from .integrations.base import IntegrationBase + from ..integrations.base import IntegrationBase try: content = template_path.read_text(encoding="utf-8") @@ -730,7 +743,7 @@ def _load_project_script_type(project_root: Path) -> str: """ default = "ps" if platform.system().lower().startswith("win") else "sh" try: - from ._init_options import load_init_options + from .._init_options import load_init_options opts = load_init_options(project_root) if isinstance(opts, dict): script = opts.get("script") @@ -885,7 +898,7 @@ def _validate_resolved_event(event_name: str, handlers: list[dict[str, Any]]) -> #17). Malformed-but-skipable entries are already dropped by ``_normalize_handlers``. """ - from .extensions import ValidationError + from ..extensions import ValidationError if event_name not in CANONICAL_EVENTS: raise ValidationError( @@ -1039,7 +1052,7 @@ def _disabled_extension_ids(project_root: Path) -> set[str]: disabled extension after its config file was preserved (e.g. a JSONC parse failure that skipped native cleanup). """ - from .extensions import ExtensionRegistry + from ..extensions import ExtensionRegistry exts_dir = project_root / ".specify" / "extensions" disabled_ids: set[str] = set() @@ -1075,7 +1088,7 @@ def collect_extension_events(project_root: Path) -> ResolvedEvents: obsolete name and ``_find_command_template`` could not match it, leaving the hook silently inert. """ - from .extensions import ExtensionManager + from ..extensions import ExtensionManager events: ResolvedEvents = {} exts_dir = project_root / ".specify" / "extensions" @@ -1143,7 +1156,7 @@ def _resolve_interpreter(project_root: Path) -> str: commands honor the project venv and never hard-code ``python3`` (which is commonly absent on Windows even when ``py.exe``/``python.exe`` exist). """ - from .integrations.base import IntegrationBase + from ..integrations.base import IntegrationBase return IntegrationBase.resolve_python_interpreter(project_root) @@ -1686,9 +1699,9 @@ def _other_event_integrations_reference_dispatcher( for the dispatcher path so uninstalling one multi-install event-capable integration doesn't delete the dispatcher the others still rely on. """ - from .integrations._helpers import _read_integration_json - from .integrations.manifest import IntegrationManifest - from .integration_state import installed_integration_keys + from ..integrations._helpers import _read_integration_json + from ..integrations.manifest import IntegrationManifest + from ..integration_state import installed_integration_keys state = _read_integration_json(project_root) for key in installed_integration_keys(state): @@ -1763,7 +1776,7 @@ def remove_integration_events( def events_stale_exclusions(integration_key: str) -> set[str]: """Return project-relative paths to protect from stale cleanup.""" - from .integrations import get_integration + from ..integrations import get_integration integration = get_integration(integration_key) if not integration: return set() @@ -1813,10 +1826,10 @@ def refresh_integration_events(project_root: Path) -> None: the lifecycle command can't claim the extension was fully deactivated while a stale native hook may still be active (R3). """ - from .integrations import get_integration - from .integrations._helpers import _read_integration_json, _resolve_integration_options - from .integrations.manifest import IntegrationManifest - from .integration_state import installed_integration_keys + from ..integrations import get_integration + from ..integrations._helpers import _read_integration_json, _resolve_integration_options + from ..integrations.manifest import IntegrationManifest + from ..integration_state import installed_integration_keys state = _read_integration_json(project_root) failures: list[tuple[str, str]] = [] @@ -1863,7 +1876,7 @@ def refresh_integration_events(project_root: Path) -> None: def validate_events(data: dict[str, Any]) -> None: """Validate ``events`` field in extension manifest data.""" - from .extensions import ValidationError + from ..extensions import ValidationError events = data.get("events") if "events" in data and not isinstance(events, dict): @@ -1911,7 +1924,7 @@ def has_events(data: dict[str, Any]) -> bool: def _toml_quote(value: str) -> str: """Render *value* as a TOML basic string via the shared escaper.""" - from ._toml_string import escape_toml_basic + from .._toml_string import escape_toml_basic return escape_toml_basic(value) @@ -2547,7 +2560,7 @@ def _ensure_safe_destination(dst: Path) -> None: outside the repo would redirect writes to external files). Then validates lexical containment so ``..`` traversal is also rejected. """ - from .agents import CommandRegistrar + from ..agents import CommandRegistrar # Walk each component so a symlinked ancestor (e.g. ``.claude`` → outside) # cannot be silently followed. Mirrors IntegrationManifest.record_existing. @@ -2619,3 +2632,10 @@ def _has_marker(entry: Any) -> bool: if isinstance(inner, list): return any(_has_marker(h) for h in inner) return False + + +def register(app: typer.Typer) -> None: + """Attach the event command group to the root application.""" + from . import command_run # noqa: F401 -- registers handler + + app.add_typer(event_app, name="event") diff --git a/src/specify_cli/commands/event.py b/src/specify_cli/events/command_run.py similarity index 86% rename from src/specify_cli/commands/event.py rename to src/specify_cli/events/command_run.py index 47bcf86e70..c5bf162b3d 100644 --- a/src/specify_cli/commands/event.py +++ b/src/specify_cli/events/command_run.py @@ -1,16 +1,13 @@ -"""specify event * command handlers.""" +"""CLI adapter for ``specify event run``.""" from __future__ import annotations from pathlib import Path import sys + import typer -event_app = typer.Typer( - name="event", - help="Manage and execute event-driven commands", - add_completion=False, -) +from . import event_app @event_app.command("run") @@ -22,7 +19,7 @@ def event_run( ), ): """Resolve and run an event-driven command script with stdin payload.""" - from ..events import resolve_and_run_event_command + from . import resolve_and_run_event_command # Read payload from stdin if available (capped at 1 MiB to prevent DoS). MAX_STDIN_BYTES = 1 * 1024 * 1024 @@ -55,7 +52,3 @@ def event_run( command_name, event_name, payload, project_root, timeout=timeout ) raise typer.Exit(code=exit_code) - - -def register(app: typer.Typer) -> None: - app.add_typer(event_app, name="event") diff --git a/tests/specify_cli/events/__init__.py b/tests/specify_cli/events/__init__.py new file mode 100644 index 0000000000..52c5b633f1 --- /dev/null +++ b/tests/specify_cli/events/__init__.py @@ -0,0 +1 @@ +"""Tests for the event domain and its nested command surface.""" diff --git a/tests/test_event_command.py b/tests/specify_cli/events/test_command_run.py similarity index 95% rename from tests/test_event_command.py rename to tests/specify_cli/events/test_command_run.py index 0a431fb5c6..6782b1dd26 100644 --- a/tests/test_event_command.py +++ b/tests/specify_cli/events/test_command_run.py @@ -1,6 +1,6 @@ """`specify event run` must read piped stdin without crashing. -`event_run` (src/specify_cli/commands/event.py) capped its stdin read at 1 +`event_run` (src/specify_cli/events/command_run.py) capped its stdin read at 1 MiB to prevent a DoS (#3857), but the truncation check read a `.eof` attribute that does not exist on any Python file-like object (including `sys.stdin`) — every piped-stdin invocation raised `AttributeError` instead @@ -22,7 +22,7 @@ from typer.testing import CliRunner from specify_cli import app -from specify_cli.commands.event import event_run +from specify_cli.events.command_run import event_run def test_event_run_reads_piped_stdin_payload(): @@ -71,7 +71,7 @@ class FakeTtyStdin: def isatty(self): return True - monkeypatch.setattr("specify_cli.commands.event.sys.stdin", FakeTtyStdin()) + monkeypatch.setattr("specify_cli.events.command_run.sys.stdin", FakeTtyStdin()) with patch( "specify_cli.events.resolve_and_run_event_command", return_value=0 diff --git a/tests/integrations/test_events.py b/tests/specify_cli/events/test_events.py similarity index 99% rename from tests/integrations/test_events.py rename to tests/specify_cli/events/test_events.py index 946acc3c26..9a193c762c 100644 --- a/tests/integrations/test_events.py +++ b/tests/specify_cli/events/test_events.py @@ -1,4 +1,4 @@ -"""Tests for events module: integration runtime events.""" +"""Tests for the event domain and integration runtime hooks.""" from __future__ import annotations