Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the chang

<!-- towncrier release notes start -->

## [1.21.1](https://github.com/opsmill/infrahub-sdk-python/tree/v1.21.1) - 2026-06-05

### Changed

- `infrahubctl marketplace get --collection` now downloads each member schema individually into a `<output_dir>/<collection name>/<schema name>.yml` layout (for example `schemas/base-schemas/dcim.yml`), instead of dumping version-suffixed files flat into the output directory. Filenames no longer carry the version, matching single-schema downloads, so re-downloading a collection overwrites cleanly rather than accumulating stale versions. If two members share a schema name across namespaces, those members are written to `<output_dir>/<collection name>/<namespace>/<schema name>.yml` instead of overwriting each other. ([#1057](https://github.com/opsmill/infrahub-sdk-python/issues/1057))

### Fixed

- Fix `infrahubctl` printing a spurious `Error: 1` and Python traceback after the human-readable error message when a command exits with `typer.Exit`. The CLI now exits cleanly with only the intended error output. ([#1047](https://github.com/opsmill/infrahub-sdk-python/issues/1047))

## [1.21.0](https://github.com/opsmill/infrahub-sdk-python/tree/v1.21.0) - 2026-05-29

### Added
Expand Down
1 change: 0 additions & 1 deletion changelog/1047.fixed.md

This file was deleted.

79 changes: 50 additions & 29 deletions infrahub_sdk/ctl/marketplace.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
from __future__ import annotations

import asyncio
import io
import sys
import zipfile
from enum import Enum
from pathlib import Path
from typing import Any, Literal, NamedTuple, NoReturn
Expand Down Expand Up @@ -73,7 +71,7 @@ def _schema_url(base_url: str, namespace: str, name: str, version: str | None =


def _collection_url(base_url: str, namespace: str, name: str) -> str:
return f"{base_url}/api/v1/collections/{namespace}/{name}/download"
return f"{base_url}/api/v1/collections/{namespace}/{name}"


def _is_transport_failure(r: object) -> bool:
Expand Down Expand Up @@ -158,6 +156,7 @@ async def _download_schema(
stdout: bool,
prefetched: httpx.Response | None = None,
schema_confirmed_exists: bool = False,
needs_separator: bool = False,
) -> None:
"""Download a single schema and write it to disk or stdout.

Expand All @@ -166,6 +165,9 @@ async def _download_schema(
``schema_confirmed_exists`` signals that the schema is known to exist (e.g. from
the auto-detect probe), so a 404 on a versioned URL is reported as version-not-found
rather than the generic not-found message.
``needs_separator`` inserts a ``---`` document separator before the content in
stdout mode when it is missing, so multiple schemas streamed back-to-back (e.g.
from a collection) form a valid multi-document YAML stream.
"""
if prefetched is not None and version is None:
resp = prefetched
Expand All @@ -188,6 +190,8 @@ async def _download_schema(
resolved_version = version or resp.headers.get("x-schema-version", "latest")

if stdout:
if needs_separator and not resp.text.lstrip().startswith("---"):
sys.stdout.write("---\n")
sys.stdout.write(resp.text)
if not resp.text.endswith("\n"):
sys.stdout.write("\n")
Expand All @@ -212,11 +216,17 @@ async def _download_collection(
stdout: bool,
prefetched: httpx.Response | None = None,
) -> None:
"""Fetch all schemas in a collection, writing to disk or stdout.

The marketplace returns a ZIP archive of ``{namespace}-{name}-{semver}.yml`` files.
"""Fetch every schema in a collection, writing to disk or stdout.

The collection metadata endpoint lists each member schema along with its latest
published version. Each member is downloaded individually via :func:`_download_schema`
so naming, versioning, and error handling stay identical to single-schema downloads.
On disk, members land in ``output_dir/<collection name>/<schema name>.yml``. If two
members share a name across namespaces, those members are disambiguated into
``output_dir/<collection name>/<namespace>/<schema name>.yml`` instead of silently
overwriting each other.
When ``prefetched`` is supplied (from the auto-detect probe), reuses that response
instead of re-fetching the collection download URL.
instead of re-fetching the collection metadata.
"""
if prefetched:
resp = prefetched
Expand All @@ -230,34 +240,45 @@ async def _download_collection(
resp.raise_for_status()

try:
archive = zipfile.ZipFile(io.BytesIO(resp.content))
except zipfile.BadZipFile:
payload = resp.json()
except ValueError:
_fail(
_ErrorClass.NETWORK,
f"Response from {_collection_url(base_url, namespace, name)} is not a valid ZIP archive",
f"Response from {_collection_url(base_url, namespace, name)} is not valid JSON",
)

schema_names = [info.filename for info in archive.infolist() if not info.is_dir()]
items = payload.get("items", []) if isinstance(payload, dict) else []
schemas = [item.get("schema") for item in items if isinstance(item, dict)]
members: list[dict[str, Any]] = [schema for schema in schemas if isinstance(schema, dict)]
status = _status_console(stdout)
target_dir = output_dir / name

member_names = [schema.get("name") for schema in members if schema.get("namespace") and schema.get("name")]
duplicated_names = {member_name for member_name in member_names if member_names.count(member_name) > 1}

downloaded = 0
for schema in members:
member_namespace = schema.get("namespace")
member_name = schema.get("name")
if not member_namespace or not member_name:
status.print("[yellow]Warning: skipping a collection member with missing namespace or name.")
continue
version = (schema.get("latest_version") or {}).get("semver")
member_dir = target_dir / member_namespace if member_name in duplicated_names else target_dir
await _download_schema(
client=client,
base_url=base_url,
namespace=member_namespace,
name=member_name,
version=version,
output_dir=member_dir,
stdout=stdout,
schema_confirmed_exists=True,
needs_separator=downloaded > 0,
)
downloaded += 1

if stdout:
for index, schema_name in enumerate(schema_names):
content = archive.read(schema_name).decode("utf-8")
if index > 0 and not content.lstrip().startswith("---"):
sys.stdout.write("---\n")
sys.stdout.write(content)
if not content.endswith("\n"):
sys.stdout.write("\n")
err_console.print(f"[green]Fetched {schema_name}")
else:
_mkdir_or_fail(output_dir)
for schema_name in schema_names:
content = archive.read(schema_name).decode("utf-8")
file_path = output_dir / schema_name
file_path.write_text(content, encoding="utf-8")
console.print(f"[green]Downloaded {schema_name} -> {file_path}")

status.print(f"\n[green]Collection {namespace}/{name}: {len(schema_names)} schemas downloaded")
status.print(f"\n[green]Collection {namespace}/{name}: {downloaded} schemas downloaded")


@app.command()
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "infrahub-sdk"
version = "1.21.0"
version = "1.21.1"
description = "Python Client to interact with Infrahub"
authors = [
{name = "OpsMill", email = "info@opsmill.com"}
Expand Down
Loading
Loading