Skip to content
Open
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
6 changes: 5 additions & 1 deletion src/specify_cli/workflows/overlays/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ def _find_overlay_file(project_root: Path, workflow_id: str, overlay_id: str) ->
return None
matches: list[Path] = []
for path in entries:
if not path.is_file() or path.suffix not in (".yml", ".yaml"):
# Must match ``ProjectOverlaySource.collect`` exactly. With the loader
# lowercasing the suffix and this helper not, a ``<id>.YML`` overlay was
# ACTIVE during resolution yet reported "not found" by overlay
# enable / disable / remove -- applied, but impossible to manage.
if not path.is_file() or path.suffix.lower() not in (".yml", ".yaml"):
continue
if path.is_symlink():
continue
Expand Down
7 changes: 6 additions & 1 deletion src/specify_cli/workflows/overlays/layer_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,12 @@ def collect(self, workflow_id: str, *, include_disabled: bool = False) -> list[L
workflow_overlay_dir, [f"Cannot enumerate overlays: {exc}"]
) from exc
for path in entries:
if not path.is_file() or path.suffix not in (".yml", ".yaml"):
# Match the extension case-insensitively. A hand-placed overlay
# named ``<id>.YML`` or ``<id>.Yaml`` was skipped here and never
# applied, with nothing reported to say the file had been ignored.
# Every other YAML discovery path in the package already lowercases
# before matching (engine.py:941, _commands.py:1325/1894/2128).
if not path.is_file() or path.suffix.lower() not in (".yml", ".yaml"):
Comment thread
jawwad-ali marked this conversation as resolved.
continue
if path.is_symlink():
raise OverlayLoadError(path, ["Symlinked overlay files are not allowed"])
Expand Down
52 changes: 52 additions & 0 deletions tests/workflows/test_overlay_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -1002,3 +1002,55 @@ def test_duplicate_manifest_id_is_rejected(self, project_dir, monkeypatch):

with pytest.raises(typer.Exit):
_find_overlay_file(project_dir, "wf", "lint")


class TestOverlayManagementMatchesUppercaseExtension:
"""Overlay management finds the same files the resolver loads."""

@pytest.mark.parametrize("filename", ["lint.YML", "lint.Yaml"])
def test_uppercase_extension_overlay_is_manageable(
self, project_dir, monkeypatch, filename
):
"""An overlay the resolver applies must be reachable by enable/disable/remove.

`ProjectOverlaySource.collect` matches the suffix case-insensitively, so
a `lint.YML` overlay is ACTIVE during resolution. `_find_overlay_file`
matched it case-sensitively, so the same overlay was reported "not
found" by every management command — applied, but impossible to manage.
"""
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
_write_workflow(
project_dir,
"wf",
{
"schema_version": "1.0",
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
"steps": [{"id": "a", "type": "command", "command": "echo"}],
},
)
ov_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf"
ov_dir.mkdir(parents=True, exist_ok=True)
overlay = ov_dir / filename
overlay.write_text(
yaml.safe_dump(
{
"id": "lint",
"extends": "wf",
"priority": 10,
"edits": [{"remove": "a"}],
}
),
encoding="utf-8",
)

result = runner.invoke(app, ["workflow", "overlay", "disable", "wf", "lint"])
assert result.exit_code == 0, result.output
assert yaml.safe_load(overlay.read_text(encoding="utf-8"))["enabled"] is False

result = runner.invoke(app, ["workflow", "overlay", "enable", "wf", "lint"])
assert result.exit_code == 0, result.output
assert yaml.safe_load(overlay.read_text(encoding="utf-8"))["enabled"] is True

result = runner.invoke(app, ["workflow", "overlay", "remove", "wf", "lint"])
assert result.exit_code == 0, result.output
assert not overlay.exists()
44 changes: 44 additions & 0 deletions tests/workflows/test_overlay_layer_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,50 @@ def test_empty_document_still_reports_missing_fields(
)


class TestProjectOverlaySourceExtensionMatching:
"""Overlay file extensions are matched case-insensitively."""

@pytest.mark.parametrize(
"filename", ["upper.YML", "mixed.Yaml", "shouty.YAML", "title.Yml"]
)
def test_uppercase_extension_is_collected(
self, project_dir: Path, filename: str
) -> None:
"""A hand-placed `<id>.YML` must not be silently ignored.

`collect` matched `path.suffix` verbatim against `(".yml", ".yaml")`,
so an overlay whose extension differed only in case was skipped with
nothing reported — the author's overlay simply never applied. Every
other YAML discovery path in the package lowercases before matching.
"""
ov_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf"
ov_dir.mkdir(parents=True, exist_ok=True)
(ov_dir / filename).write_text(
yaml.safe_dump(
{
"id": "lint",
"extends": "wf",
"priority": 10,
"edits": [{"remove": "a"}],
}
),
encoding="utf-8",
)

layers = ProjectOverlaySource(project_dir).collect("wf")

assert [layer.content.id for layer in layers] == ["lint"]

def test_non_yaml_extensions_are_still_skipped(self, project_dir: Path) -> None:
"""Broadening case must not broaden which extensions are accepted."""
ov_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf"
ov_dir.mkdir(parents=True, exist_ok=True)
for name in ("notes.txt", "backup.yml.bak", "README.md", "data.json"):
(ov_dir / name).write_text("id: lint\n", encoding="utf-8")

assert ProjectOverlaySource(project_dir).collect("wf") == []


class TestProjectOverlaySourceFileReadErrors:
"""File-read errors must be wrapped in OverlayLoadError, not leaked as raw tracebacks."""

Expand Down