Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
130 changes: 130 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down