diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 7ae4f8e9f0..1a6f35dfe0 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -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) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 61826aad5b..4d2e5be96f 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -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 = [ + { + "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