Skip to content

Commit d063842

Browse files
committed
refactor: isolate integration runtime helpers
1 parent 3b8ed1f commit d063842

3 files changed

Lines changed: 312 additions & 216 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 55 additions & 216 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,26 @@
5454
from rich.tree import Tree
5555
from typer.core import TyperGroup
5656

57+
from .integration_runtime import (
58+
invoke_separator_for_integration as _invoke_separator_for_integration,
59+
resolve_integration_options as _resolve_integration_options_impl,
60+
with_integration_setting as _with_integration_setting,
61+
)
62+
from .integration_state import (
63+
INTEGRATION_JSON,
64+
dedupe_integration_keys as _dedupe_integration_keys,
65+
default_integration_key as _default_integration_key,
66+
installed_integration_keys as _installed_integration_keys,
67+
integration_setting as _integration_setting,
68+
integration_settings as _integration_settings,
69+
normalize_integration_state as _normalize_integration_state,
70+
write_integration_json as _write_integration_json_file,
71+
)
72+
from .shared_infra import (
73+
install_shared_infra as _install_shared_infra_impl,
74+
refresh_shared_templates as _refresh_shared_templates_impl,
75+
)
76+
5777
# For cross-platform keyboard input
5878
import readchar
5979

@@ -643,6 +663,11 @@ def _locate_core_pack() -> Path | None:
643663
return None
644664

645665

