Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/specify_cli/bundler/models/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,22 @@ def _merge_config(by_id: dict[str, CatalogSource], config_path: Path, scope: Sco
return
data = load_yaml(config_path)
catalogs = data.get("catalogs") if isinstance(data, dict) else None
if not catalogs:
if catalogs is None:
return
if not isinstance(catalogs, list):
# Treat only an absent/``None`` ``catalogs`` as "nothing to merge"; any
# other non-list value (``catalogs: 5``, ``false``, ``0``, ``''``,
# ``{}``) is a malformed config and must raise, not be silently skipped
# by a falsy check. Otherwise a truthy scalar would raise a raw
# ``TypeError: 'int' object is not iterable`` from the loop below, while
# falsy non-lists would be swallowed. Report the same actionable
# BundlerError the sibling reader of this file raises
# (commands_impl/catalog_config.py) so both readers of
# bundle-catalogs.yml agree. An empty list stays valid (loop is a no-op).
raise BundlerError(
f"Malformed catalog config at {config_path}: 'catalogs' must be a "
f"list, got {type(catalogs).__name__}."
)
for raw in catalogs:
src = CatalogSource.from_dict(raw, scope)
by_id[src.id] = src
36 changes: 36 additions & 0 deletions tests/contract/test_catalog_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,42 @@ def test_builtin_default_stack_when_no_config(tmp_path: Path):
assert all(s.scope is Scope.BUILTIN for s in sources)


def test_non_list_catalogs_raises_actionable_error(tmp_path: Path):
"""A scalar ``catalogs:`` value raises a clean BundlerError, not a raw
'int object is not iterable' TypeError — matching what the sibling reader
(bundle catalog list) already reports for the same file."""
make_project(tmp_path)
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(
"catalogs: 5\n", encoding="utf-8"
)
with pytest.raises(BundlerError, match="must be a list"):
load_source_stack(tmp_path)


@pytest.mark.parametrize("value", ["false", "0", "''", "{}"])
def test_falsy_non_list_catalogs_still_raises(tmp_path: Path, value: str):
"""A *falsy* non-list ``catalogs:`` value (false/0/''/{}) must also raise —
only an absent/``None`` value means "nothing to merge". A plain falsy check
would silently swallow these, diverging from the sibling reader."""
make_project(tmp_path)
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(
f"catalogs: {value}\n", encoding="utf-8"
)
with pytest.raises(BundlerError, match="must be a list"):
load_source_stack(tmp_path)


@pytest.mark.parametrize("body", ["catalogs:\n", "catalogs: []\n"])
def test_absent_or_empty_catalogs_is_noop(tmp_path: Path, body: str):
"""An absent (``None``) or empty-list ``catalogs:`` is valid: it contributes
no project sources and falls back to the built-in default stack."""
make_project(tmp_path)
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(body, encoding="utf-8")
# Does not raise; still yields the built-in defaults.
sources = load_source_stack(tmp_path)
assert len(sources) > 0


def test_project_config_overrides_same_id(tmp_path: Path):
make_project(tmp_path)
config = {
Expand Down