Skip to content
Open
562 changes: 427 additions & 135 deletions src/specify_cli/extensions/__init__.py

Large diffs are not rendered by default.

70 changes: 51 additions & 19 deletions src/specify_cli/extensions/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
re-fetch package helpers at call time so existing monkeypatch paths keep
working. Cohesive private phases use ``_command_<name>_*.py`` modules.
"""

from __future__ import annotations

import errno
Expand Down Expand Up @@ -34,25 +35,30 @@
add_completion=False,
)


# Root helpers re-fetched at call time so test monkeypatching of
# `specify_cli.<name>` keeps working after the move.
def _require_specify_project(*args, **kwargs):
from .. import _require_specify_project as _f

return _f(*args, **kwargs)


def _locate_bundled_extension(*args, **kwargs):
from .. import _locate_bundled_extension as _f

return _f(*args, **kwargs)


def load_init_options(*args, **kwargs):
from .. import load_init_options as _f

return _f(*args, **kwargs)


def _display_project_path(*args, **kwargs):
from .. import _display_project_path as _f

return _f(*args, **kwargs)


Expand Down Expand Up @@ -97,6 +103,34 @@ def _archive_extension_directory(*args, **kwargs):
return _helper(*args, **kwargs)


def _refresh_presets_and_warn(project_root: Path) -> None:
"""Re-register enabled preset overrides after extension stack changes.

Preset regex selectors expand against currently available lower layers, so
installing, removing, enabling, disabling, or reprioritizing an extension
can change which concrete command declarations are materialized. Keep the
normal preset enablement and active-integration rules by using its existing
integration-switch registration path.
"""
try:
from .._init_options import load_init_options
from ..presets import PresetManager

agent = load_init_options(project_root).get("ai")
if agent:
PresetManager(project_root).register_enabled_presets_for_agent(agent)
Comment on lines +119 to +121
except Exception as exc:
from .. import _print_cli_warning

_print_cli_warning(
"reconcile preset artifacts after extension change",
"project",
str(project_root),
exc,
continuing="Continuing; run 'specify integration use' to retry registration.",
)


def _refresh_events_and_warn(project_root: Path) -> None:
"""Refresh native event config and surface failures (R3).

Expand Down Expand Up @@ -146,9 +180,7 @@ def install_extension_from_url(
from . import ExtensionCatalog, ExtensionError

if not is_https_or_localhost_http(url):
raise ExtensionError(
"URL must use HTTPS (HTTP is only allowed for localhost)"
)
raise ExtensionError("URL must use HTTPS (HTTP is only allowed for localhost)")

download_dir = _validate_safe_cache_dir(project_root)
archive_filename = f"extension-url-download-{uuid4().hex}.archive"
Expand Down Expand Up @@ -209,9 +241,7 @@ def install_extension_from_url(
) from exc

format_source = (
final_url
if archive_format_from_name(final_url) is not None
else url
final_url if archive_format_from_name(final_url) is not None else url
)
try:
detect_archive_format(
Expand Down Expand Up @@ -282,7 +312,9 @@ def _resolve_installed_extension(
return (ext["id"], ext["name"])

# If not found by ID, try display name match
name_matches = [ext for ext in installed_extensions if ext["name"].lower() == argument.lower()]
name_matches = [
ext for ext in installed_extensions if ext["name"].lower() == argument.lower()
]

if len(name_matches) == 1:
# Unique display-name match
Expand Down Expand Up @@ -311,7 +343,9 @@ def _resolve_installed_extension(
# No match by ID or display name
if allow_not_found:
return (None, None)
console.print(f"[red]Error:[/red] Extension '{_escape_markup(argument)}' is not installed")
console.print(
f"[red]Error:[/red] Extension '{_escape_markup(argument)}' is not installed"
)
raise typer.Exit(1)


Expand Down Expand Up @@ -375,7 +409,9 @@ def _resolve_catalog_extension(
)
console.print(table)
console.print("\nPlease rerun using the extension ID:")
console.print(f" [bold]specify extension {command_name} <extension-id>[/bold]")
console.print(
f" [bold]specify extension {command_name} <extension-id>[/bold]"
)
raise typer.Exit(1)

# Not found
Expand Down Expand Up @@ -432,9 +468,7 @@ def _verify_leaf_identity(fd: int, path: Path) -> None:
or path_stat.st_dev != open_stat.st_dev
or path_stat.st_ino != open_stat.st_ino
):
raise OSError(
errno.ENOTDIR, "Download file changed between creation and open"
)
raise OSError(errno.ENOTDIR, "Download file changed between creation and open")


def _validate_safe_cache_dir(project_root: Path) -> Path:
Expand Down Expand Up @@ -568,9 +602,7 @@ def _safe_open_download_zip(
classes on every supported platform.
"""
if _has_secure_dir_fd():
return _open_download_zip_via_dir_fd(
project_root, download_dir, zip_filename
)
return _open_download_zip_via_dir_fd(project_root, download_dir, zip_filename)
return _open_download_zip_via_paths(project_root, download_dir, zip_filename)


Expand Down Expand Up @@ -623,15 +655,15 @@ def _open_download_zip_via_paths(
project_root_resolved = project_root.resolve()

if download_dir.is_symlink() or not download_dir.is_dir():
raise OSError(
errno.ENOTDIR, "Download cache directory is not a real directory"
)
raise OSError(errno.ENOTDIR, "Download cache directory is not a real directory")
try:
download_dir.resolve().relative_to(project_root_resolved)
except (OSError, ValueError):
raise OSError(errno.ENOTDIR, "Download cache directory escapes project root")
if zip_path.is_symlink():
raise OSError(errno.ELOOP, "Refusing to write through a symlinked download file")
raise OSError(
errno.ELOOP, "Refusing to write through a symlinked download file"
)

flags = os.O_RDWR | os.O_CREAT | os.O_EXCL
flags |= getattr(os, "O_NOFOLLOW", 0)
Expand Down
Loading
Loading