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
7 changes: 6 additions & 1 deletion src/specify_cli/workflows/overlay/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 (``WorkflowEngine``, ``workflow add`` / ``run``).
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
6 changes: 5 additions & 1 deletion src/specify_cli/workflows/overlay/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,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
45 changes: 45 additions & 0 deletions tests/specify_cli/workflows/overlay/test_command_disable.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from pathlib import Path

import pytest
import yaml
from typer.testing import CliRunner

Expand Down Expand Up @@ -163,3 +164,47 @@ def test_enable_disable_with_mismatched_filename(self, project_dir, monkeypatch)
)
)
assert data["enabled"] is True


class TestOverlayUppercaseExtension:
"""``disable`` must find every overlay the resolver applies.

``ProjectOverlaySource.collect`` matches ``.yml``/``.yaml`` case-insensitively,
so a ``lint.YML`` overlay is ACTIVE during resolution. ``_find_overlay_file``
matched the suffix case-sensitively, so ``disable`` reported that same overlay
"not found" -- applied, but impossible to switch off.
"""

@pytest.mark.parametrize("filename", ["lint.YML", "lint.Yaml"])
def test_disable_finds_uppercase_extension_overlay(
self, project_dir, monkeypatch, filename
):
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
48 changes: 47 additions & 1 deletion tests/specify_cli/workflows/overlay/test_command_enable.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

from __future__ import annotations


import pytest
import yaml
from typer.testing import CliRunner

from specify_cli import app
Expand Down Expand Up @@ -30,3 +31,48 @@ def test_overlay_enable_rejects_traversal(self, project_dir, monkeypatch):
result = runner.invoke(app, ["workflow", "overlay", "enable", "wf", "../other"])
assert result.exit_code != 0, result.output
assert "invalid" in result.output.lower() or "traversal" in result.output.lower()


class TestOverlayUppercaseExtension:
"""``enable`` must find every overlay the resolver applies.

``ProjectOverlaySource.collect`` matches ``.yml``/``.yaml`` case-insensitively,
so a ``lint.YML`` overlay is ACTIVE during resolution. ``_find_overlay_file``
matched the suffix case-sensitively, so ``enable`` reported that same overlay
"not found" -- disabled, and impossible to switch back on.
"""

@pytest.mark.parametrize("filename", ["lint.YML", "lint.Yaml"])
def test_enable_finds_uppercase_extension_overlay(
self, project_dir, monkeypatch, filename
):
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,
"enabled": False,
"edits": [{"remove": "a"}],
}
),
encoding="utf-8",
)

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
45 changes: 45 additions & 0 deletions tests/specify_cli/workflows/overlay/test_command_remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from pathlib import Path

import pytest
import yaml
from typer.testing import CliRunner

Expand Down Expand Up @@ -170,3 +171,47 @@ def test_overlay_remove_rejects_symlink(self, project_dir, monkeypatch):
assert result.exit_code != 0, result.output
assert real_file.is_file()
assert "symlink" in result.output.lower() or "Invalid" in result.output


class TestOverlayUppercaseExtension:
"""``remove`` must find every overlay the resolver applies.

``ProjectOverlaySource.collect`` matches ``.yml``/``.yaml`` case-insensitively,
so a ``lint.YML`` overlay is ACTIVE during resolution. ``_find_overlay_file``
matched the suffix case-sensitively, so ``remove`` reported that same overlay
"not found" -- applied, but impossible to uninstall.
"""

@pytest.mark.parametrize("filename", ["lint.YML", "lint.Yaml"])
def test_remove_finds_uppercase_extension_overlay(
self, project_dir, monkeypatch, filename
):
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", "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
Loading