Skip to content

Commit 722edc7

Browse files
committed
refactor(catalogs): extract integration catalog config loading
1 parent abb5fe7 commit 722edc7

2 files changed

Lines changed: 188 additions & 135 deletions

File tree

src/specify_cli/catalogs.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""Shared catalog stack config primitives.
2+
3+
Catalog-backed features use the same local config shape and URL validation
4+
rules. This module keeps those narrow primitives in one place while individual
5+
catalog types keep their active source resolution, fetch, cache, and
6+
domain-specific validation behavior.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from dataclasses import dataclass
12+
from pathlib import Path
13+
from typing import ClassVar
14+
15+
import yaml
16+
17+
18+
@dataclass
19+
class CatalogEntry:
20+
"""Represents a single catalog source in a catalog stack."""
21+
22+
url: str
23+
name: str
24+
priority: int
25+
install_allowed: bool
26+
description: str = ""
27+
28+
29+
class CatalogStackBase:
30+
"""Base class for ordered catalog-source resolution.
31+
32+
Subclasses provide catalog-specific metadata and exception classes. Fetching
33+
and schema validation stay in each concrete catalog because those formats
34+
differ across integrations, extensions, presets, and workflows.
35+
"""
36+
37+
ENTRY_CLASS: ClassVar[type[CatalogEntry]] = CatalogEntry
38+
ERROR_TYPE: ClassVar[type[Exception]] = ValueError
39+
VALIDATION_ERROR_TYPE: ClassVar[type[Exception]] = ValueError
40+
41+
CONFIG_FILENAME: ClassVar[str]
42+
43+
@classmethod
44+
def _error(cls, message: str) -> Exception:
45+
return cls.ERROR_TYPE(message)
46+
47+
@classmethod
48+
def _validation_error(cls, message: str) -> Exception:
49+
return cls.VALIDATION_ERROR_TYPE(message)
50+
51+
@classmethod
52+
def _entry(
53+
cls,
54+
*,
55+
url: str,
56+
name: str,
57+
priority: int,
58+
install_allowed: bool,
59+
description: str = "",
60+
) -> CatalogEntry:
61+
return cls.ENTRY_CLASS(
62+
url=url,
63+
name=name,
64+
priority=priority,
65+
install_allowed=install_allowed,
66+
description=description,
67+
)
68+
69+
@classmethod
70+
def _validate_catalog_url(cls, url: str) -> None:
71+
"""Validate that a catalog URL uses HTTPS, except localhost HTTP."""
72+
from urllib.parse import urlparse
73+
74+
parsed = urlparse(url)
75+
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
76+
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
77+
raise cls._error(
78+
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
79+
"HTTP is only allowed for localhost."
80+
)
81+
if not parsed.netloc:
82+
raise cls._error("Catalog URL must be a valid URL with a host.")
83+
84+
def _load_catalog_config(self, config_path: Path) -> list[CatalogEntry] | None:
85+
"""Load catalog stack configuration from a YAML file.
86+
87+
Returns ``None`` when the file does not exist. Existing files fail
88+
closed when they are malformed, empty, or contain no usable URLs.
89+
"""
90+
if not config_path.exists():
91+
return None
92+
try:
93+
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
94+
except (yaml.YAMLError, OSError, UnicodeError) as exc:
95+
raise self._validation_error(
96+
f"Failed to read catalog config {config_path}: {exc}"
97+
) from exc
98+
if data is None:
99+
data = {}
100+
if not isinstance(data, dict):
101+
raise self._validation_error(
102+
f"Invalid catalog config {config_path}: expected a YAML mapping at the root"
103+
)
104+
105+
catalogs_data = data.get("catalogs", [])
106+
if not isinstance(catalogs_data, list):
107+
raise self._validation_error(
108+
f"Invalid catalog config {config_path}: 'catalogs' must be a list, "
109+
f"got {type(catalogs_data).__name__}"
110+
)
111+
if not catalogs_data:
112+
raise self._validation_error(
113+
f"Catalog config {config_path} exists but contains no 'catalogs' entries. "
114+
f"Remove the file to use built-in defaults, or add valid catalog entries."
115+
)
116+
117+
entries: list[CatalogEntry] = []
118+
skipped: list[int] = []
119+
for idx, item in enumerate(catalogs_data):
120+
if not isinstance(item, dict):
121+
raise self._validation_error(
122+
f"Invalid catalog config {config_path}: catalog entry at index {idx}: "
123+
f"expected a mapping, got {type(item).__name__}"
124+
)
125+
url = str(item.get("url", "")).strip()
126+
if not url:
127+
skipped.append(idx)
128+
continue
129+
try:
130+
self._validate_catalog_url(url)
131+
except self.ERROR_TYPE as exc:
132+
raise self._validation_error(
133+
f"Invalid catalog URL in {config_path} at index {idx}: {exc}"
134+
) from exc
135+
136+
raw_priority = item.get("priority", idx + 1)
137+
if isinstance(raw_priority, bool):
138+
raise self._validation_error(
139+
f"Invalid catalog config {config_path}: "
140+
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
141+
f"expected integer, got {raw_priority!r}"
142+
)
143+
try:
144+
priority = int(raw_priority)
145+
except (TypeError, ValueError):
146+
raise self._validation_error(
147+
f"Invalid catalog config {config_path}: "
148+
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
149+
f"expected integer, got {raw_priority!r}"
150+
)
151+
152+
raw_install = item.get("install_allowed", False)
153+
if isinstance(raw_install, str):
154+
install_allowed = raw_install.strip().lower() in ("true", "yes", "1")
155+
else:
156+
install_allowed = bool(raw_install)
157+
158+
raw_name = item.get("name")
159+
name = str(raw_name).strip() if raw_name is not None else ""
160+
if not name:
161+
name = f"catalog-{len(entries) + 1}"
162+
163+
entries.append(
164+
self._entry(
165+
url=url,
166+
name=name,
167+
priority=priority,
168+
install_allowed=install_allowed,
169+
description=str(item.get("description", "")),
170+
)
171+
)
172+
173+
entries.sort(key=lambda e: e.priority)
174+
if not entries:
175+
raise self._validation_error(
176+
f"Catalog config {config_path} contains {len(catalogs_data)} "
177+
f"entries but none have valid URLs (entries at indices {skipped} "
178+
f"were skipped). Each catalog entry must have a 'url' field."
179+
)
180+
return entries

