Skip to content

Commit 2ad6627

Browse files
authored
Fix WorkflowCatalog YAML parsing error handling and isinstance checks
1 parent 9b0e472 commit 2ad6627

2 files changed

Lines changed: 104 additions & 7 deletions

File tree

src/specify_cli/workflows/catalog.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,11 @@ def _load_catalog_config(
183183
raise WorkflowValidationError(
184184
f"Failed to read catalog config {config_path}: {exc}"
185185
)
186+
if not isinstance(data, dict):
187+
raise WorkflowValidationError(
188+
f"Invalid catalog config: expected a mapping, "
189+
f"got {type(data).__name__}"
190+
)
186191
catalogs_data = data.get("catalogs", [])
187192
if not catalogs_data:
188193
# Empty catalogs list (e.g. after removing last entry)
@@ -476,7 +481,12 @@ def add_catalog(self, url: str, name: str | None = None) -> None:
476481

477482
data: dict[str, Any] = {"catalogs": []}
478483
if config_path.exists():
479-
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
484+
try:
485+
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
486+
except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc:
487+
raise WorkflowValidationError(
488+
f"Catalog config file is unreadable or malformed: {exc}"
489+
) from exc
480490
if not isinstance(raw, dict):
481491
raise WorkflowValidationError(
482492
"Catalog config file is corrupted (expected a mapping)."
@@ -523,17 +533,27 @@ def _coerce_priority(value: Any) -> int:
523533
)
524534
data["catalogs"] = catalogs
525535

526-
config_path.parent.mkdir(parents=True, exist_ok=True)
527-
with open(config_path, "w", encoding="utf-8") as f:
528-
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
536+
try:
537+
config_path.parent.mkdir(parents=True, exist_ok=True)
538+
with open(config_path, "w", encoding="utf-8") as f:
539+
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
540+
except OSError as exc:
541+
raise WorkflowValidationError(
542+
f"Failed to write catalog config {config_path}: {exc}"
543+
) from exc
529544

530545
def remove_catalog(self, index: int) -> str:
531546
"""Remove a catalog source by index (0-based). Returns the removed name."""
532547
config_path = self.project_root / ".specify" / "workflow-catalogs.yml"
533548
if not config_path.exists():
534549
raise WorkflowValidationError("No catalog config file found.")
535550

536-
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
551+
try:
552+
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
553+
except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc:
554+
raise WorkflowValidationError(
555+
f"Catalog config file is unreadable or malformed: {exc}"
556+
) from exc
537557
if not isinstance(data, dict):
538558
raise WorkflowValidationError(
539559
"Catalog config file is corrupted (expected a mapping)."
@@ -552,8 +572,13 @@ def remove_catalog(self, index: int) -> str:
552572
removed = catalogs.pop(index)
553573
data["catalogs"] = catalogs
554574

555-
with open(config_path, "w", encoding="utf-8") as f:
556-
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
575+
try:
576+
with open(config_path, "w", encoding="utf-8") as f:
577+
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
578+
except OSError as exc:
579+
raise WorkflowValidationError(
580+
f"Failed to write catalog config {config_path}: {exc}"
581+
) from exc
557582

558583
if isinstance(removed, dict):
559584
return removed.get("name", f"catalog-{index + 1}")

tests/test_workflows.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1751,6 +1751,78 @@ def test_get_catalog_configs(self, project_dir):
17511751
assert configs[0]["name"] == "default"
17521752
assert isinstance(configs[0]["install_allowed"], bool)
17531753

1754+
def test_load_catalog_config_non_dict_yaml_raises(self, project_dir):
1755+
"""A YAML catalog config that is a list (not a mapping) must raise WorkflowValidationError."""
1756+
from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError
1757+
1758+
config_path = project_dir / ".specify" / "workflow-catalogs.yml"
1759+
config_path.write_text("- item1\n- item2\n", encoding="utf-8")
1760+
1761+
catalog = WorkflowCatalog(project_dir)
1762+
with pytest.raises(WorkflowValidationError, match="expected a mapping"):
1763+
catalog.get_active_catalogs()
1764+
1765+
def test_add_catalog_malformed_yaml_raises(self, project_dir):
1766+
"""A malformed YAML config file must raise WorkflowValidationError when adding a catalog."""
1767+
from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError
1768+
1769+
config_path = project_dir / ".specify" / "workflow-catalogs.yml"
1770+
config_path.write_text(": invalid: yaml: {\n", encoding="utf-8")
1771+
1772+
catalog = WorkflowCatalog(project_dir)
1773+
with pytest.raises(WorkflowValidationError, match="unreadable or malformed"):
1774+
catalog.add_catalog("https://example.com/new.json")
1775+
1776+
def test_remove_catalog_malformed_yaml_raises(self, project_dir):
1777+
"""A malformed YAML config file must raise WorkflowValidationError when removing a catalog."""
1778+
from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError
1779+
1780+
catalog = WorkflowCatalog(project_dir)
1781+
catalog.add_catalog("https://example.com/c1.json", "first")
1782+
1783+
config_path = project_dir / ".specify" / "workflow-catalogs.yml"
1784+
config_path.write_text(": bad: yaml: {\n", encoding="utf-8")
1785+
1786+
with pytest.raises(WorkflowValidationError, match="unreadable or malformed"):
1787+
catalog.remove_catalog(0)
1788+
1789+
def test_add_catalog_wraps_write_oserror(self, project_dir, monkeypatch):
1790+
"""An OSError on write must be wrapped as WorkflowValidationError."""
1791+
from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError
1792+
import builtins
1793+
1794+
catalog = WorkflowCatalog(project_dir)
1795+
config_path = project_dir / ".specify" / "workflow-catalogs.yml"
1796+
real_open = builtins.open
1797+
1798+
def _raising_open(file, mode="r", *args, **kwargs):
1799+
if Path(file) == config_path and "w" in mode:
1800+
raise OSError("simulated write failure")
1801+
return real_open(file, mode, *args, **kwargs)
1802+
1803+
monkeypatch.setattr(builtins, "open", _raising_open)
1804+
with pytest.raises(WorkflowValidationError, match="Failed to write catalog config"):
1805+
catalog.add_catalog("https://example.com/new-catalog.json", "my-catalog")
1806+
1807+
def test_remove_catalog_wraps_write_oserror(self, project_dir, monkeypatch):
1808+
"""An OSError on write must be wrapped as WorkflowValidationError."""
1809+
from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError
1810+
import builtins
1811+
1812+
catalog = WorkflowCatalog(project_dir)
1813+
catalog.add_catalog("https://example.com/c1.json", "first")
1814+
config_path = project_dir / ".specify" / "workflow-catalogs.yml"
1815+
real_open = builtins.open
1816+
1817+
def _raising_open(file, mode="r", *args, **kwargs):
1818+
if Path(file) == config_path and "w" in mode:
1819+
raise OSError("simulated write failure")
1820+
return real_open(file, mode, *args, **kwargs)
1821+
1822+
monkeypatch.setattr(builtins, "open", _raising_open)
1823+
with pytest.raises(WorkflowValidationError, match="Failed to write catalog config"):
1824+
catalog.remove_catalog(0)
1825+
17541826

17551827
# ===== Integration Test =====
17561828

0 commit comments

Comments
 (0)