From a0d64afbad8fc8e9923f04536cdf2eb1a799bb4c Mon Sep 17 00:00:00 2001 From: Iddo Date: Thu, 2 Jul 2026 15:22:41 +0200 Subject: [PATCH 01/12] Add changelog newsfragment for path traversal shortest_paths_only (#1119) --- changelog/1119.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/1119.added.md diff --git a/changelog/1119.added.md b/changelog/1119.added.md new file mode 100644 index 00000000..68a43e33 --- /dev/null +++ b/changelog/1119.added.md @@ -0,0 +1 @@ +Added a `shortest_paths_only` parameter to `InfrahubClient.traverse_paths()` and its sync equivalent (default `None`, deferring to the server). Set it to `False` to return all loopless paths up to `max_paths` instead of only the shortest one(s); previously path traversal always returned the shortest path(s) because the flag could not be set. `PathTraversalResult` now also parses `truncated_at_depth`, which is set when the search stopped at `max_depth` before exhausting the graph and `None` when it completed within budget. From 8468cc5978298e9b8a609a91c452850cf3351aa2 Mon Sep 17 00:00:00 2001 From: Iddo Date: Thu, 2 Jul 2026 17:10:41 +0200 Subject: [PATCH 02/12] docs(marketplace): add browsing feature design spec --- dev/specs/marketplace-browsing/design.md | 169 +++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 dev/specs/marketplace-browsing/design.md diff --git a/dev/specs/marketplace-browsing/design.md b/dev/specs/marketplace-browsing/design.md new file mode 100644 index 00000000..ea874d30 --- /dev/null +++ b/dev/specs/marketplace-browsing/design.md @@ -0,0 +1,169 @@ +# Marketplace Browsing — Design + +**Date:** 2026-07-02 +**Branch:** `ic-feat-add-marketplace-browsing` +**Status:** Approved for planning + +## Problem + +`infrahubctl marketplace get ` can download a schema or collection, +but only if the user already knows the exact identifier. There is no way to discover +what the marketplace offers. This feature adds discovery ("browsing") commands. + +## Guiding principle + +Every addition is checked against the minimalism ladder (stop at the first that applies): + +1. Does this need to exist? → no: skip it (YAGNI) +2. Already in this codebase? → reuse it, don't rewrite +3. Stdlib does it? → use it +4. Native platform feature? → use it +5. Installed dependency? → use it +6. One line? → one line +7. Only then: the minimum that works + +Concretely: raw dict access (no new models — matches existing `marketplace.py`), Rich +for tables (already used), httpx for HTTP (already used), `json` stdlib for `--json`, +and reuse of every existing helper listed below. No new dependencies. + +## Command surface + +All commands live in `infrahub_sdk/ctl/marketplace.py`, registered on the existing +`AsyncTyper` `app`, alongside `get`. + +| Command | Purpose | +|---|---| +| `marketplace list [--collections] [--limit N] [--json]` | Browse all schemas (default) or collections | +| `marketplace search [--collections] [--limit N] [--json]` | Browse filtered by the API `search=` param | +| `marketplace show [--collection] [--json]` | Full detail of one schema or collection | + +- `--collections` (on `list`/`search`) switches the listing target to collections. +- `--collection` (on `show`) forces the collection endpoint; default auto-detects (mirrors `get`). +- `--limit N` caps total output. +- `--json` emits raw structured JSON to stdout; status/errors go to stderr. +- `--marketplace-url` and `CONFIG_PARAM` follow the existing resolution + (flag → `INFRAHUB_MARKETPLACE_URL` env → config file → `https://marketplace.infrahub.app`). + +## Marketplace API (confirmed against the live service) + +### List / search + +```text +GET {base}/api/v1/schemas # default listing +GET {base}/api/v1/collections # with --collections +``` + +Query params (only these are honoured; others are ignored by the service): + +- `search=` — filters on name / display_name / description +- `limit=` — page size +- `cursor=` — cursor pagination (NOTE: `after=` is ignored; the param is `cursor`) + +Response envelope (both endpoints): + +```json +{ + "items": [ ... ], + "page_info": { "has_next_page": true, "end_cursor": "..." }, + "total_count": 52 +} +``` + +Schema list item fields used: `namespace`, `name`, `display_name`, `download_count`, +`tags[].name`, `latest_version.semver`. +Collection list item fields used: `namespace`, `name`, `display_name`, +`download_count`, `schema_count`. + +### Detail (for `show`) + +```text +GET {base}/api/v1/schemas/{namespace}/{name} # returns versions[], dependencies, ... +GET {base}/api/v1/collections/{namespace}/{name} # returns items[] (members), dependencies, ... +``` + +`show` auto-detects type by probing both detail endpoints (schema wins on a 200/200 +collision, consistent with `get`); `--collection` forces the collection endpoint. + +## Pagination behaviour + +Chosen: **fetch all, `--limit` to cap.** + +- With no `--limit`: a `while` loop follows `page_info.end_cursor` (passing `cursor=`) + until `has_next_page` is false, accumulating all items. Sensible at current scale + (~52 schemas, ~10 collections). +- With `--limit N`: request a single page with `limit=N` (no cursor loop needed). +- The table footer prints `Showing of ` when output is truncated. + +## Reuse vs new code (all in `marketplace.py`) + +**Reuse (no rewrite):** + +- `_make_http_client(sdk_cfg)` — HTTP client honouring SDK proxy/TLS. +- `_parse_identifier(str)` — `namespace/name` → NamedTuple (used by `show`). +- `_host_from(url)` — hostname for error messages. +- `_is_transport_failure(...)` — 5xx / exception detection. +- `_ErrorClass` taxonomy — INVALID_INPUT / NOT_FOUND (exit 1), NETWORK (exit 2). +- Marketplace-URL / config / env resolution and `CONFIG_PARAM`. +- The `@catch_exception(console=console)` + Rich `console` output convention. + +**New (minimal):** + +- `_list_url(base, item_type)` — one-liner mirroring `_schema_url` / `_collection_url`. +- `_detail_url(base, item_type, ident)` — one-liner. +- `_fetch_all(client, url, *, search, limit)` — the one genuinely new helper: the + cursor pagination loop returning `(items, total_count)`. +- Renderers: a Rich table builder for list/search results and a detail renderer for + `show`. Plain functions using the existing `console`. + +No pydantic models: the existing module reads API responses as raw dicts; this feature +matches that (ladder step 2). + +## Output + +**Default (Rich table):** + +- Schemas: `Identifier (ns/name) · Display Name · Version (latest semver) · Downloads · Tags` +- Collections: `Identifier · Display Name · Schemas (count) · Downloads` +- `show`: a detail block (identifier, display name, description, downloads, author, + timestamps) plus a versions table (schema) or members table (collection), and + dependencies when present. +- Footer `Showing of ` when truncated by `--limit`. + +**`--json`:** the raw item list (`list`/`search`) or the raw detail object (`show`) +serialized with `json`, printed to stdout. Status messages and errors go to stderr, +so `--json` output is cleanly pipeable. + +## Error handling + +Reuse the existing taxonomy: + +- Bad `namespace/name` for `show` → INVALID_INPUT (exit 1). +- 404 (no such item, or empty catalog is *not* an error — an empty list prints an + empty table / `[]`) → NOT_FOUND for `show` only (exit 1). +- 5xx / transport failure → NETWORK (exit 2), message points at `--marketplace-url`. + +## Scope notes + +- **CLI-only; no async/sync dual variant.** Like `get`, these hit REST directly and + are not `InfrahubClient` methods. The async/sync dual pattern applies to the client, + not CLI commands. +- **No new dependencies.** + +## Testing + +Extend `tests/unit/ctl/test_marketplace_app.py` (Typer `CliRunner` + `HTTPXMock`): + +- `list` schemas; `list --collections`. +- Multi-page cursor pagination (mock two pages; assert all items and correct `cursor=`). +- `--limit N` caps output and requests a single page. +- `search ` (passes `search=`); search with empty results. +- `show` schema (renders versions); `show` collection (renders members). +- `show` not-found; `show` auto-detect (schema-wins collision) and `--collection` force. +- `--json` output for `list`, `search`, `show` (valid JSON on stdout). +- Network error (5xx → exit 2) and invalid identifier (exit 1). + +## Docs + +- Regenerate `docs/docs/infrahubctl/infrahubctl-marketplace.mdx` via + `uv run invoke docs-generate` (commands are introspected). +- Add a towncrier newsfragment `changelog/.added.md`. From 5b5abd7b2a95e793c04654a5774b41df515bce0a Mon Sep 17 00:00:00 2001 From: Iddo Date: Thu, 2 Jul 2026 17:16:58 +0200 Subject: [PATCH 03/12] docs(marketplace): add browsing implementation plan --- dev/specs/marketplace-browsing/plan.md | 892 +++++++++++++++++++++++++ 1 file changed, 892 insertions(+) create mode 100644 dev/specs/marketplace-browsing/plan.md diff --git a/dev/specs/marketplace-browsing/plan.md b/dev/specs/marketplace-browsing/plan.md new file mode 100644 index 00000000..4a151e36 --- /dev/null +++ b/dev/specs/marketplace-browsing/plan.md @@ -0,0 +1,892 @@ +# Marketplace Browsing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `list`, `search`, and `show` discovery commands to `infrahubctl marketplace` so users can browse the marketplace without knowing an exact identifier. + +**Architecture:** All code lives in `infrahub_sdk/ctl/marketplace.py`, added to the existing `AsyncTyper` `app` (already registered in `cli_commands.py`, so no registration change). New commands reuse the module's existing helpers (`_make_http_client`, `_parse_identifier`, `_host_from`, `_is_transport_failure`, `_fail`, `_ErrorClass`, `SETTINGS`, `CONFIG_PARAM`). Responses are handled as raw dicts (no models), rendered with Rich tables, or emitted as JSON via `console.print_json`. + +**Tech Stack:** Python 3.10-3.13, Typer/AsyncTyper, httpx (async), Rich, pytest + pytest-httpx. + +## Global Constraints + +- Python 3.10-3.13; module already has `from __future__ import annotations`. +- No new dependencies. +- Every command: decorate with `@catch_exception(console=console)`, include `_: str = CONFIG_PARAM` as the final parameter, and use Rich (`console`/`err_console`) — never `print()`. +- Type hints on all function signatures. +- Marketplace URL resolution is always `(marketplace_url or SETTINGS.active.marketplace_url).rstrip("/")`. +- Tests: no `@pytest.mark.asyncio` (auto mode); use the `httpx_mock` fixture; no `unittest.mock`; no issue numbers/URLs in test names; assert concrete values. +- Before each commit: `uv run invoke format lint-code`. +- Commit messages: no AI/Claude attribution. + +## API reference (verified against the live marketplace) + +- List: `GET {base}/api/v1/schemas` or `GET {base}/api/v1/collections`. Params: `search=`, `limit=`, `cursor=`. Response: `{"items": [...], "page_info": {"has_next_page": bool, "end_cursor": str|null}, "total_count": int}`. +- Detail: `GET {base}/api/v1/schemas/{ns}/{name}` (fields incl. `versions[]`, `tags[]`, `dependencies`), `GET {base}/api/v1/collections/{ns}/{name}` (fields incl. `items[]` with member `schema`, `dependencies`). +- Schema list item fields used: `namespace`, `name`, `display_name`, `download_count`, `tags[].name`, `latest_version.semver`. +- Collection list item fields used: `namespace`, `name`, `display_name`, `download_count`, `schema_count`. +- `dependencies` shape: `{"schemas": [{"namespace","name",...}], "collections": [...], "unresolved_kinds": [...], "hidden_count": int}`. +- Collection detail member: `items[i]["schema"]` has `namespace`, `name`, `display_name`. + +--- + +### Task 1: `list` command (schemas & collections, table, pagination, `--limit`, `--json`, network errors) + +Adds the core listing machinery plus the `list` command. `search` (Task 2) reuses `_run_listing`. + +**Files:** +- Modify: `infrahub_sdk/ctl/marketplace.py` +- Test: `tests/unit/ctl/test_marketplace_app.py` + +**Interfaces:** +- Produces: + - `_list_url(base_url: str, item_type: MarketplaceItemType) -> str` + - `_fetch_listing(client: httpx.AsyncClient, base_url: str, item_type: MarketplaceItemType, *, search: str | None, limit: int | None) -> tuple[list[dict[str, Any]], int]` + - `_render_list_table(items: list[dict[str, Any]], item_type: MarketplaceItemType, total_count: int) -> None` + - `_print_json(data: Any) -> None` + - `_run_listing(*, item_type: MarketplaceItemType, search: str | None, limit: int | None, json_output: bool, marketplace_url: str | None) -> None` + - `list` command (function `list_items`) +- Consumes (existing): `_make_http_client`, `_SdkConfig`, `SETTINGS`, `_fail`, `_ErrorClass`, `MarketplaceItemType`, `console`, `err_console`, `CONFIG_PARAM`, `catch_exception`. + +- [ ] **Step 1: Add the Rich Table import** + +At the top of `infrahub_sdk/ctl/marketplace.py`, add below `from rich.console import Console`: + +```python +from rich.table import Table +``` + +- [ ] **Step 2: Write the failing test for listing schemas** + +Add to `tests/unit/ctl/test_marketplace_app.py`: + +```python +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 +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py::test_list_schemas -v` +Expected: FAIL — `list` command does not exist (Typer exits non-zero / "No such command"). + +- [ ] **Step 4: Implement the listing helpers and `list` command** + +Append to `infrahub_sdk/ctl/marketplace.py` (after `_collection_url`, before `_is_transport_failure` is fine; placement is not critical, but keep helpers above the commands): + +```python +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 = resp.json() + items.extend(payload.get("items", [])) + total_count = payload.get("total_count", len(items)) + page_info = payload.get("page_info") or {} + if limit is not None or not page_info.get("has_next_page"): + break + params = {**params, "cursor": page_info.get("end_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: + 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) +``` + +Add the command near the other `@app.command()` definitions (e.g. above `get`): + +```python +@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, + ) +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py::test_list_schemas -v` +Expected: PASS + +- [ ] **Step 6: Write the remaining Task 1 tests (collections, pagination, --limit)** + +Add to the test file: + +```python +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 +``` + +- [ ] **Step 7: Run the full Task 1 test set** + +Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py -k "list_" -v` +Expected: all PASS + +- [ ] **Step 8: Format, lint, commit** + +```bash +uv run invoke format lint-code +git add infrahub_sdk/ctl/marketplace.py tests/unit/ctl/test_marketplace_app.py +git commit -m "feat(ctl): add marketplace list command" +``` + +--- + +### Task 2: `search` command + +Thin command over `_run_listing` with the `search=` term. + +**Files:** +- Modify: `infrahub_sdk/ctl/marketplace.py` +- Test: `tests/unit/ctl/test_marketplace_app.py` + +**Interfaces:** +- Consumes: `_run_listing` (Task 1), `_listing_json`/`_schema_item` test helpers (Task 1). +- Produces: `search` command (function `search`). + +- [ ] **Step 1: Write the failing test for search** + +```python +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 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py -k "search" -v` +Expected: FAIL — `search` command does not exist. + +- [ ] **Step 3: Implement the `search` command** + +Add near the other commands in `marketplace.py`: + +```python +@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, + ) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py -k "search" -v` +Expected: PASS + +- [ ] **Step 5: Write the `--json` test (covers list & search JSON path)** + +```python +import json as _json + + +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" +``` + +- [ ] **Step 6: Run the JSON test** + +Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py::test_list_json_output_is_parseable -v` +Expected: PASS (no code change needed — `--json` was implemented in Task 1) + +- [ ] **Step 7: Format, lint, commit** + +```bash +uv run invoke format lint-code +git add infrahub_sdk/ctl/marketplace.py tests/unit/ctl/test_marketplace_app.py +git commit -m "feat(ctl): add marketplace search command" +``` + +--- + +### Task 3: `show` command — schema (auto-detect, versions, tags, dependencies, `--json`) + +**Files:** +- Modify: `infrahub_sdk/ctl/marketplace.py` +- Test: `tests/unit/ctl/test_marketplace_app.py` + +**Interfaces:** +- Produces: + - `_detail_url(base_url: str, item_type: MarketplaceItemType, namespace: str, name: str) -> str` + - `_fetch_detail(client: httpx.AsyncClient, base_url: str, namespace: str, name: str, *, force_collection: bool) -> tuple[MarketplaceItemType, dict[str, Any]]` + - `_render_detail(detail: dict[str, Any], item_type: MarketplaceItemType) -> None` + - `show` command (function `show`) +- Consumes (existing): `_parse_identifier`, `asyncio`, `_is_transport_failure`, `_host_from`, `_fail`, `_ErrorClass`, `_print_json`, `console`, plus the `--collection` flag convention from `get`. + +- [ ] **Step 1: Write the failing test for `show` on a schema** + +```python +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 +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py::test_show_schema_autodetect -v` +Expected: FAIL — `show` command does not exist. + +- [ ] **Step 3: Implement `_detail_url`, `_fetch_detail`, `_render_detail`, and `show`** + +Add the helpers near the other helpers in `marketplace.py`: + +```python +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", resp.json() + + 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: + 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", schema_resp.json() + if isinstance(collection_resp, httpx.Response) and collection_resp.status_code == 200: + return "collection", collection_resp.json() + + 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}") +``` + +Add the command: + +```python +@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: + 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) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py::test_show_schema_autodetect -v` +Expected: PASS + +- [ ] **Step 5: Write the schema `--json` and network-error tests** + +```python +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 +``` + +- [ ] **Step 6: Run the schema `show` tests** + +Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py -k "show_schema or show_network" -v` +Expected: PASS + +- [ ] **Step 7: Format, lint, commit** + +```bash +uv run invoke format lint-code +git add infrahub_sdk/ctl/marketplace.py tests/unit/ctl/test_marketplace_app.py +git commit -m "feat(ctl): add marketplace show command for schemas" +``` + +--- + +### Task 4: `show` collection (members, `--collection` force, not-found) + +**Files:** +- Modify: `tests/unit/ctl/test_marketplace_app.py` (behaviour already implemented in Task 3; this task proves the collection path and not-found handling) + +**Interfaces:** +- Consumes: `show` command, `_fetch_detail`, `_render_detail` (Task 3). + +- [ ] **Step 1: Write the collection `show` tests** + +```python +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 +``` + +- [ ] **Step 2: Run the collection `show` tests** + +Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py -k "show_collection or show_not_found or show_invalid" -v` +Expected: PASS (no source change — Task 3 implemented this) + +- [ ] **Step 3: Run the entire marketplace test module** + +Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py -v` +Expected: all PASS (new browsing tests plus the pre-existing `get` tests) + +- [ ] **Step 4: Format, lint, commit** + +```bash +uv run invoke format lint-code +git add tests/unit/ctl/test_marketplace_app.py +git commit -m "test(ctl): cover marketplace show for collections and errors" +``` + +--- + +### Task 5: Docs regeneration and changelog + +**Files:** +- Modify (generated): `docs/docs/infrahubctl/infrahubctl-marketplace.mdx` +- Create: `changelog/.added.md` + +- [ ] **Step 1: Regenerate CLI docs** + +Run: `uv run invoke docs-generate` + +- [ ] **Step 2: Verify docs are in sync** + +Run: `uv run invoke docs-validate` +Expected: passes (no diff between generated and committed docs). If it reports a diff, the generation step in Step 1 did not run or was not saved — re-run Step 1. + +- [ ] **Step 3: Confirm the new commands appear in the generated doc** + +Run: `git diff --stat docs/docs/infrahubctl/infrahubctl-marketplace.mdx` +Expected: the file shows additions documenting `list`, `search`, and `show`. + +- [ ] **Step 4: Add a changelog newsfragment** + +Determine the issue number from the tracking issue for this feature. If none exists, ask the user for the issue number before creating the file. Create `changelog/.added.md` with: + +```markdown +Added `infrahubctl marketplace list`, `search`, and `show` commands for browsing schemas and collections on the Infrahub Marketplace. +``` + +- [ ] **Step 5: Lint docs and commit** + +```bash +uv run invoke lint-docs +git add docs/docs/infrahubctl/infrahubctl-marketplace.mdx changelog/ +git commit -m "docs(marketplace): document list, search, and show commands" +``` + +--- + +## Self-Review + +**Spec coverage:** + +- `list` (schemas + `--collections`) → Task 1. ✓ +- `search ` → Task 2. ✓ +- `show ` (schema + collection, auto-detect, `--collection`) → Tasks 3, 4. ✓ +- Pagination "fetch all, `--limit` to cap" + footer → Task 1 (`_fetch_listing`, `_render_list_table`). ✓ +- Rich table default + `--json` → Tasks 1 (`_render_list_table`, `_print_json`), 3 (`_render_detail`). ✓ +- API mapping (list/detail endpoints, `search`/`limit`/`cursor` params) → Task 1 & 3 helpers. ✓ +- Error taxonomy (INVALID_INPUT/NOT_FOUND exit 1, NETWORK exit 2) → Tasks 1 (`_run_listing`), 3/4 (`_fetch_detail`, `show`). ✓ +- Reuse of existing helpers, raw dicts, no new deps, CLI-only → honoured throughout; no models introduced. ✓ +- Docs regen + changelog → Task 5. ✓ + +**Placeholder scan:** The only intentional placeholder is `changelog/.added.md` (issue number), with an explicit instruction to obtain it in Task 5 Step 4. No other TBD/TODO. + +**Type consistency:** `MarketplaceItemType` ("schema"/"collection") is used consistently by `_list_url`, `_detail_url`, `_fetch_listing`, `_render_list_table`, `_fetch_detail`, `_render_detail`, `_run_listing`. `_fetch_listing` returns `tuple[list[dict], int]` consumed by `_run_listing`. `_fetch_detail` returns `tuple[MarketplaceItemType, dict]` consumed by `show`. Test helper names (`_listing_json`, `_schema_item`, `_collection_item`, `_schema_detail`, `_collection_detail`) are defined before first use (Task 1/3) and reused later. From 06154487ee0e663cfb3ca1a31e9900adbdd30745 Mon Sep 17 00:00:00 2001 From: Iddo Date: Thu, 2 Jul 2026 17:20:52 +0200 Subject: [PATCH 04/12] feat(ctl): add marketplace list command --- infrahub_sdk/ctl/marketplace.py | 123 +++++++++++++++++++++++ tests/unit/ctl/test_marketplace_app.py | 129 +++++++++++++++++++++++++ 2 files changed, 252 insertions(+) diff --git a/infrahub_sdk/ctl/marketplace.py b/infrahub_sdk/ctl/marketplace.py index 1b12e279..560e1be6 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,6 +75,107 @@ 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 = resp.json() + items.extend(payload.get("items", [])) + total_count = payload.get("total_count", len(items)) + page_info = payload.get("page_info") or {} + if limit is not None or not page_info.get("has_next_page"): + break + params = {**params, "cursor": page_info.get("end_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: + 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 @@ -281,6 +383,27 @@ async def _download_collection( status.print(f"\n[green]Collection {namespace}/{name}: {downloaded} schemas downloaded") +@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 get( diff --git a/tests/unit/ctl/test_marketplace_app.py b/tests/unit/ctl/test_marketplace_app.py index 4f302a51..7a28cca2 100644 --- a/tests/unit/ctl/test_marketplace_app.py +++ b/tests/unit/ctl/test_marketplace_app.py @@ -570,6 +570,135 @@ 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 + + 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( From 81aada7bb43e263f818bc9e66e7743741b811fe7 Mon Sep 17 00:00:00 2001 From: Iddo Date: Thu, 2 Jul 2026 17:24:23 +0200 Subject: [PATCH 05/12] feat(ctl): add marketplace search command --- infrahub_sdk/ctl/marketplace.py | 24 +++++++++++++ tests/unit/ctl/test_marketplace_app.py | 48 ++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/infrahub_sdk/ctl/marketplace.py b/infrahub_sdk/ctl/marketplace.py index 560e1be6..82f80292 100644 --- a/infrahub_sdk/ctl/marketplace.py +++ b/infrahub_sdk/ctl/marketplace.py @@ -404,6 +404,30 @@ async def list_items( ) +@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 7a28cca2..c816c05c 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 @@ -699,6 +700,53 @@ def test_list_network_error_exits_2(httpx_mock: HTTPXMock) -> None: 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" + + 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( From edd2d0e10ee7fd568d952f02032b187bc654629f Mon Sep 17 00:00:00 2001 From: Iddo Date: Thu, 2 Jul 2026 17:27:50 +0200 Subject: [PATCH 06/12] feat(ctl): add marketplace show command for schemas --- infrahub_sdk/ctl/marketplace.py | 134 +++++++++++++++++++++++++ tests/unit/ctl/test_marketplace_app.py | 72 +++++++++++++ 2 files changed, 206 insertions(+) diff --git a/infrahub_sdk/ctl/marketplace.py b/infrahub_sdk/ctl/marketplace.py index 82f80292..678b29ce 100644 --- a/infrahub_sdk/ctl/marketplace.py +++ b/infrahub_sdk/ctl/marketplace.py @@ -383,6 +383,140 @@ 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", resp.json() + + 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: + 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", schema_resp.json() + if isinstance(collection_resp, httpx.Response) and collection_resp.status_code == 200: + return "collection", collection_resp.json() + + 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: + 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( diff --git a/tests/unit/ctl/test_marketplace_app.py b/tests/unit/ctl/test_marketplace_app.py index c816c05c..c5758701 100644 --- a/tests/unit/ctl/test_marketplace_app.py +++ b/tests/unit/ctl/test_marketplace_app.py @@ -747,6 +747,78 @@ def test_list_json_output_is_parseable(httpx_mock: HTTPXMock) -> None: 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 + + 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( From f8f4946733daf02fd3c2fecf8fbb848a8cc494dc Mon Sep 17 00:00:00 2001 From: Iddo Date: Thu, 2 Jul 2026 17:31:46 +0200 Subject: [PATCH 07/12] test(ctl): cover marketplace show for collections and errors --- tests/unit/ctl/test_marketplace_app.py | 76 ++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/unit/ctl/test_marketplace_app.py b/tests/unit/ctl/test_marketplace_app.py index c5758701..b0517f33 100644 --- a/tests/unit/ctl/test_marketplace_app.py +++ b/tests/unit/ctl/test_marketplace_app.py @@ -819,6 +819,82 @@ def test_show_network_error_exits_2(httpx_mock: HTTPXMock) -> None: 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( From 0717069a629f42fc7766b6c420cdba1e3eba43ab Mon Sep 17 00:00:00 2001 From: Iddo Date: Thu, 2 Jul 2026 17:38:15 +0200 Subject: [PATCH 08/12] docs(marketplace): document list, search, and show commands --- changelog/+marketplace-browsing.added.md | 1 + .../infrahubctl/infrahubctl-marketplace.mdx | 67 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 changelog/+marketplace-browsing.added.md 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. From 62c149419c30cb081a2ae47d49c899d44ddb6750 Mon Sep 17 00:00:00 2001 From: Iddo Date: Thu, 2 Jul 2026 18:05:04 +0200 Subject: [PATCH 09/12] fix(ctl): classify 4xx marketplace errors as exit 1 and keep show --json clean --- infrahub_sdk/ctl/marketplace.py | 21 +++++- tests/unit/ctl/test_marketplace_app.py | 91 ++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/infrahub_sdk/ctl/marketplace.py b/infrahub_sdk/ctl/marketplace.py index 678b29ce..1c574421 100644 --- a/infrahub_sdk/ctl/marketplace.py +++ b/infrahub_sdk/ctl/marketplace.py @@ -168,6 +168,9 @@ async def _run_listing( 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 listing not found on {_host_from(resolved_url)}: {exc}") detail = str(exc) or type(exc).__name__ _fail(_ErrorClass.NETWORK, f"Marketplace request failed: {detail}") if json_output: @@ -182,6 +185,19 @@ def _is_transport_failure(r: object) -> bool: 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 _mkdir_or_fail(path: Path) -> None: try: path.mkdir(parents=True, exist_ok=True) @@ -415,7 +431,7 @@ async def _fetch_detail( ) if isinstance(schema_resp, httpx.Response) and schema_resp.status_code == 200: if isinstance(collection_resp, httpx.Response) and collection_resp.status_code == 200: - console.print( + 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." ) @@ -509,6 +525,9 @@ async def show( 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 listing not found on {_host_from(resolved_url)}: {exc}") message = str(exc) or type(exc).__name__ _fail(_ErrorClass.NETWORK, f"Marketplace request failed: {message}") if json_output: diff --git a/tests/unit/ctl/test_marketplace_app.py b/tests/unit/ctl/test_marketplace_app.py index b0517f33..9fdf099d 100644 --- a/tests/unit/ctl/test_marketplace_app.py +++ b/tests/unit/ctl/test_marketplace_app.py @@ -921,3 +921,94 @@ 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" + + +# --------------------------------------------------------------------------- +# Regression guard — 5xx must still exit 2 +# --------------------------------------------------------------------------- + + +def test_list_network_error_exits_2_regression(httpx_mock: HTTPXMock) -> None: + """503 responses must still exit 2 (unchanged behaviour).""" + 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 From 7328ddb64300a098fc482178116c91bdf0b9eb3e Mon Sep 17 00:00:00 2001 From: Iddo Date: Thu, 2 Jul 2026 18:21:15 +0200 Subject: [PATCH 10/12] fix(ctl): guard marketplace pagination and JSON parsing, clarify 4xx messages --- infrahub_sdk/ctl/marketplace.py | 33 ++++++++++++---- tests/unit/ctl/test_marketplace_app.py | 53 ++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/infrahub_sdk/ctl/marketplace.py b/infrahub_sdk/ctl/marketplace.py index 1c574421..9618d585 100644 --- a/infrahub_sdk/ctl/marketplace.py +++ b/infrahub_sdk/ctl/marketplace.py @@ -105,13 +105,17 @@ async def _fetch_listing( while True: resp = await client.get(url, params=params) resp.raise_for_status() - payload = resp.json() + 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 {} - if limit is not None or not page_info.get("has_next_page"): + 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": page_info.get("end_cursor")} + params = {**params, "cursor": cursor} return items, total_count @@ -170,7 +174,7 @@ async def _run_listing( except httpx.HTTPError as exc: error_class = _classify_http_error(exc) if error_class is _ErrorClass.NOT_FOUND: - _fail(error_class, f"Marketplace listing not found on {_host_from(resolved_url)}: {exc}") + _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: @@ -198,6 +202,19 @@ def _classify_http_error(exc: httpx.HTTPError) -> _ErrorClass: 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) @@ -422,7 +439,7 @@ async def _fetch_detail( 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", resp.json() + return "collection", _json_or_fail(resp, base_url) schema_resp, collection_resp = await asyncio.gather( client.get(_detail_url(base_url, "schema", namespace, name)), @@ -435,9 +452,9 @@ async def _fetch_detail( 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", schema_resp.json() + return "schema", _json_or_fail(schema_resp, base_url) if isinstance(collection_resp, httpx.Response) and collection_resp.status_code == 200: - return "collection", collection_resp.json() + return "collection", _json_or_fail(collection_resp, base_url) if _is_transport_failure(schema_resp) or _is_transport_failure(collection_resp): _fail( @@ -527,7 +544,7 @@ async def show( except httpx.HTTPError as exc: error_class = _classify_http_error(exc) if error_class is _ErrorClass.NOT_FOUND: - _fail(error_class, f"Marketplace listing not found on {_host_from(resolved_url)}: {exc}") + _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: diff --git a/tests/unit/ctl/test_marketplace_app.py b/tests/unit/ctl/test_marketplace_app.py index 9fdf099d..f7d552f5 100644 --- a/tests/unit/ctl/test_marketplace_app.py +++ b/tests/unit/ctl/test_marketplace_app.py @@ -1012,3 +1012,56 @@ def test_list_network_error_exits_2_regression(httpx_mock: HTTPXMock) -> None: assert result.exit_code == 2 assert "Marketplace request failed" 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 From a639dcf5b504aa18a306487b34bce3f7c9386240 Mon Sep 17 00:00:00 2001 From: Iddo Date: Thu, 2 Jul 2026 18:49:29 +0200 Subject: [PATCH 11/12] docs(marketplace): fix markdownlint issues in design and plan specs --- dev/specs/marketplace-browsing/design.md | 2 +- dev/specs/marketplace-browsing/plan.md | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/dev/specs/marketplace-browsing/design.md b/dev/specs/marketplace-browsing/design.md index ea874d30..5bcaf77a 100644 --- a/dev/specs/marketplace-browsing/design.md +++ b/dev/specs/marketplace-browsing/design.md @@ -32,7 +32,7 @@ All commands live in `infrahub_sdk/ctl/marketplace.py`, registered on the existi `AsyncTyper` `app`, alongside `get`. | Command | Purpose | -|---|---| +| --- | --- | | `marketplace list [--collections] [--limit N] [--json]` | Browse all schemas (default) or collections | | `marketplace search [--collections] [--limit N] [--json]` | Browse filtered by the API `search=` param | | `marketplace show [--collection] [--json]` | Full detail of one schema or collection | diff --git a/dev/specs/marketplace-browsing/plan.md b/dev/specs/marketplace-browsing/plan.md index 4a151e36..a43613d7 100644 --- a/dev/specs/marketplace-browsing/plan.md +++ b/dev/specs/marketplace-browsing/plan.md @@ -35,10 +35,12 @@ Adds the core listing machinery plus the `list` command. `search` (Task 2) reuses `_run_listing`. **Files:** + - Modify: `infrahub_sdk/ctl/marketplace.py` - Test: `tests/unit/ctl/test_marketplace_app.py` **Interfaces:** + - Produces: - `_list_url(base_url: str, item_type: MarketplaceItemType) -> str` - `_fetch_listing(client: httpx.AsyncClient, base_url: str, item_type: MarketplaceItemType, *, search: str | None, limit: int | None) -> tuple[list[dict[str, Any]], int]` @@ -357,10 +359,12 @@ git commit -m "feat(ctl): add marketplace list command" Thin command over `_run_listing` with the `search=` term. **Files:** + - Modify: `infrahub_sdk/ctl/marketplace.py` - Test: `tests/unit/ctl/test_marketplace_app.py` **Interfaces:** + - Consumes: `_run_listing` (Task 1), `_listing_json`/`_schema_item` test helpers (Task 1). - Produces: `search` command (function `search`). @@ -477,10 +481,12 @@ git commit -m "feat(ctl): add marketplace search command" ### Task 3: `show` command — schema (auto-detect, versions, tags, dependencies, `--json`) **Files:** + - Modify: `infrahub_sdk/ctl/marketplace.py` - Test: `tests/unit/ctl/test_marketplace_app.py` **Interfaces:** + - Produces: - `_detail_url(base_url: str, item_type: MarketplaceItemType, namespace: str, name: str) -> str` - `_fetch_detail(client: httpx.AsyncClient, base_url: str, namespace: str, name: str, *, force_collection: bool) -> tuple[MarketplaceItemType, dict[str, Any]]` @@ -731,9 +737,11 @@ git commit -m "feat(ctl): add marketplace show command for schemas" ### Task 4: `show` collection (members, `--collection` force, not-found) **Files:** + - Modify: `tests/unit/ctl/test_marketplace_app.py` (behaviour already implemented in Task 3; this task proves the collection path and not-found handling) **Interfaces:** + - Consumes: `show` command, `_fetch_detail`, `_render_detail` (Task 3). - [ ] **Step 1: Write the collection `show` tests** @@ -838,6 +846,7 @@ git commit -m "test(ctl): cover marketplace show for collections and errors" ### Task 5: Docs regeneration and changelog **Files:** + - Modify (generated): `docs/docs/infrahubctl/infrahubctl-marketplace.mdx` - Create: `changelog/.added.md` From 1aa8b4ad08a3132945f95c11005f124e1655738d Mon Sep 17 00:00:00 2001 From: Iddo Date: Thu, 2 Jul 2026 19:15:28 +0200 Subject: [PATCH 12/12] chore(marketplace): drop internal spec docs and misplaced changelog, dedupe test --- changelog/1119.added.md | 1 - dev/specs/marketplace-browsing/design.md | 169 ----- dev/specs/marketplace-browsing/plan.md | 901 ----------------------- tests/unit/ctl/test_marketplace_app.py | 18 +- 4 files changed, 12 insertions(+), 1077 deletions(-) delete mode 100644 changelog/1119.added.md delete mode 100644 dev/specs/marketplace-browsing/design.md delete mode 100644 dev/specs/marketplace-browsing/plan.md diff --git a/changelog/1119.added.md b/changelog/1119.added.md deleted file mode 100644 index 68a43e33..00000000 --- a/changelog/1119.added.md +++ /dev/null @@ -1 +0,0 @@ -Added a `shortest_paths_only` parameter to `InfrahubClient.traverse_paths()` and its sync equivalent (default `None`, deferring to the server). Set it to `False` to return all loopless paths up to `max_paths` instead of only the shortest one(s); previously path traversal always returned the shortest path(s) because the flag could not be set. `PathTraversalResult` now also parses `truncated_at_depth`, which is set when the search stopped at `max_depth` before exhausting the graph and `None` when it completed within budget. diff --git a/dev/specs/marketplace-browsing/design.md b/dev/specs/marketplace-browsing/design.md deleted file mode 100644 index 5bcaf77a..00000000 --- a/dev/specs/marketplace-browsing/design.md +++ /dev/null @@ -1,169 +0,0 @@ -# Marketplace Browsing — Design - -**Date:** 2026-07-02 -**Branch:** `ic-feat-add-marketplace-browsing` -**Status:** Approved for planning - -## Problem - -`infrahubctl marketplace get ` can download a schema or collection, -but only if the user already knows the exact identifier. There is no way to discover -what the marketplace offers. This feature adds discovery ("browsing") commands. - -## Guiding principle - -Every addition is checked against the minimalism ladder (stop at the first that applies): - -1. Does this need to exist? → no: skip it (YAGNI) -2. Already in this codebase? → reuse it, don't rewrite -3. Stdlib does it? → use it -4. Native platform feature? → use it -5. Installed dependency? → use it -6. One line? → one line -7. Only then: the minimum that works - -Concretely: raw dict access (no new models — matches existing `marketplace.py`), Rich -for tables (already used), httpx for HTTP (already used), `json` stdlib for `--json`, -and reuse of every existing helper listed below. No new dependencies. - -## Command surface - -All commands live in `infrahub_sdk/ctl/marketplace.py`, registered on the existing -`AsyncTyper` `app`, alongside `get`. - -| Command | Purpose | -| --- | --- | -| `marketplace list [--collections] [--limit N] [--json]` | Browse all schemas (default) or collections | -| `marketplace search [--collections] [--limit N] [--json]` | Browse filtered by the API `search=` param | -| `marketplace show [--collection] [--json]` | Full detail of one schema or collection | - -- `--collections` (on `list`/`search`) switches the listing target to collections. -- `--collection` (on `show`) forces the collection endpoint; default auto-detects (mirrors `get`). -- `--limit N` caps total output. -- `--json` emits raw structured JSON to stdout; status/errors go to stderr. -- `--marketplace-url` and `CONFIG_PARAM` follow the existing resolution - (flag → `INFRAHUB_MARKETPLACE_URL` env → config file → `https://marketplace.infrahub.app`). - -## Marketplace API (confirmed against the live service) - -### List / search - -```text -GET {base}/api/v1/schemas # default listing -GET {base}/api/v1/collections # with --collections -``` - -Query params (only these are honoured; others are ignored by the service): - -- `search=` — filters on name / display_name / description -- `limit=` — page size -- `cursor=` — cursor pagination (NOTE: `after=` is ignored; the param is `cursor`) - -Response envelope (both endpoints): - -```json -{ - "items": [ ... ], - "page_info": { "has_next_page": true, "end_cursor": "..." }, - "total_count": 52 -} -``` - -Schema list item fields used: `namespace`, `name`, `display_name`, `download_count`, -`tags[].name`, `latest_version.semver`. -Collection list item fields used: `namespace`, `name`, `display_name`, -`download_count`, `schema_count`. - -### Detail (for `show`) - -```text -GET {base}/api/v1/schemas/{namespace}/{name} # returns versions[], dependencies, ... -GET {base}/api/v1/collections/{namespace}/{name} # returns items[] (members), dependencies, ... -``` - -`show` auto-detects type by probing both detail endpoints (schema wins on a 200/200 -collision, consistent with `get`); `--collection` forces the collection endpoint. - -## Pagination behaviour - -Chosen: **fetch all, `--limit` to cap.** - -- With no `--limit`: a `while` loop follows `page_info.end_cursor` (passing `cursor=`) - until `has_next_page` is false, accumulating all items. Sensible at current scale - (~52 schemas, ~10 collections). -- With `--limit N`: request a single page with `limit=N` (no cursor loop needed). -- The table footer prints `Showing of ` when output is truncated. - -## Reuse vs new code (all in `marketplace.py`) - -**Reuse (no rewrite):** - -- `_make_http_client(sdk_cfg)` — HTTP client honouring SDK proxy/TLS. -- `_parse_identifier(str)` — `namespace/name` → NamedTuple (used by `show`). -- `_host_from(url)` — hostname for error messages. -- `_is_transport_failure(...)` — 5xx / exception detection. -- `_ErrorClass` taxonomy — INVALID_INPUT / NOT_FOUND (exit 1), NETWORK (exit 2). -- Marketplace-URL / config / env resolution and `CONFIG_PARAM`. -- The `@catch_exception(console=console)` + Rich `console` output convention. - -**New (minimal):** - -- `_list_url(base, item_type)` — one-liner mirroring `_schema_url` / `_collection_url`. -- `_detail_url(base, item_type, ident)` — one-liner. -- `_fetch_all(client, url, *, search, limit)` — the one genuinely new helper: the - cursor pagination loop returning `(items, total_count)`. -- Renderers: a Rich table builder for list/search results and a detail renderer for - `show`. Plain functions using the existing `console`. - -No pydantic models: the existing module reads API responses as raw dicts; this feature -matches that (ladder step 2). - -## Output - -**Default (Rich table):** - -- Schemas: `Identifier (ns/name) · Display Name · Version (latest semver) · Downloads · Tags` -- Collections: `Identifier · Display Name · Schemas (count) · Downloads` -- `show`: a detail block (identifier, display name, description, downloads, author, - timestamps) plus a versions table (schema) or members table (collection), and - dependencies when present. -- Footer `Showing of ` when truncated by `--limit`. - -**`--json`:** the raw item list (`list`/`search`) or the raw detail object (`show`) -serialized with `json`, printed to stdout. Status messages and errors go to stderr, -so `--json` output is cleanly pipeable. - -## Error handling - -Reuse the existing taxonomy: - -- Bad `namespace/name` for `show` → INVALID_INPUT (exit 1). -- 404 (no such item, or empty catalog is *not* an error — an empty list prints an - empty table / `[]`) → NOT_FOUND for `show` only (exit 1). -- 5xx / transport failure → NETWORK (exit 2), message points at `--marketplace-url`. - -## Scope notes - -- **CLI-only; no async/sync dual variant.** Like `get`, these hit REST directly and - are not `InfrahubClient` methods. The async/sync dual pattern applies to the client, - not CLI commands. -- **No new dependencies.** - -## Testing - -Extend `tests/unit/ctl/test_marketplace_app.py` (Typer `CliRunner` + `HTTPXMock`): - -- `list` schemas; `list --collections`. -- Multi-page cursor pagination (mock two pages; assert all items and correct `cursor=`). -- `--limit N` caps output and requests a single page. -- `search ` (passes `search=`); search with empty results. -- `show` schema (renders versions); `show` collection (renders members). -- `show` not-found; `show` auto-detect (schema-wins collision) and `--collection` force. -- `--json` output for `list`, `search`, `show` (valid JSON on stdout). -- Network error (5xx → exit 2) and invalid identifier (exit 1). - -## Docs - -- Regenerate `docs/docs/infrahubctl/infrahubctl-marketplace.mdx` via - `uv run invoke docs-generate` (commands are introspected). -- Add a towncrier newsfragment `changelog/.added.md`. diff --git a/dev/specs/marketplace-browsing/plan.md b/dev/specs/marketplace-browsing/plan.md deleted file mode 100644 index a43613d7..00000000 --- a/dev/specs/marketplace-browsing/plan.md +++ /dev/null @@ -1,901 +0,0 @@ -# Marketplace Browsing Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add `list`, `search`, and `show` discovery commands to `infrahubctl marketplace` so users can browse the marketplace without knowing an exact identifier. - -**Architecture:** All code lives in `infrahub_sdk/ctl/marketplace.py`, added to the existing `AsyncTyper` `app` (already registered in `cli_commands.py`, so no registration change). New commands reuse the module's existing helpers (`_make_http_client`, `_parse_identifier`, `_host_from`, `_is_transport_failure`, `_fail`, `_ErrorClass`, `SETTINGS`, `CONFIG_PARAM`). Responses are handled as raw dicts (no models), rendered with Rich tables, or emitted as JSON via `console.print_json`. - -**Tech Stack:** Python 3.10-3.13, Typer/AsyncTyper, httpx (async), Rich, pytest + pytest-httpx. - -## Global Constraints - -- Python 3.10-3.13; module already has `from __future__ import annotations`. -- No new dependencies. -- Every command: decorate with `@catch_exception(console=console)`, include `_: str = CONFIG_PARAM` as the final parameter, and use Rich (`console`/`err_console`) — never `print()`. -- Type hints on all function signatures. -- Marketplace URL resolution is always `(marketplace_url or SETTINGS.active.marketplace_url).rstrip("/")`. -- Tests: no `@pytest.mark.asyncio` (auto mode); use the `httpx_mock` fixture; no `unittest.mock`; no issue numbers/URLs in test names; assert concrete values. -- Before each commit: `uv run invoke format lint-code`. -- Commit messages: no AI/Claude attribution. - -## API reference (verified against the live marketplace) - -- List: `GET {base}/api/v1/schemas` or `GET {base}/api/v1/collections`. Params: `search=`, `limit=`, `cursor=`. Response: `{"items": [...], "page_info": {"has_next_page": bool, "end_cursor": str|null}, "total_count": int}`. -- Detail: `GET {base}/api/v1/schemas/{ns}/{name}` (fields incl. `versions[]`, `tags[]`, `dependencies`), `GET {base}/api/v1/collections/{ns}/{name}` (fields incl. `items[]` with member `schema`, `dependencies`). -- Schema list item fields used: `namespace`, `name`, `display_name`, `download_count`, `tags[].name`, `latest_version.semver`. -- Collection list item fields used: `namespace`, `name`, `display_name`, `download_count`, `schema_count`. -- `dependencies` shape: `{"schemas": [{"namespace","name",...}], "collections": [...], "unresolved_kinds": [...], "hidden_count": int}`. -- Collection detail member: `items[i]["schema"]` has `namespace`, `name`, `display_name`. - ---- - -### Task 1: `list` command (schemas & collections, table, pagination, `--limit`, `--json`, network errors) - -Adds the core listing machinery plus the `list` command. `search` (Task 2) reuses `_run_listing`. - -**Files:** - -- Modify: `infrahub_sdk/ctl/marketplace.py` -- Test: `tests/unit/ctl/test_marketplace_app.py` - -**Interfaces:** - -- Produces: - - `_list_url(base_url: str, item_type: MarketplaceItemType) -> str` - - `_fetch_listing(client: httpx.AsyncClient, base_url: str, item_type: MarketplaceItemType, *, search: str | None, limit: int | None) -> tuple[list[dict[str, Any]], int]` - - `_render_list_table(items: list[dict[str, Any]], item_type: MarketplaceItemType, total_count: int) -> None` - - `_print_json(data: Any) -> None` - - `_run_listing(*, item_type: MarketplaceItemType, search: str | None, limit: int | None, json_output: bool, marketplace_url: str | None) -> None` - - `list` command (function `list_items`) -- Consumes (existing): `_make_http_client`, `_SdkConfig`, `SETTINGS`, `_fail`, `_ErrorClass`, `MarketplaceItemType`, `console`, `err_console`, `CONFIG_PARAM`, `catch_exception`. - -- [ ] **Step 1: Add the Rich Table import** - -At the top of `infrahub_sdk/ctl/marketplace.py`, add below `from rich.console import Console`: - -```python -from rich.table import Table -``` - -- [ ] **Step 2: Write the failing test for listing schemas** - -Add to `tests/unit/ctl/test_marketplace_app.py`: - -```python -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 -``` - -- [ ] **Step 3: Run the test to verify it fails** - -Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py::test_list_schemas -v` -Expected: FAIL — `list` command does not exist (Typer exits non-zero / "No such command"). - -- [ ] **Step 4: Implement the listing helpers and `list` command** - -Append to `infrahub_sdk/ctl/marketplace.py` (after `_collection_url`, before `_is_transport_failure` is fine; placement is not critical, but keep helpers above the commands): - -```python -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 = resp.json() - items.extend(payload.get("items", [])) - total_count = payload.get("total_count", len(items)) - page_info = payload.get("page_info") or {} - if limit is not None or not page_info.get("has_next_page"): - break - params = {**params, "cursor": page_info.get("end_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: - 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) -``` - -Add the command near the other `@app.command()` definitions (e.g. above `get`): - -```python -@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, - ) -``` - -- [ ] **Step 5: Run the test to verify it passes** - -Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py::test_list_schemas -v` -Expected: PASS - -- [ ] **Step 6: Write the remaining Task 1 tests (collections, pagination, --limit)** - -Add to the test file: - -```python -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 -``` - -- [ ] **Step 7: Run the full Task 1 test set** - -Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py -k "list_" -v` -Expected: all PASS - -- [ ] **Step 8: Format, lint, commit** - -```bash -uv run invoke format lint-code -git add infrahub_sdk/ctl/marketplace.py tests/unit/ctl/test_marketplace_app.py -git commit -m "feat(ctl): add marketplace list command" -``` - ---- - -### Task 2: `search` command - -Thin command over `_run_listing` with the `search=` term. - -**Files:** - -- Modify: `infrahub_sdk/ctl/marketplace.py` -- Test: `tests/unit/ctl/test_marketplace_app.py` - -**Interfaces:** - -- Consumes: `_run_listing` (Task 1), `_listing_json`/`_schema_item` test helpers (Task 1). -- Produces: `search` command (function `search`). - -- [ ] **Step 1: Write the failing test for search** - -```python -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 -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py -k "search" -v` -Expected: FAIL — `search` command does not exist. - -- [ ] **Step 3: Implement the `search` command** - -Add near the other commands in `marketplace.py`: - -```python -@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, - ) -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py -k "search" -v` -Expected: PASS - -- [ ] **Step 5: Write the `--json` test (covers list & search JSON path)** - -```python -import json as _json - - -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" -``` - -- [ ] **Step 6: Run the JSON test** - -Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py::test_list_json_output_is_parseable -v` -Expected: PASS (no code change needed — `--json` was implemented in Task 1) - -- [ ] **Step 7: Format, lint, commit** - -```bash -uv run invoke format lint-code -git add infrahub_sdk/ctl/marketplace.py tests/unit/ctl/test_marketplace_app.py -git commit -m "feat(ctl): add marketplace search command" -``` - ---- - -### Task 3: `show` command — schema (auto-detect, versions, tags, dependencies, `--json`) - -**Files:** - -- Modify: `infrahub_sdk/ctl/marketplace.py` -- Test: `tests/unit/ctl/test_marketplace_app.py` - -**Interfaces:** - -- Produces: - - `_detail_url(base_url: str, item_type: MarketplaceItemType, namespace: str, name: str) -> str` - - `_fetch_detail(client: httpx.AsyncClient, base_url: str, namespace: str, name: str, *, force_collection: bool) -> tuple[MarketplaceItemType, dict[str, Any]]` - - `_render_detail(detail: dict[str, Any], item_type: MarketplaceItemType) -> None` - - `show` command (function `show`) -- Consumes (existing): `_parse_identifier`, `asyncio`, `_is_transport_failure`, `_host_from`, `_fail`, `_ErrorClass`, `_print_json`, `console`, plus the `--collection` flag convention from `get`. - -- [ ] **Step 1: Write the failing test for `show` on a schema** - -```python -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 -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py::test_show_schema_autodetect -v` -Expected: FAIL — `show` command does not exist. - -- [ ] **Step 3: Implement `_detail_url`, `_fetch_detail`, `_render_detail`, and `show`** - -Add the helpers near the other helpers in `marketplace.py`: - -```python -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", resp.json() - - 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: - 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", schema_resp.json() - if isinstance(collection_resp, httpx.Response) and collection_resp.status_code == 200: - return "collection", collection_resp.json() - - 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}") -``` - -Add the command: - -```python -@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: - 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) -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py::test_show_schema_autodetect -v` -Expected: PASS - -- [ ] **Step 5: Write the schema `--json` and network-error tests** - -```python -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 -``` - -- [ ] **Step 6: Run the schema `show` tests** - -Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py -k "show_schema or show_network" -v` -Expected: PASS - -- [ ] **Step 7: Format, lint, commit** - -```bash -uv run invoke format lint-code -git add infrahub_sdk/ctl/marketplace.py tests/unit/ctl/test_marketplace_app.py -git commit -m "feat(ctl): add marketplace show command for schemas" -``` - ---- - -### Task 4: `show` collection (members, `--collection` force, not-found) - -**Files:** - -- Modify: `tests/unit/ctl/test_marketplace_app.py` (behaviour already implemented in Task 3; this task proves the collection path and not-found handling) - -**Interfaces:** - -- Consumes: `show` command, `_fetch_detail`, `_render_detail` (Task 3). - -- [ ] **Step 1: Write the collection `show` tests** - -```python -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 -``` - -- [ ] **Step 2: Run the collection `show` tests** - -Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py -k "show_collection or show_not_found or show_invalid" -v` -Expected: PASS (no source change — Task 3 implemented this) - -- [ ] **Step 3: Run the entire marketplace test module** - -Run: `uv run pytest tests/unit/ctl/test_marketplace_app.py -v` -Expected: all PASS (new browsing tests plus the pre-existing `get` tests) - -- [ ] **Step 4: Format, lint, commit** - -```bash -uv run invoke format lint-code -git add tests/unit/ctl/test_marketplace_app.py -git commit -m "test(ctl): cover marketplace show for collections and errors" -``` - ---- - -### Task 5: Docs regeneration and changelog - -**Files:** - -- Modify (generated): `docs/docs/infrahubctl/infrahubctl-marketplace.mdx` -- Create: `changelog/.added.md` - -- [ ] **Step 1: Regenerate CLI docs** - -Run: `uv run invoke docs-generate` - -- [ ] **Step 2: Verify docs are in sync** - -Run: `uv run invoke docs-validate` -Expected: passes (no diff between generated and committed docs). If it reports a diff, the generation step in Step 1 did not run or was not saved — re-run Step 1. - -- [ ] **Step 3: Confirm the new commands appear in the generated doc** - -Run: `git diff --stat docs/docs/infrahubctl/infrahubctl-marketplace.mdx` -Expected: the file shows additions documenting `list`, `search`, and `show`. - -- [ ] **Step 4: Add a changelog newsfragment** - -Determine the issue number from the tracking issue for this feature. If none exists, ask the user for the issue number before creating the file. Create `changelog/.added.md` with: - -```markdown -Added `infrahubctl marketplace list`, `search`, and `show` commands for browsing schemas and collections on the Infrahub Marketplace. -``` - -- [ ] **Step 5: Lint docs and commit** - -```bash -uv run invoke lint-docs -git add docs/docs/infrahubctl/infrahubctl-marketplace.mdx changelog/ -git commit -m "docs(marketplace): document list, search, and show commands" -``` - ---- - -## Self-Review - -**Spec coverage:** - -- `list` (schemas + `--collections`) → Task 1. ✓ -- `search ` → Task 2. ✓ -- `show ` (schema + collection, auto-detect, `--collection`) → Tasks 3, 4. ✓ -- Pagination "fetch all, `--limit` to cap" + footer → Task 1 (`_fetch_listing`, `_render_list_table`). ✓ -- Rich table default + `--json` → Tasks 1 (`_render_list_table`, `_print_json`), 3 (`_render_detail`). ✓ -- API mapping (list/detail endpoints, `search`/`limit`/`cursor` params) → Task 1 & 3 helpers. ✓ -- Error taxonomy (INVALID_INPUT/NOT_FOUND exit 1, NETWORK exit 2) → Tasks 1 (`_run_listing`), 3/4 (`_fetch_detail`, `show`). ✓ -- Reuse of existing helpers, raw dicts, no new deps, CLI-only → honoured throughout; no models introduced. ✓ -- Docs regen + changelog → Task 5. ✓ - -**Placeholder scan:** The only intentional placeholder is `changelog/.added.md` (issue number), with an explicit instruction to obtain it in Task 5 Step 4. No other TBD/TODO. - -**Type consistency:** `MarketplaceItemType` ("schema"/"collection") is used consistently by `_list_url`, `_detail_url`, `_fetch_listing`, `_render_list_table`, `_fetch_detail`, `_render_detail`, `_run_listing`. `_fetch_listing` returns `tuple[list[dict], int]` consumed by `_run_listing`. `_fetch_detail` returns `tuple[MarketplaceItemType, dict]` consumed by `show`. Test helper names (`_listing_json`, `_schema_item`, `_collection_item`, `_schema_detail`, `_collection_detail`) are defined before first use (Task 1/3) and reused later. diff --git a/tests/unit/ctl/test_marketplace_app.py b/tests/unit/ctl/test_marketplace_app.py index f7d552f5..b2f01b97 100644 --- a/tests/unit/ctl/test_marketplace_app.py +++ b/tests/unit/ctl/test_marketplace_app.py @@ -996,22 +996,28 @@ def test_show_collision_json_stdout_is_clean(httpx_mock: HTTPXMock) -> None: # --------------------------------------------------------------------------- -# Regression guard — 5xx must still exit 2 +# 5xx on the show (detail) path must be a network error (exit 2) # --------------------------------------------------------------------------- -def test_list_network_error_exits_2_regression(httpx_mock: HTTPXMock) -> None: - """503 responses must still exit 2 (unchanged behaviour).""" +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", + url="https://marketplace.infrahub.app/api/v1/schemas/infrahub/vlan", status_code=503, json={"detail": "unavailable"}, ) - result = runner.invoke(app, ["list"]) + 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 "Marketplace request failed" in result.output + assert "Could not reach marketplace" in result.output def test_list_stops_when_next_page_has_null_cursor(httpx_mock: HTTPXMock) -> None: