diff --git a/docs/docs/infrahubctl/infrahubctl-marketplace.mdx b/docs/docs/infrahubctl/infrahubctl-marketplace.mdx new file mode 100644 index 00000000..be52da4c --- /dev/null +++ b/docs/docs/infrahubctl/infrahubctl-marketplace.mdx @@ -0,0 +1,45 @@ +# `infrahubctl marketplace` + +Browse and download schemas from the Infrahub Marketplace. + +**Usage**: + +```console +$ infrahubctl marketplace [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--install-completion`: Install completion for the current shell. +* `--show-completion`: Show completion for the current shell, to copy it or customize the installation. +* `--help`: Show this message and exit. + +**Commands**: + +* `download`: Download a schema or collection from the... + +## `infrahubctl marketplace download` + +Download a schema or collection from the Infrahub Marketplace. + +By default, auto-detects whether `namespace/name` is a schema or a collection. +Pass --collection to force the collection path when an identifier exists as both. + +**Usage**: + +```console +$ infrahubctl marketplace download [OPTIONS] IDENTIFIER +``` + +**Arguments**: + +* `IDENTIFIER`: Schema or collection identifier in namespace/name format [required] + +**Options**: + +* `-v, --version TEXT`: Specific schema version, for example 1.2.0. Default: latest published. +* `-c, --collection`: Force collection download. Default: auto-detect whether the identifier is a schema or collection. +* `-o, --output-dir PATH`: Directory to save downloaded files. [default: schemas] +* `--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. diff --git a/infrahub_sdk/ctl/cli_commands.py b/infrahub_sdk/ctl/cli_commands.py index d7a636ed..8459497f 100644 --- a/infrahub_sdk/ctl/cli_commands.py +++ b/infrahub_sdk/ctl/cli_commands.py @@ -28,6 +28,7 @@ from ..ctl.exceptions import QueryNotFoundError from ..ctl.generator import run as run_generator from ..ctl.graphql import app as graphql_app +from ..ctl.marketplace import app as marketplace_app from ..ctl.menu import app as menu_app from ..ctl.object import app as object_app from ..ctl.render import list_jinja2_transforms, print_template_errors @@ -68,6 +69,7 @@ app.add_typer(object_app, name="object") app.add_typer(graphql_app, name="graphql") app.add_typer(task_app, name="task") +app.add_typer(marketplace_app, name="marketplace") app.command(name="dump")(dump) app.command(name="load")(load) diff --git a/infrahub_sdk/ctl/config.py b/infrahub_sdk/ctl/config.py index a5b522b2..cc1404c0 100644 --- a/infrahub_sdk/ctl/config.py +++ b/infrahub_sdk/ctl/config.py @@ -27,6 +27,7 @@ class Settings(BaseSettings): server_address: str = Field(default="http://localhost:8000", validation_alias="infrahub_address") api_token: str | None = Field(default=None) default_branch: str = Field(default="main") + marketplace_url: str = Field(default="https://marketplace.infrahub.app") @field_validator("server_address") @classmethod diff --git a/infrahub_sdk/ctl/marketplace.py b/infrahub_sdk/ctl/marketplace.py new file mode 100644 index 00000000..ef58f817 --- /dev/null +++ b/infrahub_sdk/ctl/marketplace.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import asyncio +from contextlib import suppress +from pathlib import Path +from typing import Literal +from urllib.parse import urlparse + +import httpx +import typer +from rich.console import Console + +from ..async_typer import AsyncTyper +from ..ctl import config +from ..ctl.parameters import CONFIG_PARAM +from ..ctl.utils import catch_exception + +app = AsyncTyper() +console = Console() + +MarketplaceItemType = Literal["schema", "collection"] +ErrorClass = Literal["invalid-input", "not-found", "network"] + +_ERROR_EXIT_CODES: dict[ErrorClass, int] = { + "invalid-input": 1, + "not-found": 1, + "network": 2, +} + + +def _fail(error_class: ErrorClass, message: str) -> typer.Exit: + """Print an error line and return a typer.Exit with the mapped code. Intended to be raised by the caller.""" + console.print(f"[red]{message}") + return typer.Exit(_ERROR_EXIT_CODES[error_class]) + + +@app.callback() +def callback() -> None: + """Browse and download schemas from the Infrahub Marketplace.""" + + +def _parse_identifier(identifier: str) -> tuple[str, str]: + """Validate and split a 'namespace/name' identifier.""" + parts = identifier.split("/") + if len(parts) != 2 or not all(parts): + raise _fail("invalid-input", f"Invalid identifier '{identifier}'. Expected format: namespace/name") + return parts[0], parts[1] + + +def _host_from(base_url: str) -> str: + return urlparse(base_url).netloc or base_url + + +def _mkdir_or_fail(path: Path) -> None: + try: + path.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise _fail("invalid-input", f"Cannot write to '{path}': {exc}") from exc + + +async def _detect_item_type( + client: httpx.AsyncClient, + base_url: str, + namespace: str, + name: str, +) -> tuple[MarketplaceItemType, httpx.Response]: + """Probe schema and collection endpoints in parallel. Schema wins on 200-200. + + Returns the resolved type and the winning 200 response so the caller can reuse + it instead of re-fetching the same URL. + """ + schema_url = f"{base_url}/api/v1/schemas/{namespace}/{name}/download" + collection_url = f"{base_url}/api/v1/collections/{namespace}/{name}/download" + + schema_resp, collection_resp = await asyncio.gather( + client.get(schema_url), + client.get(collection_url), + return_exceptions=True, + ) + + schema_ok = isinstance(schema_resp, httpx.Response) and schema_resp.status_code == 200 + collection_ok = isinstance(collection_resp, httpx.Response) and collection_resp.status_code == 200 + + if schema_ok: + if collection_ok: + 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 # type: ignore[return-value] + if collection_ok: + return "collection", collection_resp # type: ignore[return-value] + + def is_transport_failure(r: object) -> bool: + if isinstance(r, Exception): + return True + return isinstance(r, httpx.Response) and r.status_code >= 500 + + if is_transport_failure(schema_resp) and is_transport_failure(collection_resp): + raise _fail( + "network", + f"Could not reach marketplace at {base_url}. Check your connection or --marketplace-url.", + ) + + raise _fail( + "not-found", + f"No schema or collection named '{namespace}/{name}' found on {_host_from(base_url)}.", + ) + + +async def _download_schema( + client: httpx.AsyncClient, + base_url: str, + namespace: str, + name: str, + version: str | None, + output_dir: Path, + prefetched: httpx.Response | None = None, +) -> None: + """Download a single schema and write it to disk. + + When ``prefetched`` is supplied and ``version`` is None, reuses the response + instead of re-fetching the unversioned download URL. + """ + if prefetched is not None and version is None: + resp = prefetched + else: + if version: + url = f"{base_url}/api/v1/schemas/{namespace}/{name}/versions/{version}/download" + else: + url = f"{base_url}/api/v1/schemas/{namespace}/{name}/download" + resp = await client.get(url) + + if resp.status_code == 404: + if version is not None and prefetched is not None: + raise _fail( + "not-found", + f"Schema '{namespace}/{name}' has no published version '{version}'. " + "Run without --version for the latest.", + ) + detail = "not found" + with suppress(Exception): + detail = resp.json().get("detail", detail) + raise _fail("not-found", f"Error: {detail}") + resp.raise_for_status() + + resolved_version = version or resp.headers.get("x-schema-version", "latest") + filename = f"{name}.yml" + _mkdir_or_fail(output_dir) + file_path = output_dir / filename + file_path.write_text(resp.text, encoding="utf-8") + + console.print(f"[green]Downloaded schema {namespace}/{name} v{resolved_version} -> {file_path}") + + +async def _download_collection( + client: httpx.AsyncClient, + base_url: str, + namespace: str, + name: str, + output_dir: Path, + prefetched: httpx.Response | None = None, +) -> None: + """Download all schemas in a collection to disk. + + When ``prefetched`` is supplied, reuses the response instead of re-fetching + the collection download URL. + """ + if prefetched is not None: + resp = prefetched + else: + url = f"{base_url}/api/v1/collections/{namespace}/{name}/download" + resp = await client.get(url) + if resp.status_code == 404: + detail = "not found" + with suppress(Exception): + detail = resp.json().get("detail", detail) + raise _fail("not-found", f"Error: {detail}") + resp.raise_for_status() + + data = resp.json() + meta = data["collection"] + schemas = data["schemas"] + skipped = meta.get("skipped", []) + + collection_dir = output_dir / name + _mkdir_or_fail(collection_dir) + + for schema in schemas: + filename = f"{schema['name']}.yml" + file_path = collection_dir / filename + file_path.write_text(schema["content"], encoding="utf-8") + console.print(f"[green]Downloaded {schema['namespace']}/{schema['name']} v{schema['semver']} -> {file_path}") + + for item in skipped: + console.print(f"[yellow]Skipped {item['namespace']}/{item['name']}: {item['reason']}") + + console.print( + f"\n[green]Collection {namespace}/{name}: {meta['downloaded_count']}/{meta['schema_count']} schemas downloaded" + ) + + +@app.command() +@catch_exception(console=console) +async def download( + identifier: str = typer.Argument(help="Schema or collection identifier in namespace/name format"), + version: str | None = typer.Option( + None, "--version", "-v", help="Specific schema version, for example 1.2.0. Default: latest published." + ), + collection: bool | None = typer.Option( + None, + "--collection", + "-c", + help="Force collection download. Default: auto-detect whether the identifier is a schema or collection.", + ), + output_dir: Path = typer.Option(Path("schemas"), "--output-dir", "-o", help="Directory to save downloaded files."), + marketplace_url: str | None = typer.Option( + None, + "--marketplace-url", + help="Base URL of the Infrahub Marketplace. Overrides configuration and environment.", + ), + _: str = CONFIG_PARAM, +) -> None: + """Download a schema or collection from the Infrahub Marketplace. + + By default, auto-detects whether `namespace/name` is a schema or a collection. + Pass --collection to force the collection path when an identifier exists as both. + """ + namespace, name = _parse_identifier(identifier) + + resolved_url = marketplace_url or config.SETTINGS.active.marketplace_url + base_url = resolved_url.rstrip("/") + + async with httpx.AsyncClient(follow_redirects=True) as client: + prefetched: httpx.Response | None = None + if collection is None: + item_type, prefetched = await _detect_item_type(client, base_url, namespace, name) + elif collection: + item_type = "collection" + else: + item_type = "schema" + + if item_type == "collection": + if version: + console.print("[yellow]Warning: --version is ignored when downloading a collection.") + await _download_collection(client, base_url, namespace, name, output_dir, prefetched=prefetched) + else: + await _download_schema(client, base_url, namespace, name, version, output_dir, prefetched=prefetched) diff --git a/specs/001-marketplace-api-update/checklists/requirements.md b/specs/001-marketplace-api-update/checklists/requirements.md new file mode 100644 index 00000000..b8e8d228 --- /dev/null +++ b/specs/001-marketplace-api-update/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Marketplace Download Command Update + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-21 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- The spec intentionally references the REST API at a behavioural level only; specific endpoint paths appear only in the informational "Implementation Status" section, which is explicitly outside acceptance and describes what the current branch has already shipped. +- The "schema wins on collision" precedence is stated as an assumption rather than as a clarification-blocker because it mirrors the pre-existing behaviour; revisit during `/speckit.clarify` if stakeholders want a different default. +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/001-marketplace-api-update/contracts/marketplace-api.md b/specs/001-marketplace-api-update/contracts/marketplace-api.md new file mode 100644 index 00000000..c2b34788 --- /dev/null +++ b/specs/001-marketplace-api-update/contracts/marketplace-api.md @@ -0,0 +1,95 @@ +# External Contract: Marketplace REST API + +**Scope**: This document reverse-describes the subset of the `marketplace.infrahub.app` REST API that the `infrahubctl marketplace download` command relies on. **This is not a contract we own** — it captures the CLI's assumptions so that drift between client expectation and server reality can be caught in tests. + +**Base URL**: `https://marketplace.infrahub.app` (default). Overridable via `--marketplace-url` or the `marketplace_url` setting in `infrahubctl.toml` / `INFRAHUB_MARKETPLACE_URL` env. + +--- + +## Endpoint 1 — Download latest schema + +```text +GET {base_url}/api/v1/schemas/{namespace}/{name}/download +``` + +**Success (200)**: + +- `Content-Type`: `application/yaml` (or `text/yaml` / `text/plain`) +- Response body: raw YAML payload of the schema. +- Response header: `x-schema-version: ` — resolved version the server returned. Required for the CLI to echo the version to the user. + +**Not found (404)**: JSON body `{"detail": ""}`. Treated by the CLI as input to the not-found / auto-detect fallback flow. + +**Other 4xx**: JSON body `{"detail": ""}`; surfaced as an error with the detail message. + +**5xx / transport failure**: surfaced as a network-class error (see `research.md` R-4). + +--- + +## Endpoint 2 — Download specific schema version + +```text +GET {base_url}/api/v1/schemas/{namespace}/{name}/versions/{version}/download +``` + +Same response contract as Endpoint 1, but `{version}` is a user-supplied semver. + +**404 semantics**: + +- If the schema itself does not exist, a 404 is returned. Cannot be distinguished from "version missing" by URL alone, so the CLI MUST probe the unversioned endpoint first when constructing a "version-not-found" vs. "not-found" error (see `research.md` R-4). +- If the schema exists but the specific version is not published, a 404 is returned. + +--- + +## Endpoint 3 — Download collection + +```text +GET {base_url}/api/v1/collections/{namespace}/{name}/download +``` + +**Success (200)**: JSON body with shape: + +```json +{ + "collection": { + "namespace": "acme", + "name": "starter-pack", + "schema_count": 2, + "downloaded_count": 2, + "skipped": [ + { "namespace": "acme", "name": "broken", "reason": "no published version" } + ] + }, + "schemas": [ + { + "namespace": "acme", + "name": "network-base", + "semver": "1.0.0", + "filename": "acme-network-base-1.0.0.yml", + "content": "---\n..." + } + ] +} +``` + +The CLI writes each `schemas[].content` to disk under `//.yml` (today) or a flat layout under `/` (future — see implementation notes below). + +**404 / other errors**: same as Endpoint 1. + +--- + +## Implicit contract for auto-detection + +The CLI issues Endpoints 1 and 3 in parallel when the user omits `--collection`. The contract assumed is: + +- Both endpoints are idempotent and safe to issue concurrently. +- Either endpoint responds with a well-formed 404 (not a 5xx) when the identifier is not present as that item type. A 5xx from either probe is treated as a transport failure and aborts auto-detection. +- If both endpoints return 200, the CLI applies the documented "schema wins" precedence (`research.md` R-3). + +**Follow-up (out of scope)**: Request the marketplace team add lightweight metadata endpoints (`GET /api/v1/schemas/{ns}/{name}` and `GET /api/v1/collections/{ns}/{name}` without `/download`) so the CLI can probe cheaply without paying for the payload on the wasted side of a 200-200 collision. When those ship, the CLI swaps its probe targets and keeps the download endpoints only for the winner. + +--- + +## Versions of this contract + +This document reflects the API shape observed on or before **2026-04-21**. Any new fields added server-side are expected to be backward compatible; breaking changes require a coordinated CLI update. diff --git a/specs/001-marketplace-api-update/plan.md b/specs/001-marketplace-api-update/plan.md new file mode 100644 index 00000000..3e5d8581 --- /dev/null +++ b/specs/001-marketplace-api-update/plan.md @@ -0,0 +1,102 @@ +# Implementation Plan: Marketplace Download Command Update + +**Branch**: `knotty-dibble` | **Date**: 2026-04-21 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `specs/001-marketplace-api-update/spec.md` + +## Summary + +Migrate `infrahubctl marketplace download` to the public REST API at `marketplace.infrahub.app`, add auto-detection of schema-vs-collection identifiers, keep the `--version` pinning flag for schemas, and retain `--output-dir` (default `./schemas`). The REST migration, `--version`, and `--output-dir` pieces have already shipped in the current branch's `Marketplace` commit; the remaining deltas are auto-detection, the four-class error taxonomy, and the documented precedence rule for namespace/name collisions. + +## Technical Context + +**Language/Version**: Python 3.10–3.13 +**Primary Dependencies**: `typer` (CLI), `httpx` (HTTP), `rich` (console output), `pydantic` 2.x (config). No new runtime dependencies are expected. +**Storage**: Files on disk under the user-chosen `--output-dir` (default `./schemas`). No database or server-side state owned by this change. +**Testing**: `pytest` with `pytest-httpx` for mocking the marketplace REST API; existing tests in `tests/unit/ctl/test_marketplace_app.py` are the template. +**Target Platform**: Cross-platform CLI (macOS/Linux/Windows), installed via `uv`/`pip` as the `infrahubctl` entry point. +**Project Type**: Single project (Python SDK + CLI) — uses the `infrahub_sdk` package layout already in place. +**Performance Goals**: Interactive CLI; target end-to-end command latency dominated by the marketplace round-trip. Auto-detection must not exceed one additional round-trip beyond the download itself in the common (cache-miss, 404-on-schema) case. +**Constraints**: Must remain backward compatible with scripts that already pass `--collection` / `--output-dir` / `--version`. Must not require any new public SDK surface outside the CLI module. Must not re-introduce GraphQL usage for marketplace calls. +**Scale/Scope**: Small surface area — a single `infrahub_sdk/ctl/marketplace.py` module (~180 LOC today) plus its test file. Expected diff is additive, likely under ~150 LOC of new code plus tests. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +This repository ships a template-only `.specify/memory/constitution.md` (unfilled) in sibling worktrees, and none in the current branch. In the absence of concrete articles, the following project-level gates (derived from `AGENTS.md`) are applied: + +- **Async/sync dual pattern**: N/A for this change — the marketplace CLI command is already async-only (via `AsyncTyper`) and deliberately does not expose a sync twin. Adding a sync twin is out of scope. +- **Type hints on all signatures**: PASS — will be enforced on any new helpers. +- **No modifications to generated code (`protocols.py`)**: PASS — not touched. +- **No new runtime dependencies without asking first**: PASS — no new dependencies planned. +- **Lint + format gates (`uv run invoke format lint-code`)**: PASS — will be run before committing. +- **Tests-first spirit**: Will add unit tests covering each new acceptance scenario before wiring the CLI changes. + +**Result**: PASS. No violations to justify. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-marketplace-api-update/ +├── spec.md # Feature specification (already written) +├── plan.md # This file +├── research.md # Phase 0 output +├── contracts/ +│ └── marketplace-api.md # Reverse-documented external REST contract we consume +├── quickstart.md # Phase 1 output +├── checklists/ +│ └── requirements.md # Spec quality validation (already written) +└── tasks.md # (Produced later by /speckit.tasks) +``` + +`data-model.md` is intentionally omitted: this feature consumes an external REST API and writes YAML to disk, with no owned entity model to design. The Key Entities in `spec.md` are behavioural, not a data-store schema. + +### Source Code (repository root) + +```text +infrahub_sdk/ +├── ctl/ +│ ├── marketplace.py # Primary code under change +│ ├── config.py # `marketplace_url` setting (already in place) +│ └── cli_commands.py # Registers the marketplace sub-app (no change expected) + +tests/ +└── unit/ + └── ctl/ + └── test_marketplace_app.py # Primary test file to extend +``` + +**Structure Decision**: Single-project Python layout. All implementation lives in the existing `infrahub_sdk/ctl/marketplace.py` module; tests live alongside existing tests in `tests/unit/ctl/test_marketplace_app.py`. No new packages or modules are introduced. + +## Phases + +### Phase 0 — Research + +See [research.md](research.md). Topics resolved: + +1. Auto-detection strategy (schema-probe-first vs. parallel probe vs. metadata endpoint). +2. Name collision precedence (`schema wins` — see Assumption in spec). +3. Error taxonomy mapping (not-found vs. version-not-found vs. network vs. invalid-input) onto observable HTTP responses from the marketplace. +4. Behaviour when a schema exists only as a pre-release (no stable published semver). + +### Phase 1 — Design & Contracts + +**Prerequisites:** `research.md` complete. + +1. **External contract documentation** → [contracts/marketplace-api.md](contracts/marketplace-api.md). + Documents the *consumed* REST endpoints (schemas, collections, version pinning), expected response shapes, and HTTP status semantics. This is not an owned contract — it reverse-describes what the CLI assumes about `marketplace.infrahub.app`, so drift can be detected. + +2. **Quickstart** → [quickstart.md](quickstart.md). + Manual and automated verification steps that exercise each acceptance scenario from the spec. + +3. **Agent context update**: not applicable — no `.specify/scripts/bash/update-agent-context.sh` is installed in this branch, and the project-level agent context (`AGENTS.md`) does not require updates for this feature (no new dependencies or commands). + +### Phase 2 — Tasks (deferred) + +Not produced by this command. Will be generated by `/speckit.tasks` against this plan. + +## Complexity Tracking + +No constitution violations to justify. The feature fits inside the existing module with no new abstractions. diff --git a/specs/001-marketplace-api-update/quickstart.md b/specs/001-marketplace-api-update/quickstart.md new file mode 100644 index 00000000..a5031be7 --- /dev/null +++ b/specs/001-marketplace-api-update/quickstart.md @@ -0,0 +1,103 @@ +# Quickstart: Marketplace Download Command + +End-to-end smoke test for the updated `infrahubctl marketplace download` command. These steps exercise each acceptance scenario in `spec.md` and should be green before merging. + +## Prerequisites + +```bash +uv sync --all-groups --all-extras +``` + +## Unit tests (fast loop) + +```bash +uv run pytest tests/unit/ctl/test_marketplace_app.py -v +``` + +All existing tests must stay green, and new tests MUST be added to cover: + +- Auto-detection when identifier is a schema (no `--collection` passed). +- Auto-detection when identifier is a collection (no `--collection` passed). +- Auto-detection when both endpoints return 200 — resolved as schema, type printed in output. +- Auto-detection when both endpoints return 404 — error class "not found". +- `--version` with an unpublished version — error class "version not found". +- 5xx on either probe endpoint — error class "network". +- Invalid identifier (no slash) — error class "invalid input" — caught before any network call. + +## Manual verification against the public marketplace + +```bash +# Scenario 1: download a schema by auto-detection +uv run infrahubctl marketplace download acme/network-base +ls schemas/ + +# Scenario 2: download a collection by auto-detection +uv run infrahubctl marketplace download acme/starter-pack +ls schemas/ + +# Scenario 3: pin a specific schema version +uv run infrahubctl marketplace download acme/network-base --version 0.9.0 +grep '^version:' schemas/network-base.yml + +# Scenario 4: custom output directory +uv run infrahubctl marketplace download acme/network-base --output-dir ./tmp/market-test +ls ./tmp/market-test + +# Scenario 5: explicit --collection still works (override path) +uv run infrahubctl marketplace download acme/starter-pack --collection + +# Scenario 6: version on a collection emits a warning, proceeds +uv run infrahubctl marketplace download acme/starter-pack --version 1.0.0 +# Expect: "Warning: --version is ignored when downloading a collection." followed by success output. +``` + +## Manual verification against a local/staging marketplace + +```bash +uv run infrahubctl marketplace download acme/test \ + --marketplace-url http://localhost:8000 \ + --output-dir ./tmp/local-market +``` + +This must exercise the same auto-detection behaviour against the overridden host. + +## Expected success output shape + +For a schema: + +```text +Downloaded acme/network-base v1.2.0 -> schemas/network-base.yml +``` + +For a collection: + +```text +Downloaded acme/network-base v1.0.0 -> schemas/starter-pack/network-base.yml +Downloaded acme/dcim v2.1.0 -> schemas/starter-pack/dcim.yml + +Collection acme/starter-pack: 2/2 schemas downloaded +``` + +The CLI MUST announce the resolved item type (schema vs. collection) explicitly so the user can detect an unintended match in a collision case. + +## Expected error output shapes + +```text +# Not found +No schema or collection named 'acme/missing' found on marketplace.infrahub.app + +# Version not found +Schema 'acme/network-base' has no published version '9.9.9'. Run without --version for the latest. + +# Network +Could not reach marketplace at https://marketplace.infrahub.app: connection timed out. Check your connection or --marketplace-url. + +# Invalid input +Invalid identifier 'acme-network-base'. Expected format: namespace/name +``` + +## Lint / format / type gates (must all pass) + +```bash +uv run invoke format lint-code +``` diff --git a/specs/001-marketplace-api-update/research.md b/specs/001-marketplace-api-update/research.md new file mode 100644 index 00000000..0bf01106 --- /dev/null +++ b/specs/001-marketplace-api-update/research.md @@ -0,0 +1,142 @@ +# Phase 0 Research: Marketplace Download Command Update + +## R-1: Auto-detection strategy for schema vs. collection + +**Problem**: The CLI receives a `namespace/name` identifier and must decide whether to hit `/api/v1/schemas/...` or `/api/v1/collections/...` without the user having to pass `--collection`. + +**Options evaluated**: + +| # | Approach | Round-trips (schema case) | Round-trips (collection case) | Depends on new API? | +| - | -------- | ------------------------- | ----------------------------- | ------------------- | +| A | Probe `/schemas/{ns}/{name}/download` first; on 404 fall back to `/collections/{ns}/{name}/download` | 1 | 2 (first 404, then success) | No | +| B | Probe both endpoints in parallel with `asyncio.gather`; take whichever returns 200; if both 200, apply precedence rule | 1 wall-clock (2 wire) | 1 wall-clock (2 wire) | No | +| C | Call a dedicated resolver endpoint (e.g. `/api/v1/items/{ns}/{name}`) that returns the item type, then fetch the download | 2 | 2 | **Yes** — requires marketplace API addition | +| D | Use `HEAD` on both endpoints, then `GET` the winner | 2 (HEAD + GET) | 2 (HEAD + GET) | No (assuming HEAD is supported) | + +**Decision**: **Option B — parallel probe with precedence tie-break.** + +**Rationale**: + +- One wall-clock round-trip for both schema and collection cases keeps the user-perceived latency identical to the pre-auto-detect behaviour. +- Requires no new marketplace endpoint, so it can ship without server-side coordination. +- Naturally implements FR-012 (collision precedence): if both endpoints succeed, apply "schema wins" and print the resolved type so the user notices. +- The extra wire request over Option A is one cheap 404 on the common case (either schema or collection, not both present). The wasted bandwidth is negligible versus the win on the collection case. + +**Alternatives considered**: + +- **Option A** was rejected because collection lookups would incur a doubled latency for what should be a first-class code path. +- **Option C** was rejected as out-of-scope: we don't control the marketplace API surface inside this feature. If such an endpoint is added later, the CLI can transparently switch to it (see R-5). +- **Option D** was rejected because not all HTTP deployments support `HEAD` cleanly on download endpoints that stream payloads, and we'd still need a subsequent `GET`. + +**Implementation notes**: + +- Use `httpx.AsyncClient` with `asyncio.gather(..., return_exceptions=True)` so one probe failing with 404 does not abort the other. +- For the schema probe, prefer the lightweight metadata endpoint (if one exists under `/api/v1/schemas/{ns}/{name}`, i.e. without `/download`) to avoid paying for the payload twice in collision cases. Verify endpoint availability in R-2 before relying on this — otherwise, accept that the schema probe downloads the payload on success. + +--- + +## R-2: Available probe endpoints on the marketplace + +**Problem**: Option B is most efficient if we can probe without downloading payloads. Need to confirm what lightweight endpoints are exposed. + +**Decision**: **Use the existing `/download` endpoints for the initial implementation** and treat any future metadata endpoints as a later optimisation. + +**Rationale**: + +- We only have direct evidence of `/api/v1/schemas/{ns}/{name}/download` and `/api/v1/collections/{ns}/{name}/download` being live today (from the current `marketplace.py` implementation and its test fixtures). +- Starting with what is known to work means the feature ships without a cross-team dependency. The cost is a single wasted YAML payload in the "both exist" collision case and zero extra cost in every other case. +- **Follow-up action** (out of scope for this feature): file an issue against the marketplace service to add `GET /api/v1/schemas/{ns}/{name}` and `GET /api/v1/collections/{ns}/{name}` metadata endpoints that return type + available versions without the payload. When those land, swap the probe targets in a small follow-up. + +**Alternatives considered**: + +- Guessing endpoints (`/api/v1/items/{ns}/{name}`) based on common REST conventions — rejected; speculative and unverifiable inside this feature. + +--- + +## R-3: Name collision precedence + +**Problem**: `acme/foo` could legally exist both as a schema and as a collection. A deterministic rule is needed (FR-012). + +**Decision**: **Schema wins**, and the CLI prints the resolved type so the user can detect an unexpected match. + +**Rationale**: + +- Matches the pre-auto-detect behaviour, where absent `--collection` the CLI always queried the schema endpoint. Users with existing scripts see no change. +- Collections are the "larger" deliverable — requiring an explicit `--collection` to pick them in a collision avoids accidental multi-file downloads. +- Users who intend to download the collection in a collision case must pass `--collection`, which also forces the explicit-override branch (FR-011). + +**Alternatives considered**: + +- **Collection wins**: rejected because it changes default behaviour for existing scripts without notice. +- **Error on collision**: rejected; it makes auto-detection fragile on operator edge cases and requires a full dual fetch even when the user would be satisfied with the schema. + +--- + +## R-4: Error taxonomy + +**Problem**: FR-008 requires four distinguishable failure classes: not-found, version-not-found, network-unreachable, invalid-input. + +**Decision**: + +| Class | Trigger | User-facing message template | Exit code | +| ----- | ------- | ---------------------------- | --------- | +| Invalid input | `namespace/name` fails `_parse_identifier` OR `--version` is malformed | `Invalid identifier ''. Expected format: namespace/name` (or version-specific variant) | 1 | +| Not found | Both schema and collection probes return 404 | `No schema or collection named '/' found on ` | 1 | +| Version not found | Schema probe succeeds in general but `--version ` returns 404 | `Schema '/' has no published version ''. Run without --version for the latest.` | 1 | +| Network | `httpx.ConnectError`, `httpx.TimeoutException`, or any 5xx response | `Could not reach marketplace at : . Check your connection or --marketplace-url.` | 2 | + +**Rationale**: + +- Exit codes 1 vs. 2 let CI pipelines distinguish "bad input/absent content" (deterministic) from "transient infrastructure" (retryable). +- Each class mentions the identifier or host, so the message is self-diagnosing in terminal scrollback. +- The `--version` class only applies when the schema itself is present, which we can detect because the no-version probe returns 200 while the versioned probe returns 404. + +**Alternatives considered**: + +- A single exit code with only text differentiation: rejected because automation frequently branches on exit code. +- Including stack traces for network errors: rejected; tracebacks bury the cause. + +--- + +## R-5: Behaviour when only pre-release versions are published + +**Problem**: A schema may have only pre-release semvers (e.g. `1.0.0-rc1`) and no stable published version. Without `--version`, what should the default `/download` (no version path) return, and what should the CLI show? + +**Decision**: **Trust whatever the marketplace returns as "latest" via its default `/download` endpoint.** Echo the resolved version (from the `x-schema-version` header the server already provides — see existing `marketplace.py:57`) so the user sees what they got. If the server refuses to resolve a default when only pre-releases exist (404 or a specific error body), surface that as a "version-not-found" class error with guidance to pass `--version`. + +**Rationale**: + +- Version selection policy belongs on the server, not in the CLI. The marketplace knows its own publishing model; the CLI only reports the decision. +- Keeps the CLI logic simple and avoids encoding semver-rules locally. +- The existing code already echoes `x-schema-version`, so no new client behaviour is needed for the happy path. + +**Alternatives considered**: + +- Client-side logic to enumerate versions and pick the highest stable one: rejected; requires a list endpoint we haven't confirmed exists, and duplicates server policy. + +--- + +## R-6: Removal of `--load` + +**Problem**: The existing command carried a `--load` convenience flag that pushed downloaded schemas into a running Infrahub instance in the same invocation. Does it stay? + +**Decision**: **Remove `--load`**. The `download` command is now pure — it only writes files to disk. + +**Rationale**: + +- Single-responsibility: download and load are distinct concerns with different failure modes (network vs. schema validation). Combining them makes the error surface harder to reason about and couples a marketplace-client concern to a server-state mutation. +- The `infrahubctl schema load ` workflow already exists for loading schemas, so chaining `marketplace download` → `schema load` is a two-line script, not a feature gap. +- Reduces the CLI's dependency surface: no import of `initialize_client` or `yaml` at module load time, no coupling to the live Infrahub instance for marketplace operations. + +**Migration note**: scripts that previously relied on `infrahubctl marketplace download --load` should switch to: + +```bash +infrahubctl marketplace download -o ./schemas +infrahubctl schema load ./schemas +``` + +--- + +## Summary of resolved clarifications + +All items in the spec's Assumptions section have been either confirmed or elevated into explicit rules here. No `NEEDS CLARIFICATION` markers remain. diff --git a/specs/001-marketplace-api-update/spec.md b/specs/001-marketplace-api-update/spec.md new file mode 100644 index 00000000..9f91f9fa --- /dev/null +++ b/specs/001-marketplace-api-update/spec.md @@ -0,0 +1,128 @@ +# Feature Specification: Marketplace Download Command Update + +**Feature Branch**: `knotty-dibble` +**Created**: 2026-04-21 +**Status**: Draft +**Input**: User description: "marketplace.infrahub.app is now live and has a new api no longer using graphql. Lets update that I also want the ability to autometically determine if the namespace is a collection or a schema. The ability to specify a version like so --version and also a custom destination path for the schema with the default being the schemas directory" + +## Overview + +The public Infrahub Marketplace at `marketplace.infrahub.app` is now live and exposes a REST API for distributing schemas and schema collections. The existing `infrahubctl marketplace download` command was designed against an earlier GraphQL-based interface and must be realigned with the new REST contract. Alongside that migration, users need a simpler, less error-prone download experience: one identifier argument that Works For Schemas And Collections alike, an optional pinned version, and predictable default output placement. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Download any published item by identifier (Priority: P1) + +As a platform engineer bootstrapping a new Infrahub environment, I want to run a single `infrahubctl marketplace download /` command and have the tool figure out whether I'm asking for a single schema or a full collection so that I don't need to inspect the marketplace UI or remember which flag to pass. + +**Why this priority**: This is the primary workflow for every marketplace user. Without automatic detection, every first-time user who types the command will either guess wrong or abandon the command line and revisit the web UI, undermining the value of the CLI. + +**Independent Test**: Publish one schema and one collection to the marketplace (or mock the API), then run the download command twice — once against each identifier — without passing any type-hint flag. Both invocations succeed, write the correct files, and print the correct item type. + +**Acceptance Scenarios**: + +1. **Given** the identifier `acme/network-base` refers to a single schema on the marketplace, **When** the user runs `infrahubctl marketplace download acme/network-base`, **Then** the CLI downloads the schema file to the default destination and reports it was downloaded as a schema. +2. **Given** the identifier `acme/starter-pack` refers to a collection on the marketplace, **When** the user runs `infrahubctl marketplace download acme/starter-pack`, **Then** the CLI downloads every schema in the collection to the default destination and reports the collection totals. +3. **Given** the identifier does not match any published schema or collection, **When** the user runs the download command, **Then** the CLI fails with a clear message naming the identifier and the marketplace that was queried, and exits non-zero. + +--- + +### User Story 2 - Pin a specific schema version (Priority: P2) + +As a configuration author integrating a schema into a production pipeline, I want to pin to a specific published semver of a schema so that upstream updates do not silently change the shape of my data. + +**Why this priority**: Reproducible builds are a must-have for any downstream automation that consumes marketplace content. Without a version pin, environments drift. + +**Independent Test**: Publish at least two versions of the same schema, then run the download command with `--version ` and verify the older file contents are written rather than the latest. + +**Acceptance Scenarios**: + +1. **Given** schema `acme/network-base` has published versions `0.9.0` and `1.2.0`, **When** the user runs `infrahubctl marketplace download acme/network-base --version 0.9.0`, **Then** the CLI writes the `0.9.0` payload to disk and reports that version in its success output. +2. **Given** the user passes `--version` with a value that has not been published for the schema, **When** the command runs, **Then** the CLI fails with a message that distinguishes "version not found" from "schema not found" and exits non-zero. +3. **Given** the target identifier resolves to a collection, **When** the user also passes `--version`, **Then** the CLI warns that `--version` has no effect for collections and proceeds with the collection download. + +--- + +### User Story 3 - Choose where files land (Priority: P2) + +As a repository owner whose project uses a non-standard layout, I want to direct downloaded schemas into a specific folder so that the files match my repository conventions without a post-download move. + +**Why this priority**: Users who store schemas outside the default `schemas/` directory currently have to run an extra move step, which is easy to forget and breaks automation. + +**Independent Test**: Run the download against any identifier with `--output-dir ./custom/path` and confirm the files appear only in `./custom/path`, not in the default `schemas/` directory. + +**Acceptance Scenarios**: + +1. **Given** no `--output-dir` is provided, **When** the user downloads a schema, **Then** the file is written under `./schemas/` relative to the current working directory. +2. **Given** `--output-dir ./infra/schemas/marketplace` is provided and the directory does not yet exist, **When** the user downloads a schema or collection, **Then** the CLI creates the directory (including parents) and writes the payload there. +3. **Given** `--output-dir` points to a path the user does not have permission to write to, **When** the user runs the download, **Then** the CLI surfaces the filesystem error with the target path and exits non-zero without partial writes left in unrelated locations. + +--- + +### Edge Cases + +- A name collision exists between a schema and a collection under the same namespace (e.g. `acme/network` is both a single schema and a collection). The CLI must apply a deterministic, documented resolution order and make the choice visible to the user. +- The marketplace is unreachable, returns a 5xx, or the response is malformed. The CLI must fail with a network-oriented error message that names the host, not a Python traceback. +- The user overrides the marketplace base URL (e.g. for staging or an air-gapped mirror). Auto-detection and version resolution must honour the override. +- The same identifier has been published with only a pre-release version (no stable release yet). Default (no `--version`) behaviour must be defined: either download the latest pre-release with a warning, or fail with guidance to pass `--version`. +- A collection lists schemas that individually have no published version. The CLI must surface these as skipped entries alongside the successful downloads so the user sees a complete picture. +- The user passes an identifier missing the `/` separator, or with extra path segments. The CLI must reject the input with a usage message before any network call. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The `infrahubctl marketplace download` command MUST communicate with the marketplace exclusively over its public REST API; no GraphQL usage for marketplace operations is permitted. +- **FR-002**: The command MUST accept a single positional identifier in `namespace/name` form and reject any other shape with a usage error before making network calls. +- **FR-003**: When no type-hint flag is passed, the command MUST automatically determine whether the identifier refers to a schema or a collection and download accordingly, reporting the resolved type in its output. +- **FR-004**: The command MUST expose a `--version` option that, when provided, pins the download to that specific published semver for a schema. +- **FR-005**: If `--version` is provided alongside an identifier that resolves to a collection, the command MUST warn that the flag is ignored and continue with the collection download rather than failing. +- **FR-006**: The command MUST expose an `--output-dir` option that defaults to `./schemas` and, when provided, redirects all downloaded files (schemas or collection members) beneath the supplied path. +- **FR-007**: The command MUST create any missing intermediate directories under the chosen output path before writing files. +- **FR-008**: The command MUST distinguish, in its user-facing error output, between "identifier not found", "version not found for identifier", "marketplace unreachable", and "user input invalid", and MUST exit with a non-zero status on any of these. +- **FR-009**: The command MUST honour a user-supplied marketplace base URL (via flag or configuration) for every network call it makes, including any calls used for auto-detection. +- **FR-010**: On success, the command MUST print, per downloaded file, the namespace, name, resolved version, and absolute or workspace-relative path on disk, and MUST print an aggregate summary for collection downloads (e.g. "N of M schemas downloaded, K skipped"). +- **FR-011**: The `--collection` flag MUST remain available as an explicit override so users in automation contexts can force the collection code path and bypass auto-detection. +- **FR-012**: Name collisions between a schema and a collection sharing the same `namespace/name` MUST be resolved by a single documented precedence rule, and the CLI MUST print which type it resolved to so the user can detect an unintended match. + +### Key Entities *(include if feature involves data)* + +- **Schema**: A single published unit of Infrahub data model content, addressed by `namespace/name` and versioned by semver. Has a payload (YAML content) and metadata including the resolved version. +- **Collection**: A named, curated bundle of schemas under a single `namespace/name`. Enumerates its member schemas and, for each, either a successful entry (with content and version) or a skip reason. +- **Marketplace**: The remote catalogue service hosting schemas and collections. Addressed by a base URL that defaults to `https://marketplace.infrahub.app` but is overridable per-invocation or via configuration. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A first-time user can download any published schema or collection in a single command without reading the help text or toggling flags, in 100% of valid-identifier cases. +- **SC-002**: When a user supplies `--version`, the file written to disk is byte-identical to the payload stored for that version on the marketplace in 100% of cases. +- **SC-003**: When `--output-dir` is omitted, all downloaded content lands under `./schemas/` in 100% of runs; when it is supplied, zero files land outside the supplied path. +- **SC-004**: Error messages for the four distinct failure classes (not-found, version-not-found, network, invalid-input) are distinguishable by a human reader on first glance — validated by having a new user correctly classify each error in at least 90% of sampled cases. +- **SC-005**: Migrating existing scripts from the previous GraphQL-based command to the new one requires no change to their positional identifier or `--output-dir` arguments; only the removal of the explicit `--collection` flag is optional. + +## Assumptions + +- The marketplace REST API exposes separate endpoints (or response fields) that allow the CLI to distinguish schemas from collections without requiring the user to specify the type up-front. If the only way to distinguish is trial-and-error (probe schemas, then collections on 404), that trial is acceptable as long as it is transparent and bounded to one extra request. +- The default marketplace base URL is `https://marketplace.infrahub.app`, overridable via configuration or a flag. +- Schemas are versioned with semver and the marketplace returns the resolved version in a response header or body field so the CLI can echo it back to the user. +- The precedence rule for name collisions between a schema and a collection sharing the same `namespace/name` is **schema wins**, mirroring the previous default behaviour (download treated an identifier as a schema unless `--collection` was passed). Users relying on a collision must use `--collection` to disambiguate. +- The command downloads files only; it does not load schemas into a running Infrahub instance. Users who want to push downloaded content should chain with `infrahubctl schema load` (the earlier `--load` convenience was removed for simplicity and single-responsibility). +- Retention, telemetry, and auth on the marketplace side are out of scope for this client-side specification. + +## Implementation Status *(informational, not part of acceptance)* + +At time of writing (commit `Marketplace` on branch `knotty-dibble`) the following are **already implemented** in `infrahub_sdk/ctl/marketplace.py`: + +- REST API calls against `/api/v1/schemas/...` and `/api/v1/collections/...` (FR-001). +- `--version` option for schemas (FR-004). +- `--output-dir` option defaulting to `schemas/` (FR-006, FR-007). +- Explicit `--collection` flag (FR-011). +- Distinct error paths for 404 vs. other HTTP errors (partial FR-008). + +The following are **not yet implemented** and are the primary delta introduced by this spec: + +- Auto-detection of schema vs. collection when `--collection` is not passed (FR-003). +- Warning behaviour when `--version` is combined with a collection identifier (FR-005, partial). +- Full four-way classification of error output (FR-008). +- Documented precedence rule for namespace/name collisions (FR-012). diff --git a/specs/001-marketplace-api-update/tasks.md b/specs/001-marketplace-api-update/tasks.md new file mode 100644 index 00000000..e81c0a7f --- /dev/null +++ b/specs/001-marketplace-api-update/tasks.md @@ -0,0 +1,198 @@ +--- +description: "Task list for the Marketplace Download Command Update" +--- + +# Tasks: Marketplace Download Command Update + +**Input**: Design documents from `specs/001-marketplace-api-update/` +**Prerequisites**: plan.md, spec.md, research.md, contracts/marketplace-api.md, quickstart.md + +**Tests**: TDD is on — each user story leads with unit tests using `pytest` + `pytest-httpx`, following the pattern already in `tests/unit/ctl/test_marketplace_app.py`. + +**Organization**: Tasks are grouped by user story. Nearly all edits concentrate in two files (`infrahub_sdk/ctl/marketplace.py` and `tests/unit/ctl/test_marketplace_app.py`), so most tasks run sequentially; `[P]` appears where a task lands in a distinct file from its siblings. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Maps task to its user story (US1, US2, US3) +- Every task names an exact file path + +## Path Conventions + +Single project: repository root contains `infrahub_sdk/` and `tests/`. All paths below are repo-relative. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: No project initialization required — `infrahub_sdk/ctl/marketplace.py` and its test file already exist. + +- [ ] T001 Confirm baseline: run `uv run pytest tests/unit/ctl/test_marketplace_app.py -v` and record a green baseline before any changes to `infrahub_sdk/ctl/marketplace.py`. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Reusable helpers that every user story needs. Must land before US1 because the auto-detect path depends on them. + +**⚠️ CRITICAL**: No user-story implementation tasks (T006 onward) may start until this phase is complete. + +- [ ] T002 Introduce an internal enum/literal `MarketplaceItemType` (values `"schema"` | `"collection"`) in `infrahub_sdk/ctl/marketplace.py` to be returned by the new detection helper. +- [ ] T003 Add a private `_classify_http_error(exc_or_response) -> tuple[ErrorClass, str]` helper in `infrahub_sdk/ctl/marketplace.py` implementing the four-class taxonomy from `research.md` R-4 (invalid-input, not-found, version-not-found, network). Include a matching `ErrorClass` enum/literal. +- [ ] T004 Centralize console error emission in `infrahub_sdk/ctl/marketplace.py` via a `_fail(error_class, message)` helper that prints the coloured error line and raises `typer.Exit(1)` for input/not-found classes and `typer.Exit(2)` for network class (per `research.md` R-4 exit-code table). +- [ ] T005 Extend `_parse_identifier` in `infrahub_sdk/ctl/marketplace.py` to route its current inline error through the new `_fail("invalid-input", ...)` helper, preserving existing behaviour for backwards compat. + +**Checkpoint**: Helpers in place; user-story phases can now begin. + +--- + +## Phase 3: User Story 1 — Auto-detect schema vs. collection (Priority: P1) 🎯 MVP + +**Goal**: A single `infrahubctl marketplace download /` command resolves to the correct item type without the user passing `--collection`, and prints the resolved type in success output. + +**Independent Test**: Mock one schema endpoint and one collection endpoint (as in `tests/unit/ctl/test_marketplace_app.py`). Run the download twice with no `--collection` flag — once against each identifier. Both succeed, files land at the expected paths, and the output names the resolved type. + +### Tests for User Story 1 (write first, ensure they fail) + +- [ ] T006 [US1] Add `test_autodetect_schema` in `tests/unit/ctl/test_marketplace_app.py`: only the schema endpoint returns 200; collection endpoint returns 404. Assert exit 0, schema file written, output mentions "schema". +- [ ] T007 [US1] Add `test_autodetect_collection` in `tests/unit/ctl/test_marketplace_app.py`: only the collection endpoint returns 200; schema endpoint returns 404. Assert exit 0, collection files written, output mentions "collection". +- [ ] T008 [US1] Add `test_autodetect_collision_schema_wins` in `tests/unit/ctl/test_marketplace_app.py`: both endpoints return 200 for the same `namespace/name`. Assert resolved as schema, output prints both the resolved type and a hint that `--collection` can force the other path. +- [ ] T009 [US1] Add `test_autodetect_not_found` in `tests/unit/ctl/test_marketplace_app.py`: both endpoints return 404. Assert exit 1, error class "not found", message names the identifier and marketplace host. +- [ ] T010 [US1] Add `test_autodetect_network_error` in `tests/unit/ctl/test_marketplace_app.py`: either probe raises `httpx.ConnectError`. Assert exit 2, error class "network", message names the base URL. +- [ ] T011 [US1] Add `test_collection_flag_overrides_autodetect` in `tests/unit/ctl/test_marketplace_app.py`: user passes `--collection`; assert the schema endpoint is NOT probed (use `httpx_mock` to fail the test if it is called) and the collection endpoint is used directly. + +### Implementation for User Story 1 + +- [ ] T012 [US1] Add `async def _detect_item_type(client, base_url, namespace, name) -> MarketplaceItemType` in `infrahub_sdk/ctl/marketplace.py` that issues schema and collection probes in parallel via `asyncio.gather(..., return_exceptions=True)`, applies "schema wins" precedence on 200-200, raises a typed network error if both probes raise transport exceptions, and raises a typed not-found error if both return 404. +- [ ] T013 [US1] Update `download()` in `infrahub_sdk/ctl/marketplace.py`: when `collection=False` and the user did NOT explicitly pass `--collection` (i.e. default), call `_detect_item_type` first, then dispatch to `_download_schema` or `_download_collection` based on the result. +- [ ] T014 [US1] Modify `_download_schema` and `_download_collection` in `infrahub_sdk/ctl/marketplace.py` to print the resolved item type on their first success line (e.g. `Downloaded schema acme/network-base v1.2.0 -> ...` and `Downloaded collection acme/starter-pack: ...`), per FR-010. +- [ ] T015 [US1] Make the `collection: bool` option explicit-only: detect whether the user actually typed `--collection` by using `typer.Option(None, ...)` default and treating `None` as "auto", `True` as "force collection", `False` as "force schema" (so T013 can branch correctly without mistaking a default `False` for an explicit override). +- [ ] T016 [US1] Route the existing 404 handling in `_download_schema` and `_download_collection` in `infrahub_sdk/ctl/marketplace.py` through `_fail("not-found", ...)`. +- [ ] T017 [US1] Add network-error handling (`httpx.ConnectError`, `httpx.TimeoutException`, 5xx) wrapping the `async with httpx.AsyncClient...` block in `infrahub_sdk/ctl/marketplace.py`'s `download()` and route through `_fail("network", ...)`. + +**Checkpoint**: Tests T006–T011 all pass. Running `infrahubctl marketplace download acme/starter-pack` (collection) and `infrahubctl marketplace download acme/network-base` (schema) both succeed without `--collection`, and the output names the resolved type. MVP scope. + +--- + +## Phase 4: User Story 2 — Pin schema version (Priority: P2) + +**Goal**: `--version ` pins a schema download; when the version is unpublished, the error clearly distinguishes "version missing" from "schema missing". + +**Independent Test**: Publish at least two versions of the same schema (mock) and confirm `--version ` writes the older payload. Separately, pass an unpublished `--version` and confirm the CLI emits the "version not found" error class with a hint to drop `--version`. + +### Tests for User Story 2 + +- [ ] T018 [P] [US2] Add `test_version_not_found` in `tests/unit/ctl/test_marketplace_app.py`: unversioned schema probe returns 200, versioned schema probe returns 404. Assert exit 1, error class "version not found", message names the version and suggests running without `--version`. +- [ ] T019 [P] [US2] Extend `test_download_schema_specific_version` in `tests/unit/ctl/test_marketplace_app.py` to assert the success output still echoes the pinned version unchanged under auto-detect. + +### Implementation for User Story 2 + +- [ ] T020 [US2] In `infrahub_sdk/ctl/marketplace.py` `_download_schema`, when `version` is provided and the versioned request returns 404, first retry an unversioned HEAD/GET against the same identifier to decide between "schema not found" and "version not found"; route through `_fail` with the appropriate class. +- [ ] T021 [US2] Confirm that the existing "`--version` is ignored when downloading a collection" warning (currently at `marketplace.py:146-147`) still fires on the auto-detect path — i.e. when the user passed `--version` and the detected type is `collection`, the warning is printed before falling through to `_download_collection`. Add/adjust wiring in `download()` in `infrahub_sdk/ctl/marketplace.py` as needed. + +**Checkpoint**: US1 still passes; T018 and T019 pass; `infrahubctl marketplace download acme/network-base --version 9.9.9` prints the version-not-found message and exits 1. + +--- + +## Phase 5: User Story 3 — Custom output directory (Priority: P2) + +**Goal**: `--output-dir` redirects all output with a working default of `./schemas`; filesystem failures are surfaced cleanly. + +**Independent Test**: Run the download with `--output-dir ./custom/does-not-exist-yet` — files land under that path and only that path; running again with `--output-dir` pointing to a non-writable path fails with a filesystem-class error that names the path. + +### Tests for User Story 3 + +- [ ] T022 [P] [US3] Add `test_output_dir_creates_nested_missing_parents` in `tests/unit/ctl/test_marketplace_app.py`: supply a multi-level `--output-dir` under `tmp_path` that does not yet exist. Assert directory tree is created and files land under it. +- [ ] T023 [P] [US3] Add `test_output_dir_default_is_schemas` in `tests/unit/ctl/test_marketplace_app.py`: run inside a `tmp_path`-rooted cwd (via `monkeypatch.chdir(tmp_path)`), omit `--output-dir`, assert the file appears under `tmp_path / "schemas"`. +- [ ] T024 [P] [US3] Add `test_output_dir_permission_error` in `tests/unit/ctl/test_marketplace_app.py`: supply an unwritable `--output-dir` (e.g. `monkeypatch` `Path.mkdir` to raise `PermissionError`). Assert exit 1 and an error message naming the target path, with no partial writes elsewhere. + +### Implementation for User Story 3 + +- [ ] T025 [US3] Wrap directory creation in `_download_schema` and `_download_collection` in `infrahub_sdk/ctl/marketplace.py` with a filesystem-error branch that routes through `_fail("invalid-input", ...)` with a message naming the offending path. No code change should be required for the happy path — tests T022 and T023 should pass on the existing implementation once auto-detect (US1) is wired. + +**Checkpoint**: US1 and US2 still pass; T022–T024 pass. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Finalise help text, docs, and verification gates. + +- [ ] T026 Update the `download` command's docstring and flag helps in `infrahub_sdk/ctl/marketplace.py` to reflect auto-detection (e.g. "By default, the CLI automatically determines whether `namespace/name` is a schema or a collection. Pass `--collection` to force the collection path."). +- [ ] T027 [P] If the docs site documents the marketplace CLI, update the relevant page under `docs/` to describe auto-detect, the `--version` error behaviour, and the `--output-dir` default. Run `uv run invoke docs-generate` afterward. (Skip this task with a brief note if no such doc page exists.) +- [ ] T028 Run `uv run invoke format lint-code` and fix any issues it reports in `infrahub_sdk/ctl/marketplace.py` and `tests/unit/ctl/test_marketplace_app.py`. +- [ ] T029 Walk through every manual command block in `specs/001-marketplace-api-update/quickstart.md` against the public marketplace or a local instance; fix any mismatch between quickstart output shapes and actual output. +- [ ] T030 Run the full unit suite `uv run pytest tests/unit/` and confirm green before requesting review. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)** → starts immediately; only gates recording the baseline. +- **Phase 2 (Foundational)** → depends on T001; blocks all user-story phases. +- **Phase 3 (US1)** → depends on Phase 2 completion. +- **Phase 4 (US2)** → depends on Phase 3 (the error helpers and auto-detect plumbing from US1 are reused by US2's version-distinction logic). +- **Phase 5 (US3)** → depends on Phase 2 only; can run in parallel with Phase 4 if staffed accordingly (US3's tests operate on the same file as US1/US2 but touch distinct code paths in `marketplace.py`). +- **Phase 6 (Polish)** → depends on all user-story phases that are in scope for the cut being made. + +### Within Each User Story + +- Tests are written first (T006–T011, T018–T019, T022–T024) and MUST fail before their implementation counterparts. +- Foundational helpers (T002–T005) before they are used. +- T012 (`_detect_item_type`) before T013 (`download()` wiring). +- T015 (option default change) before or together with T013 to avoid a transient broken state. + +### Parallel Opportunities + +- Within Phase 3, T006–T011 touch the same file (`tests/unit/ctl/test_marketplace_app.py`), so they run sequentially unless the team splits the file (not recommended). No `[P]` markers in US1. +- T018 and T019 (Phase 4 tests) and T022–T024 (Phase 5 tests) are marked `[P]` because they target independent test functions; they can be drafted in parallel and committed in one batch. +- Phase 5 implementation (T025) and Phase 4 implementation (T020–T021) operate on different code paths within the same module and should be merged sequentially to keep the diff clean; do not flag as `[P]`. + +--- + +## Parallel Example: User Story 2 + +```bash +# Developers A and B can draft these tests simultaneously (different test functions): +Task: "Add test_version_not_found in tests/unit/ctl/test_marketplace_app.py" +Task: "Extend test_download_schema_specific_version in tests/unit/ctl/test_marketplace_app.py" +``` + +Implementation then merges into `_download_schema` / `download()` in `infrahub_sdk/ctl/marketplace.py` sequentially (T020 → T021). + +--- + +## Implementation Strategy + +### MVP scope (ship US1 alone) + +1. Complete Phase 1 (T001). +2. Complete Phase 2 (T002–T005). +3. Complete Phase 3 (T006–T017). +4. **STOP and VALIDATE**: `uv run pytest tests/unit/ctl/test_marketplace_app.py -v` green; spot-check against the public marketplace per `quickstart.md` Scenarios 1–2. +5. Ship MVP. This alone satisfies the user's stated top ask: "auto-detect if namespace is a collection or a schema". + +### Incremental delivery + +- MVP (US1) → ship → gather feedback → add US2 (version error taxonomy) → ship → add US3 (output-dir polish/tests) → ship. +- Each increment keeps earlier stories green. + +### Parallel team strategy + +With two or more developers after Phase 2: + +- Dev A: Phase 3 (US1) — critical path. +- Dev B: Phase 5 (US3) — independent code paths; merges after Dev A lands the option-default change (T015) to avoid stomping on the `collection` signature. +- Either dev then picks up Phase 4 (US2) once Phase 3 merges. + +--- + +## Notes + +- [P] tasks = different files or distinct test functions, no dependencies on incomplete tasks. +- Almost all implementation lives in `infrahub_sdk/ctl/marketplace.py` — watch for merge conflicts when parallelising. +- Verify new tests fail before their paired implementation tasks. +- Commit after each task or small group (e.g. T002–T005 together; each test in US1 with its implementation task). +- The existing `Marketplace` commit (`5452df9`) already satisfies FR-001/002/004/006/007/009/010/011; the tasks above close the gaps on FR-003, FR-005 (auto-detect path), FR-008, and FR-012. diff --git a/tests/unit/ctl/test_marketplace_app.py b/tests/unit/ctl/test_marketplace_app.py new file mode 100644 index 00000000..da15ef31 --- /dev/null +++ b/tests/unit/ctl/test_marketplace_app.py @@ -0,0 +1,448 @@ +from pathlib import Path + +import httpx +import pytest +from pytest_httpx import HTTPXMock +from typer.testing import CliRunner + +from infrahub_sdk.ctl.marketplace import app + +runner = CliRunner() + +SCHEMA_YAML = """--- +version: "1.0" +nodes: + - name: Device + namespace: Infra +""" + + +def test_download_schema_latest(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/network-base/download", + text=SCHEMA_YAML, + headers={"x-schema-version": "1.2.0"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base/download", + status_code=404, + json={"detail": "Collection not found"}, + ) + result = runner.invoke(app, ["download", "acme/network-base", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "Downloaded schema acme/network-base v1.2.0" in result.stdout + written = tmp_path / "network-base.yml" + assert written.exists() + assert written.read_text() == SCHEMA_YAML + + +def test_download_schema_specific_version(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + # Auto-detect probes + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/network-base/download", + text=SCHEMA_YAML, + headers={"x-schema-version": "1.2.0"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base/download", + status_code=404, + json={"detail": "Collection not found"}, + ) + # Actual pinned-version download + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/network-base/versions/0.9.0/download", + text=SCHEMA_YAML, + ) + result = runner.invoke(app, ["download", "acme/network-base", "-v", "0.9.0", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "Downloaded schema acme/network-base v0.9.0" in result.stdout + written = tmp_path / "network-base.yml" + assert written.exists() + + +def test_download_collection(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/starter-pack/download", + json={ + "collection": { + "namespace": "acme", + "name": "starter-pack", + "schema_count": 2, + "downloaded_count": 2, + "skipped": [], + }, + "schemas": [ + { + "namespace": "acme", + "name": "network-base", + "semver": "1.0.0", + "filename": "acme-network-base-1.0.0.yml", + "content": SCHEMA_YAML, + }, + { + "namespace": "acme", + "name": "dcim", + "semver": "2.1.0", + "filename": "acme-dcim-2.1.0.yml", + "content": SCHEMA_YAML, + }, + ], + }, + ) + result = runner.invoke(app, ["download", "acme/starter-pack", "-c", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "Downloaded acme/network-base v1.0.0" in result.stdout + assert "Downloaded acme/dcim v2.1.0" in result.stdout + assert "2/2 schemas downloaded" in result.stdout + assert (tmp_path / "starter-pack" / "network-base.yml").exists() + assert (tmp_path / "starter-pack" / "dcim.yml").exists() + + +def test_download_not_found(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/nonexistent/download", + status_code=404, + json={"detail": "Schema not found"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/nonexistent/download", + status_code=404, + json={"detail": "Collection not found"}, + ) + result = runner.invoke(app, ["download", "acme/nonexistent", "-o", str(tmp_path)]) + + assert result.exit_code == 1 + assert "acme/nonexistent" in result.stdout + assert "marketplace.infrahub.app" in result.stdout + + +def test_download_invalid_identifier(tmp_path: Path) -> None: + result = runner.invoke(app, ["download", "invalid-no-slash", "-o", str(tmp_path)]) + + assert result.exit_code == 1 + assert "Invalid identifier" in result.stdout + + +def test_download_custom_marketplace_url(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + httpx_mock.add_response( + method="GET", + url="http://localhost:8000/api/v1/schemas/acme/test/download", + text=SCHEMA_YAML, + headers={"x-schema-version": "1.0.0"}, + ) + httpx_mock.add_response( + method="GET", + url="http://localhost:8000/api/v1/collections/acme/test/download", + status_code=404, + json={"detail": "Collection not found"}, + ) + result = runner.invoke( + app, + ["download", "acme/test", "-o", str(tmp_path), "--marketplace-url", "http://localhost:8000"], + ) + + assert result.exit_code == 0 + assert "Downloaded schema acme/test v1.0.0" in result.stdout + + +def test_autodetect_schema(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/network-base/download", + text=SCHEMA_YAML, + headers={"x-schema-version": "1.2.0"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base/download", + status_code=404, + json={"detail": "Collection not found"}, + ) + result = runner.invoke(app, ["download", "acme/network-base", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "Downloaded schema acme/network-base v1.2.0" in result.stdout + assert (tmp_path / "network-base.yml").exists() + + +def test_autodetect_collection(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/starter-pack/download", + status_code=404, + json={"detail": "Schema not found"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/starter-pack/download", + json={ + "collection": { + "namespace": "acme", + "name": "starter-pack", + "schema_count": 1, + "downloaded_count": 1, + "skipped": [], + }, + "schemas": [ + { + "namespace": "acme", + "name": "network-base", + "semver": "1.0.0", + "filename": "acme-network-base-1.0.0.yml", + "content": SCHEMA_YAML, + }, + ], + }, + ) + result = runner.invoke(app, ["download", "acme/starter-pack", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "Collection acme/starter-pack" in result.stdout + assert "1/1 schemas downloaded" in result.stdout + assert (tmp_path / "starter-pack" / "network-base.yml").exists() + + +def test_autodetect_collision_schema_wins(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/network/download", + text=SCHEMA_YAML, + headers={"x-schema-version": "1.0.0"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/network/download", + json={ + "collection": { + "namespace": "acme", + "name": "network", + "schema_count": 0, + "downloaded_count": 0, + "skipped": [], + }, + "schemas": [], + }, + ) + result = runner.invoke(app, ["download", "acme/network", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "both a schema and a collection" in result.stdout + assert "--collection" in result.stdout + assert "Downloaded schema acme/network v1.0.0" in result.stdout + + +def test_autodetect_network_error(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + httpx_mock.add_exception(httpx.ConnectError("connection refused")) + httpx_mock.add_exception(httpx.ConnectError("connection refused")) + result = runner.invoke(app, ["download", "acme/anything", "-o", str(tmp_path)]) + + assert result.exit_code == 2 + assert "Could not reach marketplace" in result.stdout + assert "marketplace.infrahub.app" in result.stdout + + +def test_version_not_found(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/network-base/download", + text=SCHEMA_YAML, + headers={"x-schema-version": "1.2.0"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base/download", + status_code=404, + json={"detail": "Collection not found"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/network-base/versions/9.9.9/download", + status_code=404, + json={"detail": "Version not found"}, + ) + result = runner.invoke(app, ["download", "acme/network-base", "-v", "9.9.9", "-o", str(tmp_path)]) + + assert result.exit_code == 1 + assert "9.9.9" in result.stdout + assert "--version" in result.stdout + assert "no published version" in result.stdout + + +def test_version_ignored_on_autodetected_collection(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/starter-pack/download", + status_code=404, + json={"detail": "Schema not found"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/starter-pack/download", + json={ + "collection": { + "namespace": "acme", + "name": "starter-pack", + "schema_count": 1, + "downloaded_count": 1, + "skipped": [], + }, + "schemas": [ + { + "namespace": "acme", + "name": "network-base", + "semver": "1.0.0", + "filename": "acme-network-base-1.0.0.yml", + "content": SCHEMA_YAML, + }, + ], + }, + ) + result = runner.invoke(app, ["download", "acme/starter-pack", "-v", "1.0.0", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "Warning: --version is ignored" in result.stdout + assert (tmp_path / "starter-pack" / "network-base.yml").exists() + + +def test_collection_flag_overrides_autodetect(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/starter-pack/download", + json={ + "collection": { + "namespace": "acme", + "name": "starter-pack", + "schema_count": 1, + "downloaded_count": 1, + "skipped": [], + }, + "schemas": [ + { + "namespace": "acme", + "name": "network-base", + "semver": "1.0.0", + "filename": "acme-network-base-1.0.0.yml", + "content": SCHEMA_YAML, + }, + ], + }, + ) + # No schema endpoint mock — if the implementation probes it, pytest-httpx + # will raise "request not expected". + result = runner.invoke(app, ["download", "acme/starter-pack", "-c", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "Collection acme/starter-pack" in result.stdout + + +def test_output_dir_creates_nested_missing_parents(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/network-base/download", + text=SCHEMA_YAML, + headers={"x-schema-version": "1.2.0"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base/download", + status_code=404, + json={"detail": "Collection not found"}, + ) + nested = tmp_path / "a" / "b" / "c" + result = runner.invoke(app, ["download", "acme/network-base", "-o", str(nested)]) + + assert result.exit_code == 0 + assert (nested / "network-base.yml").exists() + + +def test_output_dir_default_is_schemas(httpx_mock: HTTPXMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/network-base/download", + text=SCHEMA_YAML, + headers={"x-schema-version": "1.2.0"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base/download", + status_code=404, + json={"detail": "Collection not found"}, + ) + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["download", "acme/network-base"]) + + assert result.exit_code == 0 + assert (tmp_path / "schemas" / "network-base.yml").exists() + + +def test_output_dir_permission_error(httpx_mock: HTTPXMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/network-base/download", + text=SCHEMA_YAML, + headers={"x-schema-version": "1.2.0"}, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base/download", + status_code=404, + json={"detail": "Collection not found"}, + ) + original_mkdir = Path.mkdir + + def raising_mkdir(self: Path, *args: object, **kwargs: object) -> None: + if str(self).endswith("/unwritable"): + raise PermissionError(f"[Errno 13] Permission denied: {self}") + original_mkdir(self, *args, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(Path, "mkdir", raising_mkdir) + + target = tmp_path / "unwritable" + result = runner.invoke(app, ["download", "acme/network-base", "-o", str(target)]) + + assert result.exit_code == 1 + assert "Cannot write" in result.stdout + assert "unwritable" in result.stdout + + +def test_download_collection_with_skipped(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/mixed/download", + json={ + "collection": { + "namespace": "acme", + "name": "mixed", + "schema_count": 2, + "downloaded_count": 1, + "skipped": [ + {"namespace": "acme", "name": "broken", "reason": "no published version"}, + ], + }, + "schemas": [ + { + "namespace": "acme", + "name": "good", + "semver": "1.0.0", + "filename": "acme-good-1.0.0.yml", + "content": SCHEMA_YAML, + }, + ], + }, + ) + result = runner.invoke(app, ["download", "acme/mixed", "-c", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "Skipped acme/broken" in result.stdout + assert "1/2 schemas downloaded" in result.stdout