Skip to content

Commit 31d69e7

Browse files
jawwad-aliclaude
andcommitted
fix(workflows): distinguish an empty document from an explicit YAML null
Review catch: `safe_load` returns None for an explicit null scalar (`null`, `~`, `Null`, `NULL`) as well as for an empty document, so the `data is None` normalization still converted those manifests to `{}` and they still received missing-field errors instead of the mapping-shape error. Use `yaml.compose`, which yields no node only for a genuinely empty document, to tell the two apart. Measured: empty doc -> missing-field (correct) explicit null -> SHAPE explicit ~ -> SHAPE NULL -> SHAPE [] false 0 '' -> SHAPE - a / hello -> SHAPE Extends the parametrized cases with null/~/NULL, and corrects the article before `isinstance` in the docstring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e269c02 commit 31d69e7

2 files changed

Lines changed: 32 additions & 16 deletions

File tree

src/specify_cli/workflows/overlays/layer_sources.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -152,18 +152,26 @@ def collect(self, workflow_id: str, *, include_disabled: bool = False) -> list[L
152152
if path.is_symlink():
153153
raise OverlayLoadError(path, ["Symlinked overlay files are not allowed"])
154154
try:
155-
data = yaml.safe_load(path.read_text(encoding="utf-8"))
155+
text = path.read_text(encoding="utf-8")
156+
# ``safe_load`` returns None for BOTH an empty document and an
157+
# explicit null scalar (``null``, ``~``, ``Null``, ``NULL``), so
158+
# it cannot tell them apart on its own. ``compose`` yields no
159+
# node only for a genuinely empty document.
160+
is_empty_document = yaml.compose(text) is None
161+
data = yaml.safe_load(text)
156162
except yaml.YAMLError as exc:
157163
raise OverlayLoadError(path, [f"Invalid YAML: {exc}"]) from exc
158164
except (OSError, UnicodeDecodeError) as exc:
159165
raise OverlayLoadError(path, [f"Cannot load overlay: {exc}"]) from exc
160-
# Only an empty document (``None``) becomes an empty mapping, so the
161-
# missing-field errors are reported. ``or {}`` also masked the FALSY
162-
# non-mappings (``[]``, ``false``, ``0``, ``''``), which must be
163-
# reported as the wrong manifest shape like their truthy twins
164-
# (``- a``, ``hello``) already are. The sibling reader for these same
165-
# files, ``_read_overlay`` in overlays/_commands.py, does not coerce.
166-
if data is None:
166+
# Only a genuinely EMPTY document becomes an empty mapping, so its
167+
# missing-field errors are reported. Every non-mapping document --
168+
# including an explicit ``null``/``~`` and the falsy shapes ``[]``,
169+
# ``false``, ``0``, ``''`` that the previous ``or {}`` masked -- must
170+
# reach ``validate_overlay_yaml`` unchanged so it reports the wrong
171+
# manifest shape, like the truthy twins (``- a``, ``hello``) already
172+
# do. The sibling reader for these same files, ``_read_overlay`` in
173+
# overlays/_commands.py, does not coerce either.
174+
if is_empty_document:
167175
data = {}
168176
if (
169177
not include_disabled

tests/workflows/test_overlay_layer_sources.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,27 @@ def _write_overlay_file(project_dir: Path, workflow_id: str, overlay_id: str, da
3333
class TestProjectOverlaySourceManifestShape:
3434
"""A non-mapping overlay manifest is reported as a shape error."""
3535

36-
@pytest.mark.parametrize("content", ["[]", "false", "0", "''"])
36+
@pytest.mark.parametrize(
37+
"content", ["[]", "false", "0", "''", "null", "~", "NULL"]
38+
)
3739
def test_falsy_non_mapping_manifest_reports_shape_error(
3840
self, project_dir: Path, content: str
3941
) -> None:
40-
"""`or {}` masked the falsy non-mappings.
42+
"""Every non-mapping document reports the mapping-shape error.
4143
42-
`validate_overlay_yaml` opens with a `isinstance(data, dict)` check, so a
44+
`validate_overlay_yaml` opens with an `isinstance(data, dict)` check, so a
4345
truthy non-mapping (`- a`, `hello`) correctly reports "Overlay manifest
44-
must be a mapping." But `yaml.safe_load(...) or {}` replaced `[]`,
45-
`false`, `0` and `''` with an empty mapping first, so those files were
46-
reported as three bogus missing-field errors instead of the wrong shape.
47-
The sibling reader for these same files, `_read_overlay` in
48-
`overlays/_commands.py`, does not coerce.
46+
must be a mapping." Two things masked that for other documents:
47+
48+
* `yaml.safe_load(...) or {}` replaced the falsy shapes `[]`, `false`,
49+
`0` and `''` with an empty mapping.
50+
* `safe_load` returns `None` for an explicit null scalar (`null`, `~`,
51+
`NULL`) as well as for an empty document, so a `data is None` check
52+
swallowed those too.
53+
54+
Both now reach the validator unchanged; only a genuinely empty document
55+
is normalised to `{}` (pinned separately below), using `yaml.compose`,
56+
which yields no node only for an empty document.
4957
"""
5058
ov_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf"
5159
ov_dir.mkdir(parents=True, exist_ok=True)

0 commit comments

Comments
 (0)