666+
def _repo_root() -> Path:
667+
"""Return the source checkout root used for editable installs."""
668+
return Path(__file__).parent.parent.parent
669+
670+
646671
def _locate_bundled_extension(extension_id: str) -> Path | None:
647672
"""Return the path to a bundled extension, or None.
648673
@@ -660,8 +685,7 @@ def _locate_bundled_extension(extension_id: str) -> Path | None:
660685
return candidate
661686

662687
# Source-checkout / editable install: look relative to repo root
663-
repo_root = Path(__file__).parent.parent.parent
664-
candidate = repo_root / "extensions" / extension_id
688+
candidate = _repo_root() / "extensions" / extension_id
665689
if (candidate / "extension.yml").is_file():
666690
return candidate
667691

@@ -685,8 +709,7 @@ def _locate_bundled_workflow(workflow_id: str) -> Path | None:
685709
return candidate
686710

687711
# Source-checkout / editable install: look relative to repo root
688-
repo_root = Path(__file__).parent.parent.parent
689-
candidate = repo_root / "workflows" / workflow_id
712+
candidate = _repo_root() / "workflows" / workflow_id
690713
if (candidate / "workflow.yml").is_file():
691714
return candidate
692715

@@ -710,82 +733,29 @@ def _locate_bundled_preset(preset_id: str) -> Path | None:
710733
return candidate
711734

712735
# Source-checkout / editable install: look relative to repo root
713-
repo_root = Path(__file__).parent.parent.parent
714-
candidate = repo_root / "presets" / preset_id
736+
candidate = _repo_root() / "presets" / preset_id
715737
if (candidate / "preset.yml").is_file():
716738
return candidate
717739

718740
return None
719741

720742

721-
def _load_speckit_manifest(project_path: Path) -> Any:
722-
"""Load the shared infrastructure manifest, preserving existing entries."""
723-
from .integrations.manifest import IntegrationManifest
724-
725-
manifest_path = project_path / ".specify" / "integrations" / "speckit.manifest.json"
726-
if manifest_path.exists():
727-
try:
728-
manifest = IntegrationManifest.load("speckit", project_path)
729-
manifest.version = get_speckit_version()
730-
return manifest
731-
except (ValueError, FileNotFoundError):
732-
pass
733-
return IntegrationManifest("speckit", project_path, version=get_speckit_version())
734-
735-
736-
def _shared_templates_source() -> Path:
737-
"""Return the bundled/source shared templates directory."""
738-
core = _locate_core_pack()
739-
if core and (core / "templates").is_dir():
740-
return core / "templates"
741-
repo_root = Path(__file__).parent.parent.parent
742-
return repo_root / "templates"
743-
744-
745743
def _refresh_shared_templates(
746744
project_path: Path,
747745
*,
748746
invoke_separator: str,
749747
force: bool = False,
750748
) -> None:
751749
"""Refresh default-sensitive shared templates without touching scripts."""
752-
from .integrations.base import IntegrationBase
753-
754-
templates_src = _shared_templates_source()
755-
if not templates_src.is_dir():
756-
return
757-
758-
manifest = _load_speckit_manifest(project_path)
759-
tracked_files = manifest.files
760-
modified = set(manifest.check_modified())
761-
skipped_files: list[str] = []
762-
763-
dest_templates = project_path / ".specify" / "templates"
764-
dest_templates.mkdir(parents=True, exist_ok=True)
765-
for src in templates_src.iterdir():
766-
if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."):
767-
continue
768-
769-
dst = dest_templates / src.name
770-
rel = dst.relative_to(project_path).as_posix()
771-
if dst.exists() and not force:
772-
if rel not in tracked_files or rel in modified:
773-
skipped_files.append(rel)
774-
continue
775-
776-
content = src.read_text(encoding="utf-8")
777-
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
778-
dst.write_text(content, encoding="utf-8")
779-
manifest.record_existing(rel)
780-
781-
manifest.save()
782-
783-
if skipped_files:
784-
console.print(
785-
f"[yellow]⚠[/yellow] {len(skipped_files)} modified or untracked shared template file(s) were not updated:"
786-
)
787-
for rel in skipped_files:
788-
console.print(f" {rel}")
750+
_refresh_shared_templates_impl(
751+
project_path,
752+
version=get_speckit_version(),
753+
core_pack=_locate_core_pack(),
754+
repo_root=_repo_root(),
755+
console=console,
756+
invoke_separator=invoke_separator,
757+
force=force,
758+
)
789759

790760

791761
def _install_shared_infra(
@@ -811,74 +781,16 @@ def _install_shared_infra(
811781
812782
Returns ``True`` on success.
813783
"""
814-
from .integrations.base import IntegrationBase
815-
816-
core = _locate_core_pack()
817-
manifest = _load_speckit_manifest(project_path)
818-
819-
# Scripts
820-
if core and (core / "scripts").is_dir():
821-
scripts_src = core / "scripts"
822-
else:
823-
repo_root = Path(__file__).parent.parent.parent
824-
scripts_src = repo_root / "scripts"
825-
826-
skipped_files: list[str] = []
827-
828-
if scripts_src.is_dir():
829-
dest_scripts = project_path / ".specify" / "scripts"
830-
dest_scripts.mkdir(parents=True, exist_ok=True)
831-
variant_dir = "bash" if script_type == "sh" else "powershell"
832-
variant_src = scripts_src / variant_dir
833-
if variant_src.is_dir():
834-
dest_variant = dest_scripts / variant_dir
835-
dest_variant.mkdir(parents=True, exist_ok=True)
836-
for src_path in variant_src.rglob("*"):
837-
if src_path.is_file():
838-
rel_path = src_path.relative_to(variant_src)
839-
dst_path = dest_variant / rel_path
840-
if dst_path.exists() and not force:
841-
skipped_files.append(str(dst_path.relative_to(project_path)))
842-
else:
843-
dst_path.parent.mkdir(parents=True, exist_ok=True)
844-
shutil.copy2(src_path, dst_path)
845-
rel = dst_path.relative_to(project_path).as_posix()
846-
manifest.record_existing(rel)
847-
848-
# Page templates (not command templates, not vscode-settings.json)
849-
templates_src = _shared_templates_source()
850-
851-
if templates_src.is_dir():
852-
dest_templates = project_path / ".specify" / "templates"
853-
dest_templates.mkdir(parents=True, exist_ok=True)
854-
for f in templates_src.iterdir():
855-
if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."):
856-
dst = dest_templates / f.name
857-
if dst.exists() and not force:
858-
skipped_files.append(str(dst.relative_to(project_path)))
859-
else:
860-
content = f.read_text(encoding="utf-8")
861-
content = IntegrationBase.resolve_command_refs(
862-
content, invoke_separator
863-
)
864-
dst.write_text(content, encoding="utf-8")
865-
rel = dst.relative_to(project_path).as_posix()
866-
manifest.record_existing(rel)
867-
868-
if skipped_files:
869-
console.print(
870-
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:"
871-
)
872-
for f in skipped_files:
873-
console.print(f" {f}")
874-
console.print(
875-
"To refresh shared infrastructure, run "
876-
"[cyan]specify init --here --force[/cyan] or "
877-
"[cyan]specify integration upgrade --force[/cyan]."
878-
)
879-
880-
manifest.save()
881-
return True
784+
return _install_shared_infra_impl(
785+
project_path,
786+
script_type,
787+
version=get_speckit_version(),
788+
core_pack=_locate_core_pack(),
789+
repo_root=_repo_root(),
790+
console=console,
791+
force=force,
792+
invoke_separator=invoke_separator,
793+
)
882794

