diff --git a/src/specify_cli/extensions/command_info.py b/src/specify_cli/extensions/command_info.py index dfc685caa2..cf7796a653 100644 --- a/src/specify_cli/extensions/command_info.py +++ b/src/specify_cli/extensions/command_info.py @@ -164,12 +164,12 @@ def _print_extension_info(ext_info: dict, manager): # Header verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else "" - _commands.console.print(f"\n[bold]{_escape_markup(str(ext_info['name']))}[/bold] (v{_escape_markup(str(ext_info['version']))}){verified_badge}") + _commands.console.print(f"\n[bold]{_escape_markup(str(ext_info.get('name', ext_info['id'])))}[/bold] (v{_escape_markup(str(ext_info.get('version', 'unknown')))}){verified_badge}") _commands.console.print(f"ID: {_escape_markup(str(ext_info['id']))}") _commands.console.print() # Description - _commands.console.print(f"{_escape_markup(str(ext_info['description']))}") + _commands.console.print(f"{_escape_markup(str(ext_info.get('description', '')))}") _commands.console.print() # Author and License diff --git a/src/specify_cli/extensions/command_search.py b/src/specify_cli/extensions/command_search.py index ad31662643..8a0deca520 100644 --- a/src/specify_cli/extensions/command_search.py +++ b/src/specify_cli/extensions/command_search.py @@ -45,8 +45,8 @@ def extension_search( for ext in results: # Extension header verified_badge = " [green]✓ Verified[/green]" if ext.get("verified") else "" - console.print(f"[bold]{_escape_markup(str(ext['name']))}[/bold] (v{_escape_markup(str(ext['version']))}){verified_badge}") - console.print(f" {_escape_markup(str(ext['description']))}") + console.print(f"[bold]{_escape_markup(str(ext.get('name', ext['id'])))}[/bold] (v{_escape_markup(str(ext.get('version', 'unknown')))}){verified_badge}") + console.print(f" {_escape_markup(str(ext.get('description', '')))}") # Metadata console.print(f"\n [dim]Author:[/dim] {_escape_markup(str(ext.get('author', 'Unknown')))}") diff --git a/tests/specify_cli/extensions/conftest.py b/tests/specify_cli/extensions/conftest.py index 98b24fe60f..caa853eb8e 100644 --- a/tests/specify_cli/extensions/conftest.py +++ b/tests/specify_cli/extensions/conftest.py @@ -1,8 +1,10 @@ """Shared fixtures for the mirrored extension command tests.""" from __future__ import annotations +import json import shutil import tempfile +from datetime import datetime, timezone from pathlib import Path import pytest @@ -87,3 +89,48 @@ def project_dir(temp_dir): project.mkdir() (project / ".specify").mkdir() return project + + +@pytest.fixture( + params=[ + ({"name": "Example", "description": "No version"}, "Example (vunknown)"), + ({"version": "1.2.3", "description": "No name"}, "example (v1.2.3)"), + ({"name": "Example", "version": "1.2.3"}, "Example (v1.2.3)"), + ({"name": "Example", "version": "[/red]", "description": "Markup"}, "Example (v[/red])"), + ], + ids=["no-version", "no-name", "no-description", "markup"], +) +def incomplete_catalog_entry(request, tmp_path, monkeypatch): + """Serve an incomplete entry and a healthy sibling from the real cache. + + Returns the header expected for the incomplete entry. + """ + from specify_cli.extensions import ExtensionCatalog + + entry, header = request.param + + # tmp_path, not temp_dir: monkeypatch restores the cwd only after + # temp_dir's rmtree, which Windows refuses while the cwd is inside it. + project_dir = tmp_path / "project" + (project_dir / ".specify").mkdir(parents=True) + monkeypatch.chdir(project_dir) + monkeypatch.setenv("SPECKIT_CATALOG_URL", ExtensionCatalog.DEFAULT_CATALOG_URL) + catalog = ExtensionCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True) + catalog.cache_file.write_text(json.dumps({ + "schema_version": "1.0", + "extensions": { + "example": entry, + "healthy": {"name": "Healthy", "description": "Complete entry", "version": "2.0.0"}, + }, + }), encoding="utf-8") + catalog.cache_metadata_file.write_text(json.dumps({ + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": ExtensionCatalog.DEFAULT_CATALOG_URL, + }), encoding="utf-8") + + def no_network(*args, **kwargs): + pytest.fail("The fresh catalog cache must be used") + + monkeypatch.setattr(ExtensionCatalog, "_open_url", no_network) + return header diff --git a/tests/specify_cli/extensions/test_command_info.py b/tests/specify_cli/extensions/test_command_info.py index c8618b5328..5c32f3ad53 100644 --- a/tests/specify_cli/extensions/test_command_info.py +++ b/tests/specify_cli/extensions/test_command_info.py @@ -67,6 +67,17 @@ def test_info_renders_markup_bearing_stars(self): class TestExtensionInfoCLI: """CLI tests for ``specify extension info``.""" + def test_info_shows_incomplete_catalog_entry(self, incomplete_catalog_entry): + result = CliRunner().invoke(app, ["extension", "info", "example"]) + + assert result.exit_code == 0, result.output + assert incomplete_catalog_entry in result.output + assert "ID: example" in result.output + + healthy = CliRunner().invoke(app, ["extension", "info", "healthy"]) + assert healthy.exit_code == 0, healthy.output + assert "Healthy (v2.0.0)" in healthy.output + def test_info_discovery_only_shows_candidate_archive_url(self, tmp_path): """For a discovery-only entry that carries a ``download_url``, ``info`` surfaces the candidate archive URL (flagged for vetting) and the vetted diff --git a/tests/specify_cli/extensions/test_command_search.py b/tests/specify_cli/extensions/test_command_search.py index e44dad7a54..2dd6326203 100644 --- a/tests/specify_cli/extensions/test_command_search.py +++ b/tests/specify_cli/extensions/test_command_search.py @@ -22,6 +22,18 @@ class TestExtensionSearchCLI: """CLI tests for ``specify extension search``.""" + def test_search_shows_incomplete_catalog_entry(self, incomplete_catalog_entry): + result = CliRunner().invoke(app, ["extension", "search"]) + + assert result.exit_code == 0, result.output + assert incomplete_catalog_entry in result.output + assert "Healthy (v2.0.0)" in result.output + + filtered = CliRunner().invoke(app, ["extension", "search", "Healthy"]) + assert filtered.exit_code == 0, filtered.output + assert "Example" not in filtered.output + assert "Healthy (v2.0.0)" in filtered.output + @pytest.mark.parametrize("install_allowed", [True, False]) @pytest.mark.parametrize( ("extension_id", "command_id"),