Skip to content
Open
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
29 changes: 20 additions & 9 deletions src/specify_cli/presets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4500,23 +4500,34 @@ def search(
results = []

for pack_id, pack_data in packs.items():
if author and pack_data.get("author", "").lower() != author.lower():
continue
if author:
author_val = pack_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 [
str(t).lower() for t in pack_data.get("tags", [])
]:
continue
if tag:
raw_tags = pack_data.get("tags", [])
tags_list = raw_tags if isinstance(raw_tags, list) else []
if tag.lower() not in [
str(t).lower() for t in tags_list
]:
continue

if query:
query_lower = query.lower()
raw_tags = pack_data.get("tags", [])
tags_list = raw_tags if isinstance(raw_tags, list) else []
name_val = pack_data.get("name", "")
desc_val = pack_data.get("description", "")
searchable_text = " ".join(
[
pack_data.get("name", ""),
pack_data.get("description", ""),
str(name_val) if name_val else "",
str(desc_val) if desc_val else "",
pack_id,
]
+ [str(t) for t in pack_data.get("tags", [])]
+ [str(t) for t in tags_list]
).lower()

if query_lower not in searchable_text:
Expand Down
20 changes: 12 additions & 8 deletions src/specify_cli/presets/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ def preset_list():
pri = pack.get('priority', 10)
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 = _escape_markup(", ".join(str(t) for t in pack["tags"]))
tags = pack.get("tags", [])
if isinstance(tags, list) and tags:
tags_str = _escape_markup(", ".join(str(t) for t in tags))
console.print(f" [dim]Tags: {tags_str}[/dim]")
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
console.print()
Expand Down Expand Up @@ -287,8 +288,9 @@ def preset_search(
for pack in results:
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(str(t) for t in pack["tags"])
tags = pack.get("tags", [])
if isinstance(tags, list) and tags:
tags_str = _escape_markup(", ".join(str(t) for t in tags))
console.print(f" [dim]Tags: {tags_str}[/dim]")
console.print()

Expand Down Expand Up @@ -378,8 +380,9 @@ def preset_info(
console.print(f" Description: {local_pack.description}")
if local_pack.author:
console.print(f" Author: {local_pack.author}")
if local_pack.tags:
console.print(f" Tags: {', '.join(str(t) for t in local_pack.tags)}")
local_tags = local_pack.tags
if isinstance(local_tags, list) and local_tags:
console.print(f" Tags: {', '.join(str(t) for t in local_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 @@ -414,8 +417,9 @@ def preset_info(
console.print(f" Description: {pack_info.get('description', '')}")
if pack_info.get("author"):
console.print(f" Author: {pack_info['author']}")
if pack_info.get("tags"):
console.print(f" Tags: {', '.join(str(t) for t in pack_info['tags'])}")
catalog_tags = pack_info.get("tags", [])
if isinstance(catalog_tags, list) and catalog_tags:
console.print(f" Tags: {', '.join(str(t) for t in catalog_tags)}")
if pack_info.get("repository"):
console.print(f" Repository: {pack_info['repository']}")
if pack_info.get("license"):
Expand Down
130 changes: 123 additions & 7 deletions tests/test_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -11717,18 +11717,21 @@ class TestPresetTagsNonString:
this with ``str(t) for t in ...`` — presets must match.
"""

def _seed_catalog(self, project_dir, tags):
def _seed_catalog(self, project_dir, tags, extra=None):
catalog = PresetCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
pack = {
"name": "Numeric Tags",
"description": "Preset with non-string tags",
"version": "1.0.0",
"tags": tags,
}
if extra:
pack.update(extra)
catalog_data = {
"schema_version": "1.0",
"presets": {
"numeric-tags": {
"name": "Numeric Tags",
"description": "Preset with non-string tags",
"version": "1.0.0",
"tags": tags,
},
"numeric-tags": pack,
},
}
catalog.cache_file.write_text(json.dumps(catalog_data))
Expand Down Expand Up @@ -11772,3 +11775,116 @@ def test_info_renders_non_string_tags(self, project_dir):
assert result.exit_code == 0, result.output
plain = strip_ansi(result.output)
assert "Tags: 1, 2" in plain

def _default_only(self, catalog):
return [PresetCatalogEntry(
url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True
)]

def test_search_by_author_tolerates_non_string_author(self, project_dir):
"""``--author`` must not crash on a numeric catalog ``author``.

``PresetCatalog.search`` called ``.lower()`` straight on the raw value,
raising ``AttributeError: 'int' object has no attribute 'lower'``. The
sibling extension/integration catalogs coerce with ``str(...)`` first.
"""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app

catalog = self._seed_catalog(project_dir, ["ci"], extra={"author": 789})

with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(PresetCatalog, "get_active_catalogs",
return_value=self._default_only(catalog)):
result = CliRunner().invoke(app, ["preset", "search", "--author", "789"])

assert result.exit_code == 0, result.output
assert "Numeric Tags" in strip_ansi(result.output)

def test_search_query_tolerates_non_string_name_and_description(self, project_dir):
"""A query search must not crash on numeric ``name``/``description``.

The searchable-text join passed the raw values through, raising
``TypeError: sequence item 0: expected str instance, int found``.
"""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app

catalog = self._seed_catalog(
project_dir, ["ci"], extra={"name": 123, "description": 456}
)

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

assert result.exit_code == 0, result.output
assert "numeric-tags" in strip_ansi(result.output)

def test_search_tolerates_non_list_tags(self, project_dir):
"""A scalar ``tags:`` value must not crash the tag filter or display.

``tags: 5`` is truthy but not iterable, so both the ``--tag`` filter and
the result-display join raised ``TypeError: 'int' object is not
iterable``. Siblings guard with ``isinstance(raw_tags, list)``.
"""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app

catalog = self._seed_catalog(project_dir, 5)

with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(PresetCatalog, "get_active_catalogs",
return_value=self._default_only(catalog)):
filtered = CliRunner().invoke(app, ["preset", "search", "--tag", "ci"])
displayed = CliRunner().invoke(app, ["preset", "search", "Numeric"])

assert filtered.exit_code == 0, filtered.output
assert "No presets found" in strip_ansi(filtered.output)

assert displayed.exit_code == 0, displayed.output
plain = strip_ansi(displayed.output)
assert "Numeric Tags" in plain
assert "Tags:" not in plain

def test_info_tolerates_non_list_tags(self, project_dir):
"""``preset info`` must not crash rendering a scalar ``tags:`` value."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app

catalog = self._seed_catalog(project_dir, 5)

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

assert result.exit_code == 0, result.output
plain = strip_ansi(result.output)
assert "numeric-tags" in plain
assert "Tags:" not in plain

def test_search_escapes_rich_markup_in_tags(self, project_dir):
"""Bracketed tag text must survive Rich markup parsing.

``preset search`` printed tags unescaped, so a tag like ``[bold]`` was
swallowed as a style tag. ``preset list`` already escaped this.
"""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app

catalog = self._seed_catalog(project_dir, ["[bold]ci"])

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

assert result.exit_code == 0, result.output
assert "[bold]ci" in strip_ansi(result.output)