diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 91d8193fa1..d1c84f164b 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -3136,22 +3136,35 @@ 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 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 61826aad5b..9814e8f2ab 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -4169,6 +4169,136 @@ 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_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