From 797c44c79f66fd489c26806a2ce43f87fbfec4ba Mon Sep 17 00:00:00 2001 From: Noor-ul-ain001 Date: Sun, 26 Jul 2026 11:40:09 +0500 Subject: [PATCH 1/3] fix(extensions): tolerate non-string catalog name in display-name lookup _resolve_catalog_extension() filters catalog search results by display name with `ext["name"].lower() == argument.lower()`. Extension catalog JSON is user-editable, so a hand-authored non-string name (e.g. `name: 123`) crashes the filter with `AttributeError: 'int' object has no attribute 'lower'`, taking down `extension info ` and `extension add `. A missing `name` key would likewise KeyError. Coerce defensively with `str(ext.get("name", "")).lower()`, matching the ambiguous-match display block just below (which already str()-coerces name for the same reason). A bad-named entry simply doesn't match, yielding a clean not-found error instead of a traceback. Adds a regression test invoking `extension info ` against a mocked catalog whose search result has `name: 123`; it fails pre-fix with AttributeError. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/specify_cli/extensions/_commands.py | 12 +++++- tests/test_extensions.py | 49 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 7ae4f8e9f0..a4c2b6ae9a 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 + # 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(query=argument) - name_matches = [ext for ext in search_results if ext["name"].lower() == argument.lower()] + 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..5ecd41ba05 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_add_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 From 1100aad196b529a8a91b7ac6d601559ca5c8682c Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Mon, 27 Jul 2026 19:33:11 +0500 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index a4c2b6ae9a..1a6f35dfe0 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -170,7 +170,7 @@ def _resolve_catalog_extension( # 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(query=argument) + search_results = catalog.search() argument_lower = argument.lower() name_matches = [ ext From 33c6a89da3a4bdc1a08d7b5ce07bdf44eece06c1 Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Tue, 28 Jul 2026 00:43:10 +0500 Subject: [PATCH 3/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/test_extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 5ecd41ba05..4d2e5be96f 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -6506,7 +6506,7 @@ def mock_download(extension_id): f"but was called with '{download_called_with[0]}'" ) - def test_add_by_name_tolerates_non_string_catalog_name(self, tmp_path): + 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