Skip to content

Commit de2d9e6

Browse files
iamaeroplaneclaude
andcommitted
refactor(extensions): extract ExtensionResolver from PresetResolver
Move extension template resolution and discovery into a dedicated ExtensionResolver class in extensions.py. PresetResolver now delegates its tier-3 (extension) lookups to ExtensionResolver instead of walking extension directories directly. This gives extensions their own resolution/discovery API without coupling them to the preset system. PresetResolver remains the unified resolver across all 4 tiers but no longer owns extension-specific logic. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 62283b7 commit de2d9e6

3 files changed

Lines changed: 332 additions & 71 deletions

File tree

src/specify_cli/extensions.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,178 @@ def get_extension(self, extension_id: str) -> Optional[ExtensionManifest]:
847847
return None
848848

849849

850+
class ExtensionResolver:
851+
"""Resolves and discovers templates provided by installed extensions.
852+
853+
Handles priority-based ordering of extensions, template resolution,
854+
and source attribution for extension-provided templates.
855+
856+
This class owns the extension tier of the template resolution stack.
857+
PresetResolver delegates to it for extension lookups rather than
858+
walking extension directories directly.
859+
"""
860+
861+
def __init__(self, project_root: Path):
862+
self.project_root = project_root
863+
self.extensions_dir = project_root / ".specify" / "extensions"
864+
865+
def get_all_by_priority(self) -> List[tuple]:
866+
"""Build unified list of registered and unregistered extensions sorted by priority.
867+
868+
Registered extensions use their stored priority; unregistered directories
869+
get implicit priority=10. Results are sorted by (priority, ext_id) for
870+
deterministic ordering.
871+
872+
Returns:
873+
List of (priority, ext_id, metadata_or_none) tuples sorted by priority.
874+
"""
875+
if not self.extensions_dir.exists():
876+
return []
877+
878+
registry = ExtensionRegistry(self.extensions_dir)
879+
registered_extension_ids = registry.keys()
880+
all_registered = registry.list_by_priority(include_disabled=True)
881+
882+
all_extensions: list[tuple[int, str, dict | None]] = []
883+
884+
for ext_id, metadata in all_registered:
885+
if not metadata.get("enabled", True):
886+
continue
887+
priority = normalize_priority(metadata.get("priority") if metadata else None)
888+
all_extensions.append((priority, ext_id, metadata))
889+
890+
for ext_dir in self.extensions_dir.iterdir():
891+
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
892+
continue
893+
if ext_dir.name not in registered_extension_ids:
894+
all_extensions.append((10, ext_dir.name, None))
895+
896+
all_extensions.sort(key=lambda x: (x[0], x[1]))
897+
return all_extensions
898+
899+
def resolve(
900+
self,
901+
template_name: str,
902+
template_type: str = "template",
903+
) -> Optional[Path]:
904+
"""Resolve a template name to its file path within extensions.
905+
906+
Args:
907+
template_name: Template name (e.g., "spec-template")
908+
template_type: Template type ("template", "command", or "script")
909+
910+
Returns:
911+
Path to the resolved template file, or None if not found
912+
"""
913+
subdirs, ext = self._type_config(template_type)
914+
915+
for _priority, ext_id, _metadata in self.get_all_by_priority():
916+
ext_dir = self.extensions_dir / ext_id
917+
if not ext_dir.is_dir():
918+
continue
919+
for subdir in subdirs:
920+
if subdir:
921+
candidate = ext_dir / subdir / f"{template_name}{ext}"
922+
else:
923+
candidate = ext_dir / f"{template_name}{ext}"
924+
if candidate.exists():
925+
return candidate
926+
927+
return None
928+
929+
def resolve_with_source(
930+
self,
931+
template_name: str,
932+
template_type: str = "template",
933+
) -> Optional[Dict[str, str]]:
934+
"""Resolve a template name and return source attribution.
935+
936+
Args:
937+
template_name: Template name (e.g., "spec-template")
938+
template_type: Template type ("template", "command", or "script")
939+
940+
Returns:
941+
Dictionary with 'path' and 'source' keys, or None if not found
942+
"""
943+
subdirs, ext = self._type_config(template_type)
944+
945+
for _priority, ext_id, ext_meta in self.get_all_by_priority():
946+
ext_dir = self.extensions_dir / ext_id
947+
if not ext_dir.is_dir():
948+
continue
949+
for subdir in subdirs:
950+
if subdir:
951+
candidate = ext_dir / subdir / f"{template_name}{ext}"
952+
else:
953+
candidate = ext_dir / f"{template_name}{ext}"
954+
if candidate.exists():
955+
if ext_meta:
956+
version = ext_meta.get("version", "?")
957+
source = f"extension:{ext_id} v{version}"
958+
else:
959+
source = f"extension:{ext_id} (unregistered)"
960+
return {"path": str(candidate), "source": source}
961+
962+
return None
963+
964+
def list_templates(
965+
self,
966+
template_type: str = "template",
967+
) -> List[Dict[str, str]]:
968+
"""List all templates of a given type provided by extensions.
969+
970+
Returns templates sorted by extension priority, then alphabetically.
971+
972+
Args:
973+
template_type: Template type ("template", "command", or "script")
974+
975+
Returns:
976+
List of dicts with 'name', 'path', and 'source' keys.
977+
"""
978+
subdirs, ext = self._type_config(template_type)
979+
results: List[Dict[str, str]] = []
980+
seen: set[str] = set()
981+
982+
for _priority, ext_id, ext_meta in self.get_all_by_priority():
983+
ext_dir = self.extensions_dir / ext_id
984+
if not ext_dir.is_dir():
985+
continue
986+
987+
if ext_meta:
988+
version = ext_meta.get("version", "?")
989+
source_label = f"extension:{ext_id} v{version}"
990+
else:
991+
source_label = f"extension:{ext_id} (unregistered)"
992+
993+
for subdir in subdirs:
994+
scan_dir = ext_dir / subdir if subdir else ext_dir
995+
if not scan_dir.is_dir():
996+
continue
997+
for f in sorted(scan_dir.iterdir()):
998+
if f.is_file() and f.suffix == ext:
999+
name = f.stem
1000+
if name not in seen:
1001+
seen.add(name)
1002+
results.append({
1003+
"name": name,
1004+
"path": str(f),
1005+
"source": source_label,
1006+
})
1007+
1008+
return results
1009+
1010+
@staticmethod
1011+
def _type_config(template_type: str) -> tuple:
1012+
"""Return (subdirs, file_extension) for a template type."""
1013+
if template_type == "template":
1014+
return ["templates", ""], ".md"
1015+
elif template_type == "command":
1016+
return ["commands"], ".md"
1017+
elif template_type == "script":
1018+
return ["scripts"], ".sh"
1019+
return [""], ".md"
1020+
1021+
8501022
def version_satisfies(current: str, required: str) -> bool:
8511023
"""Check if current version satisfies required version specifier.
8521024

src/specify_cli/presets.py

Lines changed: 12 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from packaging import version as pkg_version
2525
from packaging.specifiers import SpecifierSet, InvalidSpecifier
2626

27-
from .extensions import ExtensionRegistry, normalize_priority
27+
from .extensions import ExtensionRegistry, ExtensionResolver, normalize_priority
2828

2929

3030
@dataclass
@@ -1529,48 +1529,7 @@ def __init__(self, project_root: Path):
15291529
self.presets_dir = project_root / ".specify" / "presets"
15301530
self.overrides_dir = self.templates_dir / "overrides"
15311531
self.extensions_dir = project_root / ".specify" / "extensions"
1532-
1533-
def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:
1534-
"""Build unified list of registered and unregistered extensions sorted by priority.
1535-
1536-
Registered extensions use their stored priority; unregistered directories
1537-
get implicit priority=10. Results are sorted by (priority, ext_id) for
1538-
deterministic ordering.
1539-
1540-
Returns:
1541-
List of (priority, ext_id, metadata_or_none) tuples sorted by priority.
1542-
"""
1543-
if not self.extensions_dir.exists():
1544-
return []
1545-
1546-
registry = ExtensionRegistry(self.extensions_dir)
1547-
# Use keys() to track ALL extensions (including corrupted entries) without deep copy
1548-
# This prevents corrupted entries from being picked up as "unregistered" dirs
1549-
registered_extension_ids = registry.keys()
1550-
1551-
# Get all registered extensions including disabled; we filter disabled manually below
1552-
all_registered = registry.list_by_priority(include_disabled=True)
1553-
1554-
all_extensions: list[tuple[int, str, dict | None]] = []
1555-
1556-
# Only include enabled extensions in the result
1557-
for ext_id, metadata in all_registered:
1558-
# Skip disabled extensions
1559-
if not metadata.get("enabled", True):
1560-
continue
1561-
priority = normalize_priority(metadata.get("priority") if metadata else None)
1562-
all_extensions.append((priority, ext_id, metadata))
1563-
1564-
# Add unregistered directories with implicit priority=10
1565-
for ext_dir in self.extensions_dir.iterdir():
1566-
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
1567-
continue
1568-
if ext_dir.name not in registered_extension_ids:
1569-
all_extensions.append((10, ext_dir.name, None))
1570-
1571-
# Sort by (priority, ext_id) for deterministic ordering
1572-
all_extensions.sort(key=lambda x: (x[0], x[1]))
1573-
return all_extensions
1532+
self._ext_resolver = ExtensionResolver(project_root)
15741533

15751534
def resolve(
15761535
self,
@@ -1624,18 +1583,10 @@ def resolve(
16241583
if candidate.exists():
16251584
return candidate
16261585

1627-
# Priority 3: Extension-provided templates (sorted by priority — lower number wins)
1628-
for _priority, ext_id, _metadata in self._get_all_extensions_by_priority():
1629-
ext_dir = self.extensions_dir / ext_id
1630-
if not ext_dir.is_dir():
1631-
continue
1632-
for subdir in subdirs:
1633-
if subdir:
1634-
candidate = ext_dir / subdir / f"{template_name}{ext}"
1635-
else:
1636-
candidate = ext_dir / f"{template_name}{ext}"
1637-
if candidate.exists():
1638-
return candidate
1586+
# Priority 3: Extension-provided templates (delegated to ExtensionResolver)
1587+
ext_result = self._ext_resolver.resolve(template_name, template_type)
1588+
if ext_result is not None:
1589+
return ext_result
16391590

16401591
# Priority 4: Core templates
16411592
if template_type == "template":
@@ -1693,7 +1644,7 @@ def resolve_with_source(
16931644
except ValueError:
16941645
continue
16951646

1696-
for _priority, ext_id, ext_meta in self._get_all_extensions_by_priority():
1647+
for _priority, ext_id, ext_meta in self._ext_resolver.get_all_by_priority():
16971648
ext_dir = self.extensions_dir / ext_id
16981649
if not ext_dir.is_dir():
16991650
continue
@@ -1779,21 +1730,11 @@ def _collect(directory: Path, source: str):
17791730
else:
17801731
_collect(pack_dir, source_label)
17811732

1782-
# Priority 3: Extension-provided templates (sorted by priority)
1783-
for _priority, ext_id, ext_meta in self._get_all_extensions_by_priority():
1784-
ext_dir = self.extensions_dir / ext_id
1785-
if not ext_dir.is_dir():
1786-
continue
1787-
if ext_meta:
1788-
version = ext_meta.get("version", "?")
1789-
source_label = f"extension:{ext_id} v{version}"
1790-
else:
1791-
source_label = f"extension:{ext_id} (unregistered)"
1792-
for subdir in subdirs:
1793-
if subdir:
1794-
_collect(ext_dir / subdir, source_label)
1795-
else:
1796-
_collect(ext_dir, source_label)
1733+
# Priority 3: Extension-provided templates (delegated to ExtensionResolver)
1734+
for entry in self._ext_resolver.list_templates(template_type):
1735+
if entry["name"] not in seen:
1736+
seen.add(entry["name"])
1737+
results.append(entry)
17971738

17981739
# Priority 4: Core templates
17991740
if template_type == "template":

0 commit comments

Comments
 (0)