diff --git a/src/specify_cli/bundler/commands_impl/catalog_config.py b/src/specify_cli/bundler/commands_impl/catalog_config.py index dadc7e9672..caa6329622 100644 --- a/src/specify_cli/bundler/commands_impl/catalog_config.py +++ b/src/specify_cli/bundler/commands_impl/catalog_config.py @@ -40,6 +40,10 @@ def _read(project_root: Path) -> list[dict]: path = ensure_within(project_root, _config_path(project_root)) if not path.exists(): return [] + # ``load_yaml`` returns ``{}`` only for an empty document and the raw parse + # otherwise, so a falsy non-mapping (``[]``/``false``/``0``/``''``) is caught + # by the isinstance guard below and raised like a truthy one, staying + # consistent with the other reader of this file (models/catalog._merge_config). data = load_yaml(path) if data is None: return [] diff --git a/src/specify_cli/bundler/lib/yamlio.py b/src/specify_cli/bundler/lib/yamlio.py index fc90580275..74aad59fcc 100644 --- a/src/specify_cli/bundler/lib/yamlio.py +++ b/src/specify_cli/bundler/lib/yamlio.py @@ -39,17 +39,25 @@ def ensure_within(root: Path, candidate: Path) -> Path: def load_yaml(path: Path) -> Any: - """Parse a YAML file, returning ``{}`` for an empty document.""" + """Parse a YAML file, returning ``{}`` for an empty document. + + Only an *empty* document (``yaml.safe_load`` -> ``None``) becomes ``{}``. + A non-empty document is returned exactly as parsed — including a falsy + non-mapping such as ``[]``, ``false``, ``0``, or ``''`` — so callers can + validate the top-level shape (e.g. reject a non-mapping config) instead of + having it silently coerced to an empty mapping. + """ path = Path(path) if not path.exists(): raise BundlerError(f"File not found: {path}") try: with path.open("r", encoding="utf-8") as handle: - return yaml.safe_load(handle) or {} + data = yaml.safe_load(handle) except yaml.YAMLError as exc: raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc except OSError as exc: raise BundlerError(f"Could not read {path}: {exc}") from exc + return {} if data is None else data def dump_yaml(path: Path, data: Any, *, within: Path | None = None) -> Path: diff --git a/src/specify_cli/bundler/models/catalog.py b/src/specify_cli/bundler/models/catalog.py index 293d31c69b..b56eba7309 100644 --- a/src/specify_cli/bundler/models/catalog.py +++ b/src/specify_cli/bundler/models/catalog.py @@ -249,8 +249,18 @@ def load_source_stack(project_root: Path, user_config_dir: Path | None = None) - def _merge_config(by_id: dict[str, CatalogSource], config_path: Path, scope: Scope) -> None: if not config_path.exists(): return + # ``load_yaml`` returns ``{}`` only for an empty document and the raw parse + # otherwise, so a non-mapping top level (a YAML list or scalar, including + # the falsy ``[]``/``false``/``0``/``''``) is caught here and raised — + # matching the sibling reader commands_impl/catalog_config._read. #3623 + # aligned the inner non-list ``catalogs`` value between the two readers. data = load_yaml(config_path) - catalogs = data.get("catalogs") if isinstance(data, dict) else None + if not isinstance(data, dict): + raise BundlerError( + f"Malformed catalog config at {config_path}: expected a mapping at " + f"the top level, got {type(data).__name__}." + ) + catalogs = data.get("catalogs") if catalogs is None: return if not isinstance(catalogs, list): diff --git a/tests/contract/test_catalog_schema.py b/tests/contract/test_catalog_schema.py index bfa289d497..1ff63dc56b 100644 --- a/tests/contract/test_catalog_schema.py +++ b/tests/contract/test_catalog_schema.py @@ -68,6 +68,29 @@ def test_falsy_non_list_catalogs_still_raises(tmp_path: Path, value: str): load_source_stack(tmp_path) +@pytest.mark.parametrize( + "body", + [ + "- a\n- b\n", # truthy list + "42\n", # truthy scalar + "[]\n", # falsy list + "false\n", # falsy bool + "0\n", # falsy int + "''\n", # falsy empty string + ], +) +def test_toplevel_non_mapping_raises(tmp_path: Path, body: str): + """A top-level non-mapping bundle-catalogs.yml (list/scalar) must raise, + matching the sibling reader (catalog_config._read) — not silently fall back + to the built-in default stack. This includes FALSY non-mappings ([], false, + 0, ''); the shared load_yaml would coerce those to {} and hide them, so both + readers parse the raw document instead.""" + make_project(tmp_path) + (tmp_path / ".specify" / "bundle-catalogs.yml").write_text(body, encoding="utf-8") + with pytest.raises(BundlerError, match="expected a mapping at the top level"): + 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 diff --git a/tests/unit/test_bundler_catalog_config.py b/tests/unit/test_bundler_catalog_config.py index 5ad6ecbb93..337d5b9242 100644 --- a/tests/unit/test_bundler_catalog_config.py +++ b/tests/unit/test_bundler_catalog_config.py @@ -154,6 +154,19 @@ def test_read_rejects_non_mapping_top_level(tmp_path: Path): cc._read(project) +@pytest.mark.parametrize("body", ["[]\n", "false\n", "0\n", "''\n"]) +def test_read_rejects_falsy_non_mapping_top_level(tmp_path: Path, body: str): + # A FALSY non-mapping top level ([], false, 0, '') must raise like a truthy + # one. The shared load_yaml would coerce these to {} and hide them, so _read + # parses the raw document — staying consistent with models/catalog._merge_config. + project = tmp_path / "proj" + (project / ".specify").mkdir(parents=True) + cc._config_path(project).write_text(body, encoding="utf-8") + + with pytest.raises(BundlerError, match="expected a mapping at the top level"): + cc._read(project) + + def test_read_rejects_unknown_schema_version(tmp_path: Path): project = tmp_path / "proj" (project / ".specify").mkdir(parents=True)