diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d1f5efddbb..28b61c2cab 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -3204,8 +3204,32 @@ def extension_search( tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"), author: Optional[str] = typer.Option(None, "--author", help="Filter by author"), verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"), + markdown: bool = typer.Option( + False, + "--markdown", + help=( + "Output the full community catalog as a markdown table " + "(ignores query/tag/author/verified filters)" + ), + ), ): """Search for available extensions in catalog.""" + if markdown: + if query or tag or author or verified: + typer.echo( + "Warning: --markdown outputs the full community catalog and ignores filters " + "(query, --tag, --author, --verified).", + err=True, + ) + from .community_catalog_docs import render_community_extensions_table + + try: + typer.echo(render_community_extensions_table()) + except (ValueError, FileNotFoundError) as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) + return + from .extensions import ExtensionCatalog, ExtensionError project_root = _require_specify_project() diff --git a/src/specify_cli/community_catalog_docs.py b/src/specify_cli/community_catalog_docs.py new file mode 100644 index 0000000000..8d0accb1a0 --- /dev/null +++ b/src/specify_cli/community_catalog_docs.py @@ -0,0 +1,107 @@ +"""Helpers for rendering the community extensions reference table.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + + +ROOT_DIR = Path(__file__).resolve().parents[2] +COMMUNITY_CATALOG_PATH = ROOT_DIR / "extensions" / "catalog.community.json" + + +def _render_cell(value: str) -> str: + return value.replace("\r\n", " ").replace("\r", " ").replace("\n", " ").replace("|", "\\|") + + +def _format_inline_code(value: str) -> str: + text = _render_cell(value) + runs = [len(match) for match in re.findall(r"`+", text)] + fence = "`" * (max(runs, default=0) + 1) + return f"{fence}{text}{fence}" + + +def _sanitize_link_target(value: str) -> str: + return value.replace("\r\n", "").replace("\r", "").replace("\n", "").replace("|", "%7C") + + +def _format_tags(tags: Any) -> str: + if not isinstance(tags, list) or not tags: + return "—" + # Clean first, then filter: a tag of " | " would pass str(tag).strip() but produce + # an empty code span after pipe removal, so filter on the cleaned value. + cleaned = [_format_inline_code(c) for tag in tags if (c := str(tag).replace("|", "").strip())] + return ", ".join(cleaned) if cleaned else "—" + + +def list_community_extensions(path: Path = COMMUNITY_CATALOG_PATH) -> list[dict[str, Any]]: + """Return community extensions sorted alphabetically by name then ID.""" + if not path.exists(): + raise FileNotFoundError( + f"Community catalog not found: {path}. " + "The --markdown flag requires a spec-kit source checkout." + ) + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"Expected {path} to contain a JSON object") + extensions = data.get("extensions") + if not isinstance(extensions, dict): + raise ValueError(f"Expected {path} to contain an 'extensions' object") + + rows: list[dict[str, Any]] = [] + for ext_id, ext in extensions.items(): + if not isinstance(ext, dict): + raise ValueError(f"Community extension {ext_id!r} must be a mapping") + rows.append( + { + "name": str(ext.get("name") or ext_id), + "id": str(ext.get("id") or ext_id), + "description": str(ext.get("description") or ""), + "tags": ext.get("tags") or [], + "verified": "Yes" if bool(ext.get("verified")) else "No", + "repository": str(ext.get("repository") or ""), + } + ) + + return sorted(rows, key=lambda row: (row["name"].casefold(), row["id"].casefold())) + + +def render_community_extensions_table(path: Path = COMMUNITY_CATALOG_PATH) -> str: + """Render the community extensions table from catalog.community.json.""" + rows = list_community_extensions(path=path) + if not rows: + raise ValueError("Community catalog has no extensions") + + table_rows: list[list[str]] = [] + for row in rows: + # Escape raw field values *before* composing Markdown syntax so that + # a pipe inside a name or description doesn't break a link target. + safe_name = _render_cell(row["name"]) + safe_repository = _sanitize_link_target(row["repository"]) + link = ( + f"[{safe_name}]({safe_repository})" + if safe_repository + else safe_name + ) + table_rows.append( + [ + link, + _format_inline_code(row["id"]), + _render_cell(row["description"]), + _format_tags(row["tags"]), + row["verified"], + ] + ) + + headers = ("Extension", "ID", "Description", "Tags", "Verified") + + def render_row(values: list[str]) -> str: + # Values are already escaped; do not re-apply _render_cell here. + return "| " + " | ".join(values) + " |" + + separator = "| " + " | ".join("---" for _ in headers) + " |" + lines = [render_row(list(headers)), separator] + lines.extend(render_row(row) for row in table_rows) + return "\n".join(lines) + "\n" diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 5a595fbffa..8719b93995 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1134,7 +1134,7 @@ def check_compatibility( # Parse version specifier (e.g., ">=0.1.0,<2.0.0") try: specifier = SpecifierSet(required) - if current not in specifier: + if not specifier.contains(current, prereleases=True): raise CompatibilityError( f"Extension requires spec-kit {required}, " f"but {speckit_version} is installed.\n" diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 5219880741..b8c7db2c04 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -573,7 +573,7 @@ def check_compatibility( try: specifier = SpecifierSet(required) - if current not in specifier: + if not specifier.contains(current, prereleases=True): raise PresetCompatibilityError( f"Preset requires spec-kit {required}, " f"but {speckit_version} is installed.\n" diff --git a/tests/test_community_catalog_docs.py b/tests/test_community_catalog_docs.py new file mode 100644 index 0000000000..2cb9417eef --- /dev/null +++ b/tests/test_community_catalog_docs.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from specify_cli.community_catalog_docs import list_community_extensions, render_community_extensions_table + + +def _write_catalog(tmp_path: Path, extensions: dict) -> Path: + p = tmp_path / "catalog.community.json" + p.write_text(json.dumps({"extensions": extensions}), encoding="utf-8") + return p + + +# --------------------------------------------------------------------------- +# Happy-path tests against the real catalog +# --------------------------------------------------------------------------- + +def test_community_extensions_table_renders() -> None: + table = render_community_extensions_table() + assert "| Extension" in table + assert "| ID" in table + assert "| Description" in table + assert "| Tags" in table + assert "| Verified" in table + + +def test_community_extensions_are_sorted_by_name() -> None: + rows = list_community_extensions() + names = [row["name"] for row in rows] + assert names == sorted(names, key=str.casefold) + + +# --------------------------------------------------------------------------- +# Edge-case tests using synthetic catalogs +# --------------------------------------------------------------------------- + +def test_missing_catalog_file(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError, match="spec-kit source checkout"): + list_community_extensions(path=tmp_path / "missing.json") + + +def test_malformed_json(tmp_path: Path) -> None: + bad = tmp_path / "bad.json" + bad.write_text("not valid json", encoding="utf-8") + with pytest.raises(json.JSONDecodeError): + list_community_extensions(path=bad) + + +def test_non_dict_root(tmp_path: Path) -> None: + f = tmp_path / "catalog.json" + f.write_text(json.dumps([{"id": "foo"}]), encoding="utf-8") + with pytest.raises(ValueError, match="JSON object"): + list_community_extensions(path=f) + + +def test_missing_extensions_key(tmp_path: Path) -> None: + f = tmp_path / "catalog.json" + f.write_text(json.dumps({"other": {}}), encoding="utf-8") + with pytest.raises(ValueError, match="'extensions' object"): + list_community_extensions(path=f) + + +def test_non_dict_extension_value(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, {"foo": "not-a-dict"}) + with pytest.raises(ValueError, match="must be a mapping"): + list_community_extensions(path=f) + + +def test_empty_catalog_raises(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, {}) + with pytest.raises(ValueError, match="no extensions"): + render_community_extensions_table(path=f) + + +def test_extension_without_repository(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo": {"name": "Foo", "id": "foo", "description": "A foo tool", "tags": [], "verified": False, "repository": ""}, + }) + table = render_community_extensions_table(path=f) + assert "Foo" in table + assert "[Foo](" not in table # plain name, no link + + +def test_backticks_in_ids_and_tags_render_safely(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo": { + "name": "Foo", + "id": "foo`bar", + "description": "", + "tags": ["a`b"], + "verified": False, + "repository": "", + }, + }) + table = render_community_extensions_table(path=f) + assert "``foo`bar``" in table + assert "``a`b``" in table + foo_row = next(line for line in table.split("\n") if line.startswith("| ") and "Foo" in line) + assert foo_row.count("|") == 6 + + +def test_repository_values_are_sanitized_for_table_cells(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo": { + "name": "Foo", + "id": "foo", + "description": "", + "tags": [], + "verified": False, + "repository": "https://example.com/a|b\nnext", + }, + }) + table = render_community_extensions_table(path=f) + assert "https://example.com/a%7Cbnext" in table + foo_row = next(line for line in table.split("\n") if line.startswith("| ") and "Foo" in line) + assert foo_row.count("|") == 6 + + +def test_tags_containing_pipe_do_not_break_table(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + # No "id" field — exercises ext_id fallback; tag has pipe — exercises stripping + "foo": {"name": "Foo", "description": "", "tags": ["foo|bar"], "verified": False, "repository": ""}, + }) + table = render_community_extensions_table(path=f) + # pipe stripped from tag value + assert "`foobar`" in table + # id falls back to the dict key when "id" field is absent + assert "`foo`" in table + # row is well-formed: 5-column table has exactly 6 pipe separators per row + foo_row = next(line for line in table.split("\n") if line.startswith("| ") and "Foo" in line) + assert foo_row.count("|") == 6 + + +def test_non_list_tags_renders_em_dash(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo": {"name": "Foo", "description": "", "tags": "not-a-list", "verified": False, "repository": ""}, + }) + table = render_community_extensions_table(path=f) + assert "—" in table diff --git a/tests/test_extensions.py b/tests/test_extensions.py index b26a4b8f2a..1c45bf4613 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -754,6 +754,14 @@ def test_check_compatibility_valid(self, extension_dir, project_dir): result = manager.check_compatibility(manifest, "0.1.0") assert result is True + def test_check_compatibility_allows_prerelease_dev_version(self, extension_dir, project_dir): + """Test compatibility check allows source/dev prerelease versions.""" + manager = ExtensionManager(project_dir) + manifest = ExtensionManifest(extension_dir / "extension.yml") + + result = manager.check_compatibility(manifest, "0.8.15.dev0") + assert result is True + def test_check_compatibility_invalid(self, extension_dir, project_dir): """Test compatibility check with invalid version.""" manager = ExtensionManager(project_dir) diff --git a/tests/test_presets.py b/tests/test_presets.py index 29c1a9e5e2..fa634826a2 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -706,6 +706,12 @@ def test_check_compatibility_valid(self, pack_dir, temp_dir): manifest = PresetManifest(pack_dir / "preset.yml") assert manager.check_compatibility(manifest, "0.1.5") is True + def test_check_compatibility_allows_prerelease_dev_version(self, pack_dir, temp_dir): + """Test compatibility check allows source/dev prerelease versions.""" + manager = PresetManager(temp_dir) + manifest = PresetManifest(pack_dir / "preset.yml") + assert manager.check_compatibility(manifest, "0.8.15.dev0") is True + def test_check_compatibility_invalid(self, pack_dir, temp_dir): """Test compatibility check with invalid specifier.""" manager = PresetManager(temp_dir)