src/specify_cli/integrations/catalog.py

Lines changed: 8 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import yaml
2222
from packaging import version as pkg_version
2323

24+
from ..catalogs import CatalogEntry, CatalogStackBase
25+
2426

2527
# ---------------------------------------------------------------------------
2628
# Errors
@@ -43,21 +45,15 @@ class IntegrationDescriptorError(Exception):
4345
# ---------------------------------------------------------------------------
4446

4547
@dataclass
46-
class IntegrationCatalogEntry:
48+
class IntegrationCatalogEntry(CatalogEntry):
4749
"""Represents a single catalog source in the catalog stack."""
4850

49-
url: str
50-
name: str
51-
priority: int
52-
install_allowed: bool
53-
description: str = ""
54-
5551

5652
# ---------------------------------------------------------------------------
5753
# IntegrationCatalog
5854
# ---------------------------------------------------------------------------
5955

60-
class IntegrationCatalog:
56+
class IntegrationCatalog(CatalogStackBase):
6157
"""Manages integration catalog fetching, caching, and searching."""
6258

6359
DEFAULT_CATALOG_URL = (
@@ -67,136 +63,15 @@ class IntegrationCatalog:
6763
"https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.community.json"
6864
)
6965
CACHE_DURATION = 3600 # 1 hour
66+
CONFIG_FILENAME = "integration-catalogs.yml"
67+
ENTRY_CLASS = IntegrationCatalogEntry
68+
ERROR_TYPE = IntegrationCatalogError
69+
VALIDATION_ERROR_TYPE = IntegrationValidationError
7070

7171
def __init__(self, project_root: Path) -> None:
7272
self.project_root = project_root
7373
self.cache_dir = project_root / ".specify" / "integrations" / ".cache"
7474

