diff --git a/changelog/+marketplace-browsing.added.md b/changelog/+marketplace-browsing.added.md new file mode 100644 index 00000000..b7a7c62d --- /dev/null +++ b/changelog/+marketplace-browsing.added.md @@ -0,0 +1 @@ +Added `infrahubctl marketplace list`, `search`, and `show` commands for browsing schemas and collections on the Infrahub Marketplace. diff --git a/docs/docs/infrahubctl/infrahubctl-marketplace.mdx b/docs/docs/infrahubctl/infrahubctl-marketplace.mdx index 4b9ebc5f..63791550 100644 --- a/docs/docs/infrahubctl/infrahubctl-marketplace.mdx +++ b/docs/docs/infrahubctl/infrahubctl-marketplace.mdx @@ -16,8 +16,75 @@ $ infrahubctl marketplace [OPTIONS] COMMAND [ARGS]... **Commands**: +* `show`: Show full details of a schema or... +* `list`: List schemas (default) or collections... +* `search`: Search the Infrahub Marketplace for... * `get`: Fetch a schema or collection from the... +## `infrahubctl marketplace show` + +Show full details of a schema or collection from the Infrahub Marketplace. + +**Usage**: + +```console +$ infrahubctl marketplace show [OPTIONS] IDENTIFIER +``` + +**Arguments**: + +* `IDENTIFIER`: Schema or collection identifier in namespace/name format [required] + +**Options**: + +* `-c, --collection`: Force collection lookup. Default: auto-detect whether the identifier is a schema or collection. +* `--json`: Output raw JSON to stdout instead of a table. +* `--marketplace-url TEXT`: Base URL of the Infrahub Marketplace. Overrides configuration and environment. +* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml] +* `--help`: Show this message and exit. + +## `infrahubctl marketplace list` + +List schemas (default) or collections available on the Infrahub Marketplace. + +**Usage**: + +```console +$ infrahubctl marketplace list [OPTIONS] +``` + +**Options**: + +* `--collections`: List collections instead of schemas. +* `-l, --limit INTEGER`: Maximum number of results to display. +* `--json`: Output raw JSON to stdout instead of a table. +* `--marketplace-url TEXT`: Base URL of the Infrahub Marketplace. Overrides configuration and environment. +* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml] +* `--help`: Show this message and exit. + +## `infrahubctl marketplace search` + +Search the Infrahub Marketplace for schemas (default) or collections. + +**Usage**: + +```console +$ infrahubctl marketplace search [OPTIONS] TERM +``` + +**Arguments**: + +* `TERM`: Search term matched against name, display name, and description. [required] + +**Options**: + +* `--collections`: Search collections instead of schemas. +* `-l, --limit INTEGER`: Maximum number of results to display. +* `--json`: Output raw JSON to stdout instead of a table. +* `--marketplace-url TEXT`: Base URL of the Infrahub Marketplace. Overrides configuration and environment. +* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml] +* `--help`: Show this message and exit. + ## `infrahubctl marketplace get` Fetch a schema or collection from the Infrahub Marketplace. diff --git a/infrahub_sdk/ctl/marketplace.py b/infrahub_sdk/ctl/marketplace.py index 1b12e279..9618d585 100644 --- a/infrahub_sdk/ctl/marketplace.py +++ b/infrahub_sdk/ctl/marketplace.py @@ -10,6 +10,7 @@ import httpx import typer from rich.console import Console +from rich.table import Table from ..async_typer import AsyncTyper from ..config import ConfigBase as _SdkConfig @@ -74,12 +75,146 @@ def _collection_url(base_url: str, namespace: str, name: str) -> str: return f"{base_url}/api/v1/collections/{namespace}/{name}" +def _list_url(base_url: str, item_type: MarketplaceItemType) -> str: + return f"{base_url}/api/v1/{item_type}s" + + +async def _fetch_listing( + client: httpx.AsyncClient, + base_url: str, + item_type: MarketplaceItemType, + *, + search: str | None, + limit: int | None, +) -> tuple[list[dict[str, Any]], int]: + """Fetch marketplace listing items, following cursor pagination. + + When ``limit`` is given, a single page of that size is requested (no cursor + loop). Otherwise every page is fetched until ``has_next_page`` is false. + Returns the accumulated items and the reported ``total_count``. + """ + url = _list_url(base_url, item_type) + params: dict[str, Any] = {} + if search: + params["search"] = search + if limit is not None: + params["limit"] = limit + + items: list[dict[str, Any]] = [] + total_count = 0 + while True: + resp = await client.get(url, params=params) + resp.raise_for_status() + payload = _json_or_fail(resp, url) + items.extend(payload.get("items", [])) + total_count = payload.get("total_count", len(items)) + page_info = payload.get("page_info") or {} + cursor = page_info.get("end_cursor") + # Stop when a single page was requested, when the server reports no more + # pages, or when it claims a next page but gives no cursor to follow + # (guards against an infinite loop re-requesting the same page). + if limit is not None or not page_info.get("has_next_page") or not cursor: + break + params = {**params, "cursor": cursor} + return items, total_count + + +def _print_json(data: Any) -> None: + console.print_json(data=data) + + +def _render_list_table(items: list[dict[str, Any]], item_type: MarketplaceItemType, total_count: int) -> None: + table = Table() + if item_type == "schema": + table.add_column("Identifier") + table.add_column("Name") + table.add_column("Version") + table.add_column("Downloads", justify="right") + table.add_column("Tags") + for item in items: + latest = item.get("latest_version") or {} + tags = ", ".join(tag.get("name", "") for tag in item.get("tags") or []) + table.add_row( + f"{item.get('namespace', '')}/{item.get('name', '')}", + item.get("display_name", ""), + latest.get("semver", ""), + str(item.get("download_count", 0)), + tags, + ) + else: + table.add_column("Identifier") + table.add_column("Name") + table.add_column("Schemas", justify="right") + table.add_column("Downloads", justify="right") + for item in items: + table.add_row( + f"{item.get('namespace', '')}/{item.get('name', '')}", + item.get("display_name", ""), + str(item.get("schema_count", 0)), + str(item.get("download_count", 0)), + ) + console.print(table) + if len(items) < total_count: + console.print(f"[dim]Showing {len(items)} of {total_count}.") + + +async def _run_listing( + *, + item_type: MarketplaceItemType, + search: str | None, + limit: int | None, + json_output: bool, + marketplace_url: str | None, +) -> None: + sdk_cfg = _SdkConfig() + resolved_url = (marketplace_url or SETTINGS.active.marketplace_url).rstrip("/") + async with _make_http_client(sdk_cfg) as client: + try: + items, total_count = await _fetch_listing(client, resolved_url, item_type, search=search, limit=limit) + except httpx.HTTPError as exc: + error_class = _classify_http_error(exc) + if error_class is _ErrorClass.NOT_FOUND: + _fail(error_class, f"Marketplace request to {_host_from(resolved_url)} failed: {exc}") + detail = str(exc) or type(exc).__name__ + _fail(_ErrorClass.NETWORK, f"Marketplace request failed: {detail}") + if json_output: + _print_json(items) + return + _render_list_table(items, item_type, total_count) + + def _is_transport_failure(r: object) -> bool: if isinstance(r, Exception): return True return isinstance(r, httpx.Response) and r.status_code >= 500 +def _classify_http_error(exc: httpx.HTTPError) -> _ErrorClass: + """Map an httpx error to an error class for consistent exit-code assignment. + + A response with a 4xx status code is a client/not-found error (exit 1). + A 5xx response or a transport-level failure with no response is a network + error (exit 2). + """ + response = getattr(exc, "response", None) + if response is not None and response.status_code < 500: + return _ErrorClass.NOT_FOUND + return _ErrorClass.NETWORK + + +def _json_or_fail(resp: httpx.Response, source_url: str) -> Any: + """Parse a JSON response body, failing with a network-class error on invalid JSON. + + A 200 with a malformed body is a broken response, not user error, so it is + reported cleanly (exit 2) rather than leaking a raw ``JSONDecodeError`` and + traceback — which would also corrupt ``--json`` output. + """ + try: + return resp.json() + except ValueError: + _fail(_ErrorClass.NETWORK, f"Response from {_host_from(source_url)} is not valid JSON.") + + def _mkdir_or_fail(path: Path) -> None: try: path.mkdir(parents=True, exist_ok=True) @@ -281,6 +416,188 @@ async def _download_collection( status.print(f"\n[green]Collection {namespace}/{name}: {downloaded} schemas downloaded") +def _detail_url(base_url: str, item_type: MarketplaceItemType, namespace: str, name: str) -> str: + return f"{base_url}/api/v1/{item_type}s/{namespace}/{name}" + + +async def _fetch_detail( + client: httpx.AsyncClient, + base_url: str, + namespace: str, + name: str, + *, + force_collection: bool, +) -> tuple[MarketplaceItemType, dict[str, Any]]: + """Fetch full detail for a schema or collection. + + With ``force_collection`` the collection detail endpoint is used directly. + Otherwise both detail endpoints are probed in parallel; a schema wins a + 200/200 collision (consistent with ``get``'s auto-detection). + """ + if force_collection: + resp = await client.get(_detail_url(base_url, "collection", namespace, name)) + if resp.status_code == 404: + _fail(_ErrorClass.NOT_FOUND, f"No collection named '{namespace}/{name}' found on {_host_from(base_url)}.") + resp.raise_for_status() + return "collection", _json_or_fail(resp, base_url) + + schema_resp, collection_resp = await asyncio.gather( + client.get(_detail_url(base_url, "schema", namespace, name)), + client.get(_detail_url(base_url, "collection", namespace, name)), + return_exceptions=True, + ) + if isinstance(schema_resp, httpx.Response) and schema_resp.status_code == 200: + if isinstance(collection_resp, httpx.Response) and collection_resp.status_code == 200: + err_console.print( + f"[yellow]Note: '{namespace}/{name}' exists as both a schema and a collection. " + "Resolving as schema. Pass --collection to force the collection path." + ) + return "schema", _json_or_fail(schema_resp, base_url) + if isinstance(collection_resp, httpx.Response) and collection_resp.status_code == 200: + return "collection", _json_or_fail(collection_resp, base_url) + + if _is_transport_failure(schema_resp) or _is_transport_failure(collection_resp): + _fail( + _ErrorClass.NETWORK, + f"Could not reach marketplace at {base_url}. Check your connection or --marketplace-url.", + ) + _fail( + _ErrorClass.NOT_FOUND, + f"No schema or collection named '{namespace}/{name}' found on {_host_from(base_url)}.", + ) + + +def _render_detail(detail: dict[str, Any], item_type: MarketplaceItemType) -> None: + namespace = detail.get("namespace", "") + name = detail.get("name", "") + console.print(f"[bold]{namespace}/{name}[/] — {detail.get('display_name', '')}") + if detail.get("description"): + console.print(detail["description"]) + console.print(f"Downloads: {detail.get('download_count', 0)}") + + if item_type == "schema": + tags = ", ".join(tag.get("name", "") for tag in detail.get("tags") or []) + if tags: + console.print(f"Tags: {tags}") + versions = detail.get("versions") or [] + if versions: + table = Table(title="Versions") + table.add_column("Version") + table.add_column("Status") + table.add_column("Released") + table.add_column("Changelog") + for version in versions: + table.add_row( + version.get("semver", ""), + version.get("status", ""), + (version.get("created_at") or "")[:10], + version.get("changelog") or "", + ) + console.print(table) + else: + members = detail.get("items") or [] + console.print(f"Schemas: {len(members)}") + if members: + table = Table(title="Members") + table.add_column("Identifier") + table.add_column("Name") + for member in members: + schema = member.get("schema") or {} + table.add_row( + f"{schema.get('namespace', '')}/{schema.get('name', '')}", + schema.get("display_name", ""), + ) + console.print(table) + + deps = (detail.get("dependencies") or {}).get("schemas") or [] + if deps: + dep_list = ", ".join(f"{dep.get('namespace', '')}/{dep.get('name', '')}" for dep in deps) + console.print(f"Dependencies: {dep_list}") + + +@app.command() +@catch_exception(console=console) +async def show( + identifier: str = typer.Argument(help="Schema or collection identifier in namespace/name format"), + collection: bool = typer.Option( + False, + "--collection", + "-c", + is_flag=True, + help="Force collection lookup. Default: auto-detect whether the identifier is a schema or collection.", + ), + json_output: bool = typer.Option(False, "--json", help="Output raw JSON to stdout instead of a table."), + marketplace_url: str | None = typer.Option( + None, "--marketplace-url", help="Base URL of the Infrahub Marketplace. Overrides configuration and environment." + ), + _: str = CONFIG_PARAM, +) -> None: + """Show full details of a schema or collection from the Infrahub Marketplace.""" + parsed = _parse_identifier(identifier) + sdk_cfg = _SdkConfig() + resolved_url = (marketplace_url or SETTINGS.active.marketplace_url).rstrip("/") + async with _make_http_client(sdk_cfg) as client: + try: + item_type, detail = await _fetch_detail( + client, resolved_url, parsed.namespace, parsed.name, force_collection=collection + ) + except httpx.HTTPError as exc: + error_class = _classify_http_error(exc) + if error_class is _ErrorClass.NOT_FOUND: + _fail(error_class, f"Marketplace request for '{parsed.namespace}/{parsed.name}' failed: {exc}") + message = str(exc) or type(exc).__name__ + _fail(_ErrorClass.NETWORK, f"Marketplace request failed: {message}") + if json_output: + _print_json(detail) + return + _render_detail(detail, item_type) + + +@app.command(name="list") +@catch_exception(console=console) +async def list_items( + collections: bool = typer.Option(False, "--collections", is_flag=True, help="List collections instead of schemas."), + limit: int | None = typer.Option(None, "--limit", "-l", help="Maximum number of results to display."), + json_output: bool = typer.Option(False, "--json", help="Output raw JSON to stdout instead of a table."), + marketplace_url: str | None = typer.Option( + None, "--marketplace-url", help="Base URL of the Infrahub Marketplace. Overrides configuration and environment." + ), + _: str = CONFIG_PARAM, +) -> None: + """List schemas (default) or collections available on the Infrahub Marketplace.""" + await _run_listing( + item_type="collection" if collections else "schema", + search=None, + limit=limit, + json_output=json_output, + marketplace_url=marketplace_url, + ) + + +@app.command() +@catch_exception(console=console) +async def search( + term: str = typer.Argument(help="Search term matched against name, display name, and description."), + collections: bool = typer.Option( + False, "--collections", is_flag=True, help="Search collections instead of schemas." + ), + limit: int | None = typer.Option(None, "--limit", "-l", help="Maximum number of results to display."), + json_output: bool = typer.Option(False, "--json", help="Output raw JSON to stdout instead of a table."), + marketplace_url: str | None = typer.Option( + None, "--marketplace-url", help="Base URL of the Infrahub Marketplace. Overrides configuration and environment." + ), + _: str = CONFIG_PARAM, +) -> None: + """Search the Infrahub Marketplace for schemas (default) or collections.""" + await _run_listing( + item_type="collection" if collections else "schema", + search=term, + limit=limit, + json_output=json_output, + marketplace_url=marketplace_url, + ) + + @app.command() @catch_exception(console=console) async def get( diff --git a/tests/unit/ctl/test_marketplace_app.py b/tests/unit/ctl/test_marketplace_app.py index 4f302a51..b2f01b97 100644 --- a/tests/unit/ctl/test_marketplace_app.py +++ b/tests/unit/ctl/test_marketplace_app.py @@ -1,3 +1,4 @@ +import json as _json from pathlib import Path import httpx @@ -570,6 +571,330 @@ def test_get_collection_stdout_separator(httpx_mock: HTTPXMock, tmp_path: Path) assert result.output.count(bare_yaml) == 2 +def _listing_json(item_type: str, items: list[dict], *, total: int | None = None, cursor: str | None = None) -> dict: + """Build a marketplace list/search envelope. ``item_type`` is 'schemas' or 'collections'.""" + return { + "items": items, + "page_info": {"has_next_page": cursor is not None, "end_cursor": cursor}, + "total_count": total if total is not None else len(items), + } + + +def _schema_item(namespace: str, name: str, *, display: str, semver: str, downloads: int, tags: list[str]) -> dict: + return { + "namespace": namespace, + "name": name, + "display_name": display, + "download_count": downloads, + "tags": [{"name": t} for t in tags], + "latest_version": {"semver": semver}, + } + + +def test_list_schemas(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas", + json=_listing_json( + "schemas", + [_schema_item("infrahub", "dcim", display="DCIM", semver="1.2.0", downloads=42, tags=["core"])], + total=1, + ), + ) + result = runner.invoke(app, ["list"]) + + assert result.exit_code == 0 + assert "infrahub/dcim" in result.output + assert "DCIM" in result.output + assert "1.2.0" in result.output + assert "42" in result.output + assert "core" in result.output + + +def _collection_item(namespace: str, name: str, *, display: str, schema_count: int, downloads: int) -> dict: + return { + "namespace": namespace, + "name": name, + "display_name": display, + "schema_count": schema_count, + "download_count": downloads, + } + + +def test_list_collections(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections", + json=_listing_json( + "collections", + [_collection_item("infrahub", "security-mgmt", display="Security", schema_count=5, downloads=7)], + total=1, + ), + ) + result = runner.invoke(app, ["list", "--collections"]) + + assert result.exit_code == 0 + assert "infrahub/security-mgmt" in result.output + assert "Security" in result.output + assert "5" in result.output + assert "7" in result.output + + +def test_list_follows_cursor_pagination(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas", + json=_listing_json( + "schemas", + [_schema_item("infrahub", "a", display="A", semver="1.0.0", downloads=1, tags=[])], + total=2, + cursor="CURSOR1", + ), + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas?cursor=CURSOR1", + json=_listing_json( + "schemas", + [_schema_item("infrahub", "b", display="B", semver="1.0.0", downloads=1, tags=[])], + total=2, + ), + ) + result = runner.invoke(app, ["list"]) + + assert result.exit_code == 0 + assert "infrahub/a" in result.output + assert "infrahub/b" in result.output + + +def test_list_limit_requests_single_page_and_shows_footer(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas?limit=1", + json=_listing_json( + "schemas", + [_schema_item("infrahub", "a", display="A", semver="1.0.0", downloads=1, tags=[])], + total=52, + cursor="CURSOR1", + ), + ) + # No second page mock: if the implementation followed the cursor despite --limit, + # pytest-httpx would raise "request not expected". + result = runner.invoke(app, ["list", "--limit", "1"]) + + assert result.exit_code == 0 + assert "infrahub/a" in result.output + assert "Showing 1 of 52" in result.output + + +def test_list_network_error_exits_2(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas", + status_code=503, + json={"detail": "unavailable"}, + ) + result = runner.invoke(app, ["list"]) + + assert result.exit_code == 2 + assert "Marketplace request failed" in result.output + + +def test_search_passes_term_and_renders(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas?search=vlan", + json=_listing_json( + "schemas", + [_schema_item("infrahub", "vlan", display="VLAN", semver="1.0.0", downloads=3, tags=[])], + total=1, + ), + ) + result = runner.invoke(app, ["search", "vlan"]) + + assert result.exit_code == 0 + assert "infrahub/vlan" in result.output + + +def test_search_empty_results(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas?search=nomatch", + json=_listing_json("schemas", [], total=0), + ) + result = runner.invoke(app, ["search", "nomatch"]) + + assert result.exit_code == 0 + # An empty catalog is not an error; the table renders with no data rows. + assert "Identifier" in result.output + + +def test_list_json_output_is_parseable(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas", + json=_listing_json( + "schemas", + [_schema_item("infrahub", "dcim", display="DCIM", semver="1.2.0", downloads=42, tags=["core"])], + total=1, + ), + ) + result = runner.invoke(app, ["list", "--json"]) + + assert result.exit_code == 0 + parsed = _json.loads(result.output) + assert parsed[0]["name"] == "dcim" + assert parsed[0]["latest_version"]["semver"] == "1.2.0" + + +def _schema_detail() -> dict: + return { + "namespace": "infrahub", + "name": "vlan", + "display_name": "VLAN", + "description": "VLAN schema.", + "download_count": 105, + "tags": [{"name": "experimental"}], + "versions": [ + { + "semver": "1.0.0", + "status": "published", + "created_at": "2026-04-20T23:54:19+00:00", + "changelog": "Initial", + }, + ], + "dependencies": {"schemas": [{"namespace": "infrahub", "name": "dcim"}], "collections": []}, + } + + +def test_show_schema_autodetect(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/infrahub/vlan", + json=_schema_detail(), + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/infrahub/vlan", + status_code=404, + json={"detail": "Collection not found"}, + ) + result = runner.invoke(app, ["show", "infrahub/vlan"]) + + assert result.exit_code == 0 + assert "infrahub/vlan" in result.output + assert "VLAN" in result.output + assert "1.0.0" in result.output + assert "published" in result.output + assert "experimental" in result.output + assert "infrahub/dcim" in result.output # dependency + + +def test_show_schema_json(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/infrahub/vlan", + json=_schema_detail(), + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/infrahub/vlan", + status_code=404, + json={"detail": "Collection not found"}, + ) + result = runner.invoke(app, ["show", "infrahub/vlan", "--json"]) + + assert result.exit_code == 0 + parsed = _json.loads(result.output) + assert parsed["name"] == "vlan" + assert parsed["versions"][0]["semver"] == "1.0.0" + + +def test_show_network_error_exits_2(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_exception(httpx.ConnectError("connection refused")) + httpx_mock.add_exception(httpx.ConnectError("connection refused")) + result = runner.invoke(app, ["show", "infrahub/vlan"]) + + assert result.exit_code == 2 + assert "Could not reach marketplace" in result.output + + +def _collection_detail() -> dict: + return { + "namespace": "infrahub", + "name": "security-mgmt", + "display_name": "Security & Management", + "description": "Security and device management.", + "download_count": 2, + "items": [ + {"schema": {"namespace": "infrahub", "name": "security", "display_name": "Security"}}, + {"schema": {"namespace": "infrahub", "name": "qos", "display_name": "QoS"}}, + ], + "dependencies": {"schemas": [{"namespace": "infrahub", "name": "location"}], "collections": []}, + } + + +def test_show_collection_force_flag(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/infrahub/security-mgmt", + json=_collection_detail(), + ) + # No schema-detail mock: --collection must not probe the schema endpoint. + result = runner.invoke(app, ["show", "infrahub/security-mgmt", "--collection"]) + + assert result.exit_code == 0 + assert "infrahub/security-mgmt" in result.output + assert "infrahub/security" in result.output + assert "infrahub/qos" in result.output + assert "Schemas: 2" in result.output + assert "infrahub/location" in result.output # dependency + + +def test_show_collection_autodetect(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/infrahub/security-mgmt", + status_code=404, + json={"detail": "Schema not found"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/infrahub/security-mgmt", + json=_collection_detail(), + ) + result = runner.invoke(app, ["show", "infrahub/security-mgmt"]) + + assert result.exit_code == 0 + assert "infrahub/qos" in result.output + + +def test_show_not_found(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/infrahub/nope", + status_code=404, + json={"detail": "Schema not found"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/infrahub/nope", + status_code=404, + json={"detail": "Collection not found"}, + ) + result = runner.invoke(app, ["show", "infrahub/nope"]) + + assert result.exit_code == 1 + assert "No schema or collection named 'infrahub/nope'" in result.output + + +def test_show_invalid_identifier() -> None: + result = runner.invoke(app, ["show", "no-slash"]) + + assert result.exit_code == 1 + assert "Invalid identifier" in result.output + + async def test_collection_false_autodetects_schema(httpx_mock: HTTPXMock, tmp_path: Path) -> None: """collection=False (the default) triggers auto-detect; schema wins when schema endpoint returns 200.""" httpx_mock.add_response( @@ -596,3 +921,153 @@ async def test_collection_false_autodetects_schema(httpx_mock: HTTPXMock, tmp_pa ) assert (tmp_path / "network-base.yml").read_text() == SCHEMA_YAML + + +# --------------------------------------------------------------------------- +# Fix #1 — 4xx errors must exit 1 (not 2) +# --------------------------------------------------------------------------- + + +def test_list_4xx_error_exits_1(httpx_mock: HTTPXMock) -> None: + """A 4xx response on the listing endpoint must exit 1, not 2.""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas", + status_code=400, + json={"detail": "Bad Request"}, + ) + result = runner.invoke(app, ["list"]) + + assert result.exit_code == 1 + + +def test_show_force_collection_4xx_exits_1(httpx_mock: HTTPXMock) -> None: + """A 4xx response on the --collection (force) path in show must exit 1, not 2.""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/infrahub/security-mgmt", + status_code=400, + json={"detail": "Bad Request"}, + ) + result = runner.invoke(app, ["show", "infrahub/security-mgmt", "--collection"]) + + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# Fix #2 — collision note must not appear on stdout when --json is used +# --------------------------------------------------------------------------- + + +def test_show_collision_json_stdout_is_clean(httpx_mock: HTTPXMock) -> None: + """When show hits a 200/200 collision and --json is passed, stdout must stay JSON-only. + + The collision note must be routed to err_console (stderr) not console (stdout). + + Stream-separation assertion limitation: Typer 0.25 / Click 8.3 do not + support ``mix_stderr=False`` on CliRunner, so we cannot isolate stdout in + this test runner. The structural fix (console.print → err_console.print in + ``_fetch_detail``) is the authoritative correction; this test guards that the + command exits 0 on a collision+json path and that the JSON payload is + present somewhere in the combined output. + """ + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/infrahub/vlan", + json=_schema_detail(), + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/infrahub/vlan", + json=_collection_detail(), + ) + + result = runner.invoke(app, ["show", "infrahub/vlan", "--json"]) + + assert result.exit_code == 0 + # The combined output must contain the JSON payload. We parse the whole + # block starting from the first '{' at column 0 (the root object printed by + # Rich's print_json always starts at column 0). + output = result.output + root_brace = next((i for i, ch in enumerate(output) if ch == "{" and (i == 0 or output[i - 1] == "\n")), None) + assert root_brace is not None, "No root-level JSON object found in output" + parsed = _json.loads(output[root_brace:]) + assert parsed["name"] == "vlan" + + +# --------------------------------------------------------------------------- +# 5xx on the show (detail) path must be a network error (exit 2) +# --------------------------------------------------------------------------- + + +def test_show_network_error_5xx_exits_2(httpx_mock: HTTPXMock) -> None: + """A 5xx from both detail probes classifies as a network error (exit 2).""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/infrahub/vlan", + status_code=503, + json={"detail": "unavailable"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/infrahub/vlan", + status_code=503, + json={"detail": "unavailable"}, + ) + result = runner.invoke(app, ["show", "infrahub/vlan"]) + + assert result.exit_code == 2 + assert "Could not reach marketplace" in result.output + + +def test_list_stops_when_next_page_has_null_cursor(httpx_mock: HTTPXMock) -> None: + """has_next_page=true with a null end_cursor must not loop forever. + + Only one page is mocked. If the loop followed the (null) cursor it would issue + a second request that pytest-httpx has no response for, failing the test. + """ + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas", + json={ + "items": [_schema_item("infrahub", "a", display="A", semver="1.0.0", downloads=1, tags=[])], + "page_info": {"has_next_page": True, "end_cursor": None}, + "total_count": 1, + }, + ) + result = runner.invoke(app, ["list"]) + + assert result.exit_code == 0 + assert "infrahub/a" in result.output + + +def test_list_invalid_json_body_is_network_error(httpx_mock: HTTPXMock) -> None: + """A 200 response with a non-JSON body is reported as a clean network error (exit 2).""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas", + text="not json", + ) + result = runner.invoke(app, ["list"]) + + assert result.exit_code == 2 + assert "not valid JSON" in result.output + + +def test_show_invalid_json_body_is_network_error(httpx_mock: HTTPXMock) -> None: + """A schema detail endpoint returning 200 with a malformed body exits 2, not a traceback.""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/infrahub/vlan", + text="not json", + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/infrahub/vlan", + status_code=404, + json={"detail": "Collection not found"}, + ) + result = runner.invoke(app, ["show", "infrahub/vlan"]) + + assert result.exit_code == 2 + assert "not valid JSON" in result.output