From a7455237b54167cf482dddefb25dff77a480c73e Mon Sep 17 00:00:00 2001 From: Noor-ul-ain001 Date: Sun, 26 Jul 2026 11:28:27 +0500 Subject: [PATCH 1/2] fix(extensions): tolerate non-string tags in catalog search ExtensionCatalog.search() assumed catalog `tags` were always strings: the tag filter called `t.lower()` and the query path did `" ".join([...] + tags)`. Extension catalog JSON is user-editable, so a hand-authored `tags: [1, 2]` crashed search with AttributeError (tag filter) or TypeError (query join). Coerce defensively by filtering to `isinstance(t, str)` and guarding the tags value as a list, matching the reference-correct sibling in integrations/catalog.py. Non-string tags are now skipped rather than raising. Adds a regression test driving search(tag=...) and search(query=...) against a catalog with mixed string/int tags; both fail pre-fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/specify_cli/extensions/__init__.py | 13 ++++-- tests/test_extensions.py | 65 ++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 91d8193fa1..b5bd20c8f4 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -3139,19 +3139,26 @@ def search( if author and ext_data.get("author", "").lower() != author.lower(): continue - if tag and tag.lower() not in [t.lower() for t in ext_data.get("tags", [])]: - continue + if tag: + raw_tags = ext_data.get("tags", []) + tags_list = raw_tags if isinstance(raw_tags, list) else [] + if tag.lower() not in [ + t.lower() for t in tags_list if isinstance(t, str) + ]: + continue if query: # Search in name, description, and tags query_lower = query.lower() + raw_tags = ext_data.get("tags", []) + tags_list = raw_tags if isinstance(raw_tags, list) else [] searchable_text = " ".join( [ ext_data.get("name", ""), ext_data.get("description", ""), ext_id, ] - + ext_data.get("tags", []) + + [t for t in tags_list if isinstance(t, str)] ).lower() if query_lower not in searchable_text: diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 61826aad5b..cb5a55b1b9 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -4169,6 +4169,71 @@ def test_search_by_tag(self, temp_dir): assert len(results) == 2 assert {r["id"] for r in results} == {"jira", "linear"} + def test_search_tolerates_non_string_tags(self, temp_dir): + """Non-string catalog tags must not crash tag/query search. + + Catalog JSON is user-editable, so ``tags`` may contain non-strings + (e.g. ``tags: [1, 2]``). The tag filter (``t.lower()``) and the query + searchable-text ``" ".join(...)`` both assume strings; a numeric tag + must be skipped rather than raising AttributeError/TypeError. + """ + import yaml as yaml_module + + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + config_path = project_dir / ".specify" / "extension-catalogs.yml" + with open(config_path, "w") as f: + yaml_module.dump( + { + "catalogs": [ + { + "name": "test-catalog", + "url": ExtensionCatalog.DEFAULT_CATALOG_URL, + "priority": 1, + "install_allowed": True, + } + ] + }, + f, + ) + + catalog = ExtensionCatalog(project_dir) + + # Mixed string / non-string tags, mirroring hand-edited catalog JSON. + catalog_data = { + "schema_version": "1.0", + "extensions": { + "jira": { + "name": "Jira", + "id": "jira", + "version": "1.0.0", + "description": "Jira", + "tags": ["issue-tracking", 1, 2], + }, + }, + } + + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + catalog.cache_file.write_text(json.dumps(catalog_data)) + catalog.cache_metadata_file.write_text( + json.dumps( + { + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": "http://test.com", + } + ) + ) + + # Tag filter: numeric tags skipped, string tag still matches. + results = catalog.search(tag="issue-tracking") + assert {r["id"] for r in results} == {"jira"} + + # Query search: numeric tags skipped, no crash, string fields match. + results = catalog.search(query="jira") + assert {r["id"] for r in results} == {"jira"} + def test_search_verified_only(self, temp_dir): """Test searching verified extensions only.""" import yaml as yaml_module From 02f6b6c89fb260a0e1e2c37487e579be4b7da592 Mon Sep 17 00:00:00 2001 From: Noor-ul-ain001 Date: Sun, 26 Jul 2026 11:38:17 +0500 Subject: [PATCH 2/2] fix(extensions): also coerce non-string author/name in catalog search The same ExtensionCatalog.search() method had two more string assumptions on user-editable catalog fields: the author filter called `ext_data.get("author", "").lower()` (AttributeError on a numeric author) and the query searchable-text joined `name`/`description` uncoerced (TypeError on a numeric name). Coerce both defensively, matching the reference-correct integrations/catalog.py::search. Extends the regression test with non-string author/name coverage; fails pre-fix with AttributeError at the author filter. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/specify_cli/extensions/__init__.py | 14 ++++-- tests/test_extensions.py | 65 ++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index b5bd20c8f4..d1c84f164b 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -3136,8 +3136,12 @@ def search( if verified_only and not ext_data.get("verified", False): continue - if author and ext_data.get("author", "").lower() != author.lower(): - continue + if author: + author_val = ext_data.get("author", "") + if not isinstance(author_val, str): + author_val = str(author_val) if author_val is not None else "" + if author_val.lower() != author.lower(): + continue if tag: raw_tags = ext_data.get("tags", []) @@ -3152,10 +3156,12 @@ def search( query_lower = query.lower() raw_tags = ext_data.get("tags", []) tags_list = raw_tags if isinstance(raw_tags, list) else [] + name_val = ext_data.get("name", "") + desc_val = ext_data.get("description", "") searchable_text = " ".join( [ - ext_data.get("name", ""), - ext_data.get("description", ""), + str(name_val) if name_val else "", + str(desc_val) if desc_val else "", ext_id, ] + [t for t in tags_list if isinstance(t, str)] diff --git a/tests/test_extensions.py b/tests/test_extensions.py index cb5a55b1b9..9814e8f2ab 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -4234,6 +4234,71 @@ def test_search_tolerates_non_string_tags(self, temp_dir): results = catalog.search(query="jira") assert {r["id"] for r in results} == {"jira"} + def test_search_tolerates_non_string_author_and_name(self, temp_dir): + """Non-string catalog author/name must not crash author/query search. + + Catalog JSON is user-editable, so ``author`` and ``name`` may be + non-strings. The author filter (``.lower()``) and the query + searchable-text ``" ".join(...)`` both assume strings; a numeric + value must be coerced rather than raising AttributeError/TypeError. + """ + import yaml as yaml_module + + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + config_path = project_dir / ".specify" / "extension-catalogs.yml" + with open(config_path, "w") as f: + yaml_module.dump( + { + "catalogs": [ + { + "name": "test-catalog", + "url": ExtensionCatalog.DEFAULT_CATALOG_URL, + "priority": 1, + "install_allowed": True, + } + ] + }, + f, + ) + + catalog = ExtensionCatalog(project_dir) + + # Numeric author/name, mirroring hand-edited catalog JSON. + catalog_data = { + "schema_version": "1.0", + "extensions": { + "jira": { + "name": 123, + "id": "jira", + "version": "1.0.0", + "description": "Jira", + "author": 456, + }, + }, + } + + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + catalog.cache_file.write_text(json.dumps(catalog_data)) + catalog.cache_metadata_file.write_text( + json.dumps( + { + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": "http://test.com", + } + ) + ) + + # Author filter: non-string author coerced, no AttributeError. + results = catalog.search(author="456") + assert {r["id"] for r in results} == {"jira"} + + # Query search: non-string name coerced into searchable text. + results = catalog.search(query="123") + assert {r["id"] for r in results} == {"jira"} + def test_search_verified_only(self, temp_dir): """Test searching verified extensions only.""" import yaml as yaml_module