Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/docs/infrahubctl/infrahubctl-marketplace.mdx
Original file line number Diff line number Diff line change
@@ -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 (semver). 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 config/env.
* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml]
* `--help`: Show this message and exit.
2 changes: 2 additions & 0 deletions infrahub_sdk/ctl/cli_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions infrahub_sdk/ctl/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to get rid of the config for infrahubctl as it's pretty much a limited replica of what we have from the SDK config. Can we move this entry to that file instead? Sometime soon I'd like to ditch this file alltogether.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — moved marketplace_url to ConfigBase in infrahub_sdk/config.py (readable via INFRAHUB_MARKETPLACE_URL) and removed it from infrahubctl Settings. marketplace.py now instantiates ConfigBase directly, so the direct config import is gone from that file too.


@field_validator("server_address")
@classmethod
Expand Down
248 changes: 248 additions & 0 deletions infrahub_sdk/ctl/marketplace.py
Original file line number Diff line number Diff line change
@@ -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,
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The above looks like a very loose contract between ErrorClass and the Literals. While there are only a few it still looks brittle as it would fail if anything else was added to ErrorClass later. Just looking at this in isolation this looks like it could have been an Enum where the error codes were the values.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced with _ErrorClass(Enum) where each member carries an exit_code property — the mapping is now co-located with the error definition rather than split across two declarations. Adding a new error class without a matching exit code is a type error rather than a silent runtime gap.



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]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we resolve these added type ignores and ensure that we return the correct types?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — replaced the schema_ok/collection_ok booleans with inline isinstance(r, httpx.Response) and r.status_code == 200 guards directly in the if conditions. The type checker narrows to httpx.Response naturally within each branch, so no cast or TypeGuard needed. mypy and ty both confirm no issues.


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):
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Partial probe failures are misclassified as not-found. A transport failure on either endpoint (when no 200 winner exists) should return a network error, not deterministic not-found.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At infrahub_sdk/ctl/marketplace.py, line 99:

<comment>Partial probe failures are misclassified as not-found. A transport failure on either endpoint (when no 200 winner exists) should return a network error, not deterministic not-found.</comment>

<file context>
@@ -0,0 +1,248 @@
+            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",
</file context>
Fix with Cubic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 76f1948 — changed the guard to or so a transport failure on either probe (when no 200 winner exists) raises network rather than falling through to not-found. Added test_autodetect_partial_probe_failure_is_network to cover the schema-404 + collection-transport-failure case.

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 (semver). 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 config/env.",
),
_: 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:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A consideration here is that when we use a fresh AsyncClient directly like this it doesn't benefit from the configuration we have within the SDK. In this case it would mean that users behind a proxy might not be able to use this part of the tool. As such we should have methods within the client to perform these actions instead. It could be that we need some updates there with regards to the follow_redirects param.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed — added _make_http_client(sdk_cfg) which builds the AsyncClient using ConfigBase's proxy (proxy / proxy_mounts) and tls_context, matching what InfrahubClient._build_proxy_config() does internally. Also added follow_redirects=True as you noted. The get() command now instantiates ConfigBase once and passes it to both the client builder and the URL default.\n\nAgreed that moving the download logic into client methods would be the cleanest end state — happy to do that as a follow-up once the shape is settled.

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":
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Network/HTTP errors during the actual download path can still exit with code 1 because uncaught httpx exceptions are handled by the default decorator exit code.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At infrahub_sdk/ctl/marketplace.py, line 243:

<comment>Network/HTTP errors during the actual download path can still exit with code 1 because uncaught `httpx` exceptions are handled by the default decorator exit code.</comment>

<file context>
@@ -0,0 +1,248 @@
+        else:
+            item_type = "schema"
+
+        if item_type == "collection":
+            if version:
+                console.print("[yellow]Warning: --version is ignored when downloading a collection.")
</file context>
Fix with Cubic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 76f1948 — wrapped the post-detect download dispatch in try/except httpx.HTTPError, re-raising via _fail("network", ...) so transport failures and raise_for_status 5xxs during the actual fetch exit 2 instead of bubbling up to the default @catch_exception (exit 1). Added test_versioned_download_network_error and test_collection_flag_network_error to cover the two paths that make a fresh request after auto-detect (versioned schema fetch and explicit --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)
36 changes: 36 additions & 0 deletions specs/001-marketplace-api-update/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -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`.
Loading
Loading