Skip to content

Commit 6a8870f

Browse files
mnriemCopilot
andauthored
chore: refactor event domain layout (#4683)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 3789423 commit 6a8870f

6 files changed

Lines changed: 52 additions & 38 deletions

File tree

‎src/specify_cli/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,7 @@ def version(
539539

540540

541541
# ===== Event Commands =====
542-
from .commands.event import register as _register_event_cmds # noqa: E402
542+
from .events import register as _register_event_cmds # noqa: E402
543543
_register_event_cmds(app)
544544

545545
# Re-export selected helpers to preserve the public import surface.
Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
"""Agent runtime events for integrations.
1+
"""Agent runtime events and the ``specify event`` command group.
22
33
Provides:
44
- ``resolve_events`` — layered event resolution (CLI flag → YAML override → extension-declared → built-in).
55
- ``collect_extension_events`` — scan installed extension.yml files for ``events:``.
66
- ``install_integration_events`` / ``remove_integration_events`` — entry points called from ``IntegrationBase.setup()`` / ``teardown()``.
7+
8+
The singular CLI namespace intentionally maps to the plural Python package.
9+
``specify_cli.events`` is an established domain API and monkeypatch boundary,
10+
so preserving it takes precedence over matching the CLI spelling on disk.
11+
``command_run`` contains the nested CLI adapter; this module owns the domain
12+
API, Typer application, and registration.
713
"""
814

915
from __future__ import annotations
@@ -21,13 +27,20 @@
2127
from typing import TYPE_CHECKING, Any
2228

2329
import yaml
30+
import typer
2431

2532
if TYPE_CHECKING:
26-
from .integrations.base import IntegrationBase
27-
from .integrations.manifest import IntegrationManifest
33+
from ..integrations.base import IntegrationBase
34+
from ..integrations.manifest import IntegrationManifest
2835

2936
logger = logging.getLogger(__name__)
3037

38+
event_app = typer.Typer(
39+
name="event",
40+
help="Manage and execute event-driven commands",
41+
add_completion=False,
42+
)
43+
3144
# -- Constants -------------------------------------------------------------
3245

3346
# 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
526539
# on-disk fallback below.
527540
disabled_ids = _disabled_extension_ids(project_root)
528541
try:
529-
from .extensions import ExtensionManager
542+
from ..extensions import ExtensionManager
530543
manager = ExtensionManager(project_root)
531544
for ext_id in sorted(manager.registry.keys()):
532545
if ext_id in disabled_ids:
@@ -572,7 +585,7 @@ def _find_command_template(command_name: str, project_root: Path) -> tuple[Path
572585
# templates/commands). The previous bespoke inspect.getfile() math
573586
# pointed at core_pack/templates/commands, which never exists in a
574587
# wheel build (force-include maps templates/commands -> core_pack/commands).
575-
from ._assets import _locate_core_pack, _repo_root
588+
from .._assets import _locate_core_pack, _repo_root
576589
core_pack = _locate_core_pack()
577590
candidate_dirs = [
578591
core_pack / "commands" if core_pack is not None else None,
@@ -626,7 +639,7 @@ def _resolve_event_command_argv(
626639
``.py``, the platform shell otherwise). Returns ``None`` if no runnable
627640
script is declared.
628641
"""
629-
from .integrations.base import IntegrationBase
642+
from ..integrations.base import IntegrationBase
630643

631644
try:
632645
content = template_path.read_text(encoding="utf-8")
@@ -730,7 +743,7 @@ def _load_project_script_type(project_root: Path) -> str:
730743
"""
731744
default = "ps" if platform.system().lower().startswith("win") else "sh"
732745
try:
733-
from ._init_options import load_init_options
746+
from .._init_options import load_init_options
734747
opts = load_init_options(project_root)
735748
if isinstance(opts, dict):
736749
script = opts.get("script")
@@ -885,7 +898,7 @@ def _validate_resolved_event(event_name: str, handlers: list[dict[str, Any]]) ->
885898
#17). Malformed-but-skipable entries are already dropped by
886899
``_normalize_handlers``.
887900
"""
888-
from .extensions import ValidationError
901+
from ..extensions import ValidationError
889902

890903
if event_name not in CANONICAL_EVENTS:
891904
raise ValidationError(
@@ -1039,7 +1052,7 @@ def _disabled_extension_ids(project_root: Path) -> set[str]:
10391052
disabled extension after its config file was preserved (e.g. a JSONC
10401053
parse failure that skipped native cleanup).
10411054
"""
1042-
from .extensions import ExtensionRegistry
1055+
from ..extensions import ExtensionRegistry
10431056

10441057
exts_dir = project_root / ".specify" / "extensions"
10451058
disabled_ids: set[str] = set()
@@ -1075,7 +1088,7 @@ def collect_extension_events(project_root: Path) -> ResolvedEvents:
10751088
obsolete name and ``_find_command_template`` could not match it, leaving
10761089
the hook silently inert.
10771090
"""
1078-
from .extensions import ExtensionManager
1091+
from ..extensions import ExtensionManager
10791092

10801093
events: ResolvedEvents = {}
10811094
exts_dir = project_root / ".specify" / "extensions"
@@ -1143,7 +1156,7 @@ def _resolve_interpreter(project_root: Path) -> str:
11431156
commands honor the project venv and never hard-code ``python3`` (which is
11441157
commonly absent on Windows even when ``py.exe``/``python.exe`` exist).
11451158
"""
1146-
from .integrations.base import IntegrationBase
1159+
from ..integrations.base import IntegrationBase
11471160
return IntegrationBase.resolve_python_interpreter(project_root)
11481161

11491162

@@ -1686,9 +1699,9 @@ def _other_event_integrations_reference_dispatcher(
16861699
for the dispatcher path so uninstalling one multi-install event-capable
16871700
integration doesn't delete the dispatcher the others still rely on.
16881701
"""
1689-
from .integrations._helpers import _read_integration_json
1690-
from .integrations.manifest import IntegrationManifest
1691-
from .integration_state import installed_integration_keys
1702+
from ..integrations._helpers import _read_integration_json
1703+
from ..integrations.manifest import IntegrationManifest
1704+
from ..integration_state import installed_integration_keys
16921705

16931706
state = _read_integration_json(project_root)
16941707
for key in installed_integration_keys(state):
@@ -1763,7 +1776,7 @@ def remove_integration_events(
17631776

17641777
def events_stale_exclusions(integration_key: str) -> set[str]:
17651778
"""Return project-relative paths to protect from stale cleanup."""
1766-
from .integrations import get_integration
1779+
from ..integrations import get_integration
17671780
integration = get_integration(integration_key)
17681781
if not integration:
17691782
return set()
@@ -1813,10 +1826,10 @@ def refresh_integration_events(project_root: Path) -> None:
18131826
the lifecycle command can't claim the extension was fully deactivated
18141827
while a stale native hook may still be active (R3).
18151828
"""
1816-
from .integrations import get_integration
1817-
from .integrations._helpers import _read_integration_json, _resolve_integration_options
1818-
from .integrations.manifest import IntegrationManifest
1819-
from .integration_state import installed_integration_keys
1829+
from ..integrations import get_integration
1830+
from ..integrations._helpers import _read_integration_json, _resolve_integration_options
1831+
from ..integrations.manifest import IntegrationManifest
1832+
from ..integration_state import installed_integration_keys
18201833

18211834
state = _read_integration_json(project_root)
18221835
failures: list[tuple[str, str]] = []
@@ -1863,7 +1876,7 @@ def refresh_integration_events(project_root: Path) -> None:
18631876

18641877
def validate_events(data: dict[str, Any]) -> None:
18651878
"""Validate ``events`` field in extension manifest data."""
1866-
from .extensions import ValidationError
1879+
from ..extensions import ValidationError
18671880

18681881
events = data.get("events")
18691882
if "events" in data and not isinstance(events, dict):
@@ -1911,7 +1924,7 @@ def has_events(data: dict[str, Any]) -> bool:
19111924

19121925
def _toml_quote(value: str) -> str:
19131926
"""Render *value* as a TOML basic string via the shared escaper."""
1914-
from ._toml_string import escape_toml_basic
1927+
from .._toml_string import escape_toml_basic
19151928
return escape_toml_basic(value)
19161929

19171930

@@ -2547,7 +2560,7 @@ def _ensure_safe_destination(dst: Path) -> None:
25472560
outside the repo would redirect writes to external files). Then validates
25482561
lexical containment so ``..`` traversal is also rejected.
25492562
"""
2550-
from .agents import CommandRegistrar
2563+
from ..agents import CommandRegistrar
25512564

25522565
# Walk each component so a symlinked ancestor (e.g. ``.claude`` → outside)
25532566
# cannot be silently followed. Mirrors IntegrationManifest.record_existing.
@@ -2619,3 +2632,10 @@ def _has_marker(entry: Any) -> bool:
26192632
if isinstance(inner, list):
26202633
return any(_has_marker(h) for h in inner)
26212634
return False
2635+
2636+
2637+
def register(app: typer.Typer) -> None:
2638+
"""Attach the event command group to the root application."""
2639+
from . import command_run # noqa: F401 -- registers handler
2640+
2641+
app.add_typer(event_app, name="event")
Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
1-
"""specify event * command handlers."""
1+
"""CLI adapter for ``specify event run``."""
22

33
from __future__ import annotations
44

55
from pathlib import Path
66
import sys
7+
78
import typer
89

9-
event_app = typer.Typer(
10-
name="event",
11-
help="Manage and execute event-driven commands",
12-
add_completion=False,
13-
)
10+
from . import event_app
1411

1512

1613
@event_app.command("run")
@@ -22,7 +19,7 @@ def event_run(
2219
),
2320
):
2421
"""Resolve and run an event-driven command script with stdin payload."""
25-
from ..events import resolve_and_run_event_command
22+
from . import resolve_and_run_event_command
2623

2724
# Read payload from stdin if available (capped at 1 MiB to prevent DoS).
2825
MAX_STDIN_BYTES = 1 * 1024 * 1024
@@ -55,7 +52,3 @@ def event_run(
5552
command_name, event_name, payload, project_root, timeout=timeout
5653
)
5754
raise typer.Exit(code=exit_code)
58-
59-
60-
def register(app: typer.Typer) -> None:
61-
app.add_typer(event_app, name="event")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Tests for the event domain and its nested command surface."""

tests/test_event_command.py renamed to tests/specify_cli/events/test_command_run.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""`specify event run` must read piped stdin without crashing.
22
3-
`event_run` (src/specify_cli/commands/event.py) capped its stdin read at 1
3+
`event_run` (src/specify_cli/events/command_run.py) capped its stdin read at 1
44
MiB to prevent a DoS (#3857), but the truncation check read a `.eof`
55
attribute that does not exist on any Python file-like object (including
66
`sys.stdin`) — every piped-stdin invocation raised `AttributeError` instead
@@ -22,7 +22,7 @@
2222
from typer.testing import CliRunner
2323

2424
from specify_cli import app
25-
from specify_cli.commands.event import event_run
25+
from specify_cli.events.command_run import event_run
2626

2727

2828
def test_event_run_reads_piped_stdin_payload():
@@ -71,7 +71,7 @@ class FakeTtyStdin:
7171
def isatty(self):
7272
return True
7373

74-
monkeypatch.setattr("specify_cli.commands.event.sys.stdin", FakeTtyStdin())
74+
monkeypatch.setattr("specify_cli.events.command_run.sys.stdin", FakeTtyStdin())
7575

7676
with patch(
7777
"specify_cli.events.resolve_and_run_event_command", return_value=0
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Tests for events module: integration runtime events."""
1+
"""Tests for the event domain and integration runtime hooks."""
22

33
from __future__ import annotations
44

0 commit comments

Comments
 (0)