From cd93f1175b3931c86d95e77a069be5d34a78cdb2 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Sat, 7 Nov 2026 22:17:56 +0000 Subject: [PATCH 1/2] fix(extensions): show catalog entries missing name, version or description `extension search` and `extension info` indexed a catalog entry's name, version and description directly, so an entry without one of them made both commands exit with a KeyError, and search never listed the entries after it. Catalog entries are not validated field by field on purpose, and `extension update` already skips a versionless entry (#5108). Read the three fields with .get(): `unknown` for a missing version (as `extension add` and installed-extension info show it), the extension ID for a missing name, and an empty description. Assisted-by: Codex CLI (model: gpt-6.1-sol, autonomous) Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- src/specify_cli/extensions/command_info.py | 4 +- src/specify_cli/extensions/command_search.py | 4 +- tests/specify_cli/extensions/conftest.py | 43 +++++++++++++++++++ .../extensions/test_command_info.py | 11 +++++ .../extensions/test_command_search.py | 12 ++++++ 5 files changed, 70 insertions(+), 4 deletions(-) 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..9c36f887ef 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,44 @@ 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, project_dir, 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 + + 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"), From ad8c909de61a5c3cc4b987f474e188bc30ca3048 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Mon, 9 Nov 2026 21:33:21 +0000 Subject: [PATCH 2/2] test(extensions): build the incomplete-entry project under tmp_path The autouse fixtures request monkeypatch before temp_dir, so the cwd was restored only after temp_dir's rmtree. Windows refuses to delete the current directory (WinError 32), and every case errored at teardown. Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) --- tests/specify_cli/extensions/conftest.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/specify_cli/extensions/conftest.py b/tests/specify_cli/extensions/conftest.py index 9c36f887ef..caa853eb8e 100644 --- a/tests/specify_cli/extensions/conftest.py +++ b/tests/specify_cli/extensions/conftest.py @@ -100,7 +100,7 @@ def project_dir(temp_dir): ], ids=["no-version", "no-name", "no-description", "markup"], ) -def incomplete_catalog_entry(request, project_dir, monkeypatch): +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. @@ -109,6 +109,10 @@ def incomplete_catalog_entry(request, project_dir, monkeypatch): 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)