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
29 changes: 25 additions & 4 deletions src/specify_cli/integrations/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -674,16 +674,37 @@ def __init__(self, descriptor_path: Path) -> None:
@staticmethod
def _load(path: Path) -> dict:
try:
with open(path, "r", encoding="utf-8") as fh:
return yaml.safe_load(fh) or {}
except yaml.YAMLError as exc:
raise IntegrationDescriptorError(f"Invalid YAML in {path}: {exc}")
text = path.read_text(encoding="utf-8")
except FileNotFoundError:
raise IntegrationDescriptorError(f"Descriptor not found: {path}")
except (OSError, UnicodeError) as exc:
raise IntegrationDescriptorError(
f"Unable to read descriptor {path}: {exc}"
)
try:
# ``safe_load`` returns None for BOTH an empty document and an
# explicit null scalar (``null``, ``~``, ``Null``, ``NULL``), so it
# cannot tell them apart on its own. ``compose`` yields no node
# only for a genuinely empty document.
node = yaml.compose(text)
data = yaml.safe_load(text)
is_empty_document = node is None or (
data is None
and isinstance(node, yaml.nodes.ScalarNode)
and node.value == ""
and node.start_mark.index == node.end_mark.index
)
except yaml.YAMLError as exc:
raise IntegrationDescriptorError(f"Invalid YAML in {path}: {exc}")
# Only a genuinely EMPTY document becomes an empty mapping, so its
# missing-field errors are reported. Every non-mapping document --
# including an explicit ``null``/``~`` and the falsy shapes ``[]``,
# ``false``, ``0``, ``''`` that a plain ``or {}`` would mask -- must
# reach ``_validate`` unchanged so it reports the wrong descriptor
# shape, like the truthy twins (``- a``, ``hello``) already do.
if is_empty_document:
data = {}
return data

# -- Validation -------------------------------------------------------

Expand Down
30 changes: 30 additions & 0 deletions tests/integrations/test_integration_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,36 @@ def test_scripts_not_a_list(self, tmp_path):
with pytest.raises(IntegrationDescriptorError, match="expected a list"):
IntegrationDescriptor(p)

@pytest.mark.parametrize(
"content", ["[]", "false", "0", "''", "null", "~", "NULL", "- a", "hello"]
)
def test_falsy_non_mapping_descriptor_reports_shape_error(self, tmp_path, content):
"""Every non-mapping document reports the mapping-shape error.

`_validate` opens with an `isinstance(self.data, dict)` check, so a
truthy non-mapping (`- a`, `hello`) correctly reported "Descriptor root
must be a YAML mapping". `_load`'s plain `yaml.safe_load(fh) or {}`
masked that for the falsy shapes `[]`, `false`, `0`, `''` (coerced to
an empty mapping) and for an explicit null scalar (`null`, `~`, `NULL`
-- indistinguishable from an empty document by `safe_load` alone), so
those five reported "Missing required field: schema_version" instead.
"""
p = tmp_path / "integration.yml"
p.write_text(content)
with pytest.raises(
IntegrationDescriptorError,
match="Descriptor root must be a YAML mapping",
):
IntegrationDescriptor(p)

@pytest.mark.parametrize("content", ["", "---"])
def test_empty_document_still_reports_missing_fields(self, tmp_path, content):
"""Empty documents are normalized to an empty mapping, so missing fields are reported."""
p = tmp_path / "integration.yml"
p.write_text(content)
with pytest.raises(IntegrationDescriptorError, match="Missing required field: schema_version"):
IntegrationDescriptor(p)

def test_file_not_found(self, tmp_path):
with pytest.raises(IntegrationDescriptorError, match="Descriptor not found"):
IntegrationDescriptor(tmp_path / "nonexistent.yml")
Expand Down