883795

884796
def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None:
@@ -1937,7 +1849,7 @@ def get_speckit_version() -> str:
19371849
# Fallback: try reading from pyproject.toml
19381850
try:
19391851
import tomllib
1940-
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
1852+
pyproject_path = _repo_root() / "pyproject.toml"
19411853
if pyproject_path.exists():
19421854
with open(pyproject_path, "rb") as f:
19431855
data = tomllib.load(f)
@@ -1959,18 +1871,6 @@ def get_speckit_version() -> str:
19591871
app.add_typer(integration_app, name="integration")
19601872

19611873

1962-
from .integration_state import ( # noqa: E402
1963-
INTEGRATION_JSON,
1964-
dedupe_integration_keys as _dedupe_integration_keys,
1965-
default_integration_key as _default_integration_key,
1966-
installed_integration_keys as _installed_integration_keys,
1967-
integration_setting as _integration_setting,
1968-
integration_settings as _integration_settings,
1969-
normalize_integration_state as _normalize_integration_state,
1970-
write_integration_json as _write_integration_json_file,
1971-
)
1972-
1973-
19741874
def _read_integration_json(project_root: Path) -> dict[str, Any]:
19751875
"""Load ``.specify/integration.json``. Returns normalized state when present."""
19761876
path = project_root / INTEGRATION_JSON
@@ -2076,74 +1976,13 @@ def _resolve_integration_options(
20761976
raw_options: str | None,
20771977
) -> tuple[str | None, dict[str, Any] | None]:
20781978
"""Resolve raw and parsed options for an integration operation."""
2079-
if raw_options is not None:
2080-
return raw_options, _parse_integration_options(integration, raw_options)
2081-
2082-
setting = _integration_setting(state, key)
2083-
stored_raw = setting.get("raw_options")
2084-
if not isinstance(stored_raw, str):
2085-
stored_raw = None
2086-
2087-
stored_parsed = setting.get("parsed_options")
2088-
if isinstance(stored_parsed, dict):
2089-
return stored_raw, stored_parsed or None
2090-
2091-
if stored_raw:
2092-
return stored_raw, _parse_integration_options(integration, stored_raw)
2093-
2094-
return None, None
2095-
2096-
2097-
def _with_integration_setting(
2098-
state: dict[str, Any],
2099-
key: str,
2100-
integration: Any,
2101-
*,
2102-
script_type: str | None = None,
2103-
raw_options: str | None = None,
2104-
parsed_options: dict[str, Any] | None = None,
2105-
) -> dict[str, dict[str, Any]]:
2106-
"""Return integration settings with *key* updated."""
2107-
settings = _integration_settings(state)
2108-
current = dict(settings.get(key, {}))
2109-
2110-
if script_type:
2111-
current["script"] = script_type
2112-
if raw_options is not None:
2113-
current["raw_options"] = raw_options
2114-
elif "raw_options" in current and not current.get("raw_options"):
2115-
current.pop("raw_options", None)
2116-
2117-
if parsed_options is not None:
2118-
current["parsed_options"] = parsed_options
2119-
elif raw_options is not None:
2120-
current.pop("parsed_options", None)
2121-
2122-
current["invoke_separator"] = integration.effective_invoke_separator(parsed_options)
2123-
settings[key] = current
2124-
return settings
2125-
2126-
2127-
def _invoke_separator_for_integration(
2128-
integration: Any,
2129-
state: dict[str, Any],
2130-
key: str,
2131-
parsed_options: dict[str, Any] | None = None,
2132-
) -> str:
2133-
"""Resolve the invocation separator for stored/default integration state."""
2134-
if parsed_options is not None:
2135-
return integration.effective_invoke_separator(parsed_options)
2136-
2137-
setting = _integration_setting(state, key)
2138-
stored_separator = setting.get("invoke_separator")
2139-
if isinstance(stored_separator, str) and stored_separator:
2140-
return stored_separator
2141-
2142-
stored_parsed = setting.get("parsed_options")
2143-
if isinstance(stored_parsed, dict):
2144-
return integration.effective_invoke_separator(stored_parsed)
2145-
2146-
return integration.effective_invoke_separator(None)
1979+
return _resolve_integration_options_impl(
1980+
integration,
1981+
state,
1982+
key,
1983+
raw_options,
1984+
parse_options=_parse_integration_options,
1985+
)
21471986

21481987

21491988
def _set_default_integration(

0 commit comments

Comments
 (0)