From 42c7230aa92dc23b1bf0ccb3bb2f128b78abf62a Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Mon, 27 Jul 2026 21:58:23 +0500 Subject: [PATCH] fix(extensions): tolerate non-string tags in catalog search (#3746) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- src/specify_cli/extensions/__init__.py | 27 +++-- tests/test_extensions.py | 130 +++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index d39cffefe..19fcad0fa 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -3154,22 +3154,35 @@ class ExtensionCatalog(CatalogStackBase): 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 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 [] + 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, ] - + 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 fb5d3e48c..00d2b5377 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -4300,6 +4300,136 @@ class TestExtensionCatalog: 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_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