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
9 changes: 8 additions & 1 deletion src/specify_cli/extensions/catalog/command_remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,19 @@ def catalog_remove(
"[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list."
)
raise typer.Exit(1)
for index, entry in enumerate(catalogs):
if not isinstance(entry, dict):
_commands.console.print(
f"[red]Error:[/red] Invalid catalog entry at index {index}: "
f"expected a mapping, got {type(entry).__name__}"
)
raise typer.Exit(1)
safe_name = _escape_markup(name)
original_count = len(catalogs)
catalogs = [
catalog
for catalog in catalogs
if isinstance(catalog, dict) and catalog.get("name") != name
if catalog.get("name") != name
]

if len(catalogs) == original_count:
Expand Down
70 changes: 70 additions & 0 deletions tests/specify_cli/extensions/catalog/test_command_remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,47 @@
import os
from pathlib import Path

import pytest
import typer
import yaml

from tests.integrations.test_cli import _normalize_cli_output


class TestExtensionCatalogRemoveValidation:
"""Reject malformed entries before changing the user's catalog config."""

@pytest.mark.parametrize("invalid_entry", ["unfinished-entry", 0, False, None, []])
@pytest.mark.parametrize("invalid_index", [0, 1])
@pytest.mark.parametrize("target", ["keep", "does-not-exist"])
def test_malformed_entries_preserve_config(
self, tmp_path, monkeypatch, capsys, invalid_entry, invalid_index, target
):
from specify_cli.extensions import _commands
from specify_cli.extensions.catalog.command_remove import catalog_remove

(tmp_path / ".specify").mkdir()
config_path = tmp_path / ".specify" / "extension-catalogs.yml"
entries = [{"name": "keep", "url": "https://example.com/team.json"}]
entries.insert(invalid_index, invalid_entry)
original = (
"# Local settings: café\n"
+ yaml.safe_dump({"catalogs": entries, "metadata": "keep me"})
).replace("\n", "\r\n").encode("utf-8")
config_path.write_bytes(original)
monkeypatch.setattr(_commands, "_require_specify_project", lambda: tmp_path)

with pytest.raises(typer.Exit) as exc_info:
catalog_remove(target)

assert exc_info.value.exit_code == 1
assert config_path.read_bytes() == original
output = _normalize_cli_output(capsys.readouterr().out)
assert f"Invalid catalog entry at index {invalid_index}" in output
assert f"expected a mapping, got {type(invalid_entry).__name__}" in output
assert "Removed catalog" not in output


class TestExtensionCatalogRemoveCLI:
"""Integration coverage for the extension catalog command."""

Expand All @@ -34,6 +70,40 @@ def _invoke(self, argv, cwd):
finally:
os.chdir(old)

@pytest.mark.parametrize("target", ["keep", "does-not-exist"])
def test_remove_malformed_config_in_initialized_project(
self, tmp_path, target
):
"""A real initialized project keeps all config bytes on rejection."""
project = tmp_path / "initialized"
project.mkdir()
initialized = self._invoke(
[
"init", "--here", "--integration", "codex",
"--ignore-agent-tools", "--non-interactive", "--script", "py",
],
project,
)
assert initialized.exit_code == 0, initialized.output
config_path = project / ".specify" / "extension-catalogs.yml"
original = (
"# Local settings: café\r\n"
"catalogs:\r\n"
" - name: keep\r\n"
" url: https://example.com/team.json\r\n"
" - https://example.com/private.json\r\n"
"metadata: keep me\r\n"
).encode("utf-8")
config_path.write_bytes(original)

result = self._invoke(["extension", "catalog", "remove", target], project)

assert result.exit_code == 1, result.output
assert config_path.read_bytes() == original
output = _normalize_cli_output(result.output)
assert "Invalid catalog entry at index 1: expected a mapping, got str" in output
assert "Removed catalog" not in output

def test_extension_catalog_remove_rejects_non_mapping_config_root(self, tmp_path):
project = self._make_project(tmp_path)
cfg_path = project / ".specify" / "extension-catalogs.yml"
Expand Down
Loading