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
4 changes: 2 additions & 2 deletions src/specify_cli/presets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2685,7 +2685,7 @@ def search(
continue

if tag and tag.lower() not in [
t.lower() for t in pack_data.get("tags", [])
str(t).lower() for t in pack_data.get("tags", [])
]:
continue

Expand All @@ -2697,7 +2697,7 @@ def search(
pack_data.get("description", ""),
pack_id,
]
+ pack_data.get("tags", [])
+ [str(t) for t in pack_data.get("tags", [])]
).lower()

if query_lower not in searchable_text:
Expand Down
8 changes: 4 additions & 4 deletions src/specify_cli/presets/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def preset_list():
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}")
console.print(f" {pack['description']}")
if pack.get("tags"):
tags_str = ", ".join(pack["tags"])
tags_str = _escape_markup(", ".join(str(t) for t in pack["tags"]))
console.print(f" [dim]Tags: {tags_str}[/dim]")
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
console.print()
Expand Down Expand Up @@ -288,7 +288,7 @@ def preset_search(
console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}")
console.print(f" {pack.get('description', '')}")
if pack.get("tags"):
tags_str = ", ".join(pack["tags"])
tags_str = ", ".join(str(t) for t in pack["tags"])
console.print(f" [dim]Tags: {tags_str}[/dim]")
console.print()

Expand Down Expand Up @@ -379,7 +379,7 @@ def preset_info(
if local_pack.author:
console.print(f" Author: {local_pack.author}")
if local_pack.tags:
console.print(f" Tags: {', '.join(local_pack.tags)}")
console.print(f" Tags: {', '.join(str(t) for t in local_pack.tags)}")
console.print(f" Templates: {len(local_pack.templates)}")
for tmpl in local_pack.templates:
console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
Expand Down Expand Up @@ -415,7 +415,7 @@ def preset_info(
if pack_info.get("author"):
console.print(f" Author: {pack_info['author']}")
if pack_info.get("tags"):
console.print(f" Tags: {', '.join(pack_info['tags'])}")
console.print(f" Tags: {', '.join(str(t) for t in pack_info['tags'])}")
if pack_info.get("repository"):
console.print(f" Repository: {pack_info['repository']}")
if pack_info.get("license"):
Expand Down
67 changes: 67 additions & 0 deletions tests/test_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -7678,3 +7678,70 @@ def test_composes_wrap_strategy_when_ensuring(self, project_dir, temp_dir):
assert "{CORE_TEMPLATE}" not in content
assert "# Ensure Wrapper" in content
assert "[PROJECT_NAME]" in content


class TestPresetTagsNonString:
"""Non-string catalog tags must not crash preset display commands.

Catalog payloads are user-editable YAML/JSON, so a `tags:` list can contain
numbers or other non-strings. The display path joins them; a raw
``", ".join(...)`` blows up with ``TypeError: sequence item 0: expected str``.
Sibling command surfaces (extensions/integrations/workflows) already guard
this with ``str(t) for t in ...`` — presets must match.
"""

def _seed_catalog(self, project_dir, tags):
catalog = PresetCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog_data = {
"schema_version": "1.0",
"presets": {
"numeric-tags": {
"name": "Numeric Tags",
"description": "Preset with non-string tags",
"version": "1.0.0",
"tags": tags,
},
},
}
catalog.cache_file.write_text(json.dumps(catalog_data))
catalog.cache_metadata_file.write_text(json.dumps({
"cached_at": datetime.now(timezone.utc).isoformat(),
}))
return catalog

def test_search_renders_non_string_tags(self, project_dir):
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app

catalog = self._seed_catalog(project_dir, [1, 2])
default_only = [PresetCatalogEntry(
url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True
)]

with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(PresetCatalog, "get_active_catalogs", return_value=default_only):
result = CliRunner().invoke(app, ["preset", "search", "Numeric"])

assert result.exit_code == 0, result.output
plain = strip_ansi(result.output)
assert "Tags: 1, 2" in plain

def test_info_renders_non_string_tags(self, project_dir):
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app

catalog = self._seed_catalog(project_dir, [1, 2])
default_only = [PresetCatalogEntry(
url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True
)]

with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(PresetCatalog, "get_active_catalogs", return_value=default_only):
result = CliRunner().invoke(app, ["preset", "info", "numeric-tags"])

assert result.exit_code == 0, result.output
plain = strip_ansi(result.output)
assert "Tags: 1, 2" in plain