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
14 changes: 11 additions & 3 deletions src/specify_cli/extensions/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,17 @@ def _resolve_catalog_extension(
if ext_info:
return (ext_info, None)

# Try by display name - search using argument as query, then filter for exact match
search_results = catalog.search(query=argument)
name_matches = [ext for ext in search_results if ext["name"].lower() == argument.lower()]
# Try by display name - search using argument as query, then filter for exact match.
# Coerce name defensively: catalog JSON is user-editable, so a hand-authored
# non-string/missing name must not crash the match (the ambiguous-match display
# below already str()-coerces name for the same reason).
search_results = catalog.search()
argument_lower = argument.lower()
name_matches = [
ext
for ext in search_results
if str(ext.get("name", "")).lower() == argument_lower
]

if len(name_matches) == 1:
return (name_matches[0], None)
Expand Down
49 changes: 49 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6506,6 +6506,55 @@ def mock_download(extension_id):
f"but was called with '{download_called_with[0]}'"
)

def test_info_by_name_tolerates_non_string_catalog_name(self, tmp_path):
"""Display-name resolution must not crash on a non-string catalog name.

Catalog JSON is user-editable, so ``catalog.search()`` may return an
entry whose ``name`` is a non-string (e.g. ``name: 123``). The
display-name filter calls ``.lower()`` on it; without coercion this
raises ``AttributeError`` and takes down ``extension info``/``add``.
The entry with the bad name must simply not match, yielding a clean
"not found" rather than a traceback.
"""
from typer.testing import CliRunner
from unittest.mock import patch, MagicMock
from specify_cli import app

runner = CliRunner()

project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".specify" / "extensions").mkdir(parents=True)

# Catalog search returns an entry with a non-string name.
mock_catalog = MagicMock()
mock_catalog.get_extension_info.return_value = None # ID lookup fails
mock_catalog.search.return_value = [
Comment thread
mnriem marked this conversation as resolved.
{
"id": "acme-thing",
"name": 123,
"version": "1.0.0",
"description": "A thing",
"_install_allowed": True,
}
]

with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \
patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "info", "Some Name"],
catch_exceptions=True,
)

# Must not crash with AttributeError; the bad-named entry just doesn't
# match, so resolution ends as a clean not-found error exit.
assert not isinstance(result.exception, AttributeError), (
f"non-string catalog name crashed resolution: {result.exception!r}"
)
assert result.exit_code != 0

def test_add_bundled_extension_not_found_gives_clear_error(self, tmp_path):
"""extension add should give a clear error when a bundled extension is not found locally."""
from typer.testing import CliRunner
Expand Down