75-
# -- URL validation ---------------------------------------------------
76-
77-
@staticmethod
78-
def _validate_catalog_url(url: str) -> None:
79-
from urllib.parse import urlparse
80-
81-
parsed = urlparse(url)
82-
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
83-
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
84-
raise IntegrationCatalogError(
85-
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
86-
"HTTP is only allowed for localhost."
87-
)
88-
if not parsed.netloc:
89-
raise IntegrationCatalogError(
90-
"Catalog URL must be a valid URL with a host."
91-
)
92-
93-
# -- Catalog stack ----------------------------------------------------
94-
95-
def _load_catalog_config(
96-
self, config_path: Path
97-
) -> Optional[List[IntegrationCatalogEntry]]:
98-
"""Load catalog stack from a YAML file.
99-
100-
Returns None when the file does not exist.
101-
102-
Raises:
103-
IntegrationValidationError: on any local-config / YAML problem
104-
(parse failures, wrong shape, missing/invalid fields,
105-
invalid catalog URLs, etc.). This is a subclass of
106-
:class:`IntegrationCatalogError`, so any caller that already
107-
catches ``IntegrationCatalogError`` keeps working — but
108-
callers that want to distinguish *local config* problems
109-
from *remote/network* problems can match the subclass.
110-
"""
111-
if not config_path.exists():
112-
return None
113-
try:
114-
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
115-
except (yaml.YAMLError, OSError, UnicodeError) as exc:
116-
raise IntegrationValidationError(
117-
f"Failed to read catalog config {config_path}: {exc}"
118-
) from exc
119-
if data is None:
120-
data = {}
121-
if not isinstance(data, dict):
122-
raise IntegrationValidationError(
123-
f"Invalid catalog config {config_path}: expected a YAML mapping at the root"
124-
)
125-
catalogs_data = data.get("catalogs", [])
126-
if not isinstance(catalogs_data, list):
127-
raise IntegrationValidationError(
128-
f"Invalid catalog config {config_path}: 'catalogs' must be a list, "
129-
f"got {type(catalogs_data).__name__}"
130-
)
131-
if not catalogs_data:
132-
raise IntegrationValidationError(
133-
f"Catalog config {config_path} exists but contains no 'catalogs' entries. "
134-
f"Remove the file to use built-in defaults, or add valid catalog entries."
135-
)
136-
entries: List[IntegrationCatalogEntry] = []
137-
skipped: List[int] = []
138-
for idx, item in enumerate(catalogs_data):
139-
if not isinstance(item, dict):
140-
raise IntegrationValidationError(
141-
f"Invalid catalog config {config_path}: catalog entry at index {idx}: "
142-
f"expected a mapping, got {type(item).__name__}"
143-
)
144-
url = str(item.get("url", "")).strip()
145-
if not url:
146-
skipped.append(idx)
147-
continue
148-
try:
149-
self._validate_catalog_url(url)
150-
except IntegrationCatalogError as exc:
151-
# ``_validate_catalog_url`` raises the base class for direct
152-
# callers (e.g. ``add_catalog`` validating user input); when
153-
# the bad URL came from a local config file, surface it as a
154-
# validation error so CLI handlers can route it accordingly.
155-
raise IntegrationValidationError(
156-
f"Invalid catalog URL in {config_path} at index {idx}: {exc}"
157-
) from exc
158-
raw_priority = item.get("priority", idx + 1)
159-
if isinstance(raw_priority, bool):
160-
raise IntegrationValidationError(
161-
f"Invalid catalog config {config_path}: "
162-
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
163-
f"expected integer, got {raw_priority!r}"
164-
)
165-
try:
166-
priority = int(raw_priority)
167-
except (TypeError, ValueError):
168-
raise IntegrationValidationError(
169-
f"Invalid catalog config {config_path}: "
170-
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
171-
f"expected integer, got {raw_priority!r}"
172-
)
173-
raw_install = item.get("install_allowed", False)
174-
if isinstance(raw_install, str):
175-
install_allowed = raw_install.strip().lower() in ("true", "yes", "1")
176-
else:
177-
install_allowed = bool(raw_install)
178-
raw_name = item.get("name")
179-
name = str(raw_name).strip() if raw_name is not None else ""
180-
if not name:
181-
name = f"catalog-{len(entries) + 1}"
182-
entries.append(
183-
IntegrationCatalogEntry(
184-
url=url,
185-
name=name,
186-
priority=priority,
187-
install_allowed=install_allowed,
188-
description=str(item.get("description", "")),
189-
)
190-
)
191-
entries.sort(key=lambda e: e.priority)
192-
if not entries:
193-
raise IntegrationValidationError(
194-
f"Catalog config {config_path} contains {len(catalogs_data)} "
195-
f"entries but none have valid URLs (entries at indices {skipped} "
196-
f"were skipped). Each catalog entry must have a 'url' field."
197-
)
198-
return entries
199-
20075
def get_active_catalogs(self) -> List[IntegrationCatalogEntry]:
20176
"""Return the ordered list of active integration catalogs.
20277
@@ -444,8 +319,6 @@ def clear_cache(self) -> None:
444319

445320
# -- Catalog-source management ----------------------------------------
446321

447-
CONFIG_FILENAME = "integration-catalogs.yml"
448-
449322
def get_catalog_configs(self) -> List[Dict[str, Any]]:
450323
"""Return the active catalog stack as a list of dicts.
451324

0 commit comments

Comments
 (0)