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: 1 addition & 0 deletions changelog/+ed38b6b.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `infrahubctl graphql query-report` to analyze a GraphQL query and report whether it targets unique nodes, which controls whether Infrahub limits artifact regeneration to changed nodes or regenerates all artifacts on any relevant node change. Supports `--online` to fetch the query from the server by name.
1 change: 0 additions & 1 deletion changelog/1047.fixed.md

This file was deleted.

3 changes: 3 additions & 0 deletions changelog/1063.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
`RelatedNode`, `RelatedNodeSync`, `RelationshipManager` and `RelationshipManagerSync` are now generic over their peer type, and `infrahubctl protocols` parameterises generated relationships accordingly (e.g. `device: RelatedNode[NetworkDevice]`, `interfaces: RelationshipManager[NetworkInterface]`).

Traversing a relationship via `.peer`, `.peers` or indexing now preserves the peer's type instead of collapsing to the dynamic `InfrahubNode`, so chains such as `device.rack.peer.name.value` type-check under mypy/ty without casts. Existing un-parameterised `RelatedNode` / `RelationshipManager` usage is unaffected — the peer type defaults to `InfrahubNode` / `InfrahubNodeSync`, preserving current behaviour. ([#1063](https://github.com/opsmill/infrahub-sdk-python/issues/1063))
22 changes: 22 additions & 0 deletions docs/docs/infrahubctl/infrahubctl-graphql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,31 @@ $ infrahubctl graphql [OPTIONS] COMMAND [ARGS]...

**Commands**:

* `query-report`: Run a GraphQL query through...
* `export-schema`: Export the GraphQL schema to a file.
* `generate-return-types`: Create Pydantic Models for GraphQL query...

## `infrahubctl graphql query-report`

Run a GraphQL query through InfrahubGraphQLQueryReport and report its analysis.

**Usage**:

```console
$ infrahubctl graphql query-report [OPTIONS] NAME
```

**Arguments**:

* `NAME`: Name of the GraphQL query to analyze. [required]

**Options**:

* `--online`: Fetch the query from the Infrahub server (CoreGraphQLQuery by name) instead of reading it from the local .infrahub.yml file.
* `--branch TEXT`: Branch on which to run the report.
* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml]
* `--help`: Show this message and exit.

## `infrahubctl graphql export-schema`

Export the GraphQL schema to a file.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,15 @@ fetch(self, timeout: int | None = None) -> None
#### `peer`

```python
peer(self) -> InfrahubNode
peer(self) -> PeerT
```

Return the peer node, or raise ValueError if no identifier is available.

#### `get`

```python
get(self) -> InfrahubNode
get(self) -> PeerT
```

Return the peer node, performing a store lookup if not materialized.
Expand Down Expand Up @@ -135,15 +135,15 @@ fetch(self, timeout: int | None = None) -> None
#### `peer`

```python
peer(self) -> InfrahubNodeSync
peer(self) -> PeerTSync
```

Return the peer node, or raise ValueError if no identifier is available.

#### `get`

```python
get(self) -> InfrahubNodeSync
get(self) -> PeerTSync
```

Return the peer node, performing a store lookup if not materialized.
Expand Down
2 changes: 1 addition & 1 deletion infrahub_sdk/ctl/branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ async def report(
git_files_changed = await check_git_files_changed(client, branch=branch_name)

proposed_changes = await client.filters(
kind=CoreProposedChange, # type: ignore[type-abstract]
kind=CoreProposedChange,
source_branch__value=branch_name,
prefetch_relationships=True,
property=True,
Expand Down
66 changes: 66 additions & 0 deletions infrahub_sdk/ctl/graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@

from ..async_typer import AsyncTyper
from ..ctl.client import initialize_client
from ..ctl.repository import find_repository_config_file, get_repository_config
from ..ctl.utils import catch_exception
from ..graphql.query_renderer import render_query
from ..graphql.utils import (
insert_fragments_inline,
remove_fragment_import,
strip_typename_from_fragment,
strip_typename_from_operation,
)
from ..protocols import CoreGraphQLQuery
from .parameters import CONFIG_PARAM

app = AsyncTyper()
Expand Down Expand Up @@ -97,6 +100,69 @@ def callback() -> None:
"""Various GraphQL related commands."""


QUERY_REPORT_DOCUMENT = """
query ($q: String!) {
InfrahubGraphQLQueryReport(query: $q) {
targets_unique_nodes
}
}
"""


@app.command(name="query-report")
@catch_exception(console=console)
async def query_report(
name: str = typer.Argument(..., help="Name of the GraphQL query to analyze."),
online: bool = typer.Option(
False,
"--online",
help=(
"Fetch the query from the Infrahub server (CoreGraphQLQuery by name) "
"instead of reading it from the local .infrahub.yml file."
),
),
branch: str | None = typer.Option(None, help="Branch on which to run the report."),
_: str = CONFIG_PARAM,
) -> None:
"""Run a GraphQL query through InfrahubGraphQLQueryReport and report its analysis."""
client = initialize_client(branch=branch)

if online:
node = await client.get(
kind=CoreGraphQLQuery, # type: ignore[type-abstract]
name__value=name,
branch=branch,
raise_when_missing=False,
)
if node is None:
console.print(f"[red]GraphQL query {name!r} not found on the server")
raise typer.Exit(1)
query_str = node.query.value
source_label = f"online: id={node.id}"
else:
repository_config = get_repository_config(find_repository_config_file())
query_str = render_query(name=name, config=repository_config)
source_label = f"local: {repository_config.get_query(name).file_path}"

response = await client.execute_graphql(
query=QUERY_REPORT_DOCUMENT,
variables={"q": query_str},
branch_name=branch,
tracker="query-graphql-query-report",
)
targets_unique_nodes = response["InfrahubGraphQLQueryReport"]["targets_unique_nodes"]

header_parts = [source_label]
if branch:
header_parts.append(f"branch: {branch}")
console.print(f"Query {name!r} ({', '.join(header_parts)})")

if targets_unique_nodes:
console.print("Targets unique nodes: [green]true[/green]")
else:
console.print("Targets unique nodes: [yellow]false[/yellow]")


@app.command()
@catch_exception(console=console)
async def export_schema(
Expand Down
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
7 changes: 4 additions & 3 deletions infrahub_sdk/node/attribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, NamedTuple, get_args

from ..protocols_base import CoreNodeBase
from ..uuidt import UUIDT
from .constants import ATTRIBUTE_METADATA_OBJECT, IP_TYPES, PROPERTIES_FLAG, PROPERTIES_OBJECT, SAFE_VALUE
from .property import NodeProperty
Expand Down Expand Up @@ -115,7 +114,7 @@ def _initialize_graphql_payload(self) -> _GraphQLPayloadAttribute:
# Pool-based allocation (dict data or resource-pool node)
if self._from_pool is not None:
return _GraphQLPayloadAttribute(payload={"from_pool": self._from_pool}, variables={}, needs_metadata=True)
if isinstance(self.value, CoreNodeBase) and self.value.is_resource_pool():
if hasattr(self.value, "is_resource_pool") and self.value.is_resource_pool():
return _GraphQLPayloadAttribute(
payload={"from_pool": {"id": self.value.id}}, variables={}, needs_metadata=True
)
Expand Down Expand Up @@ -190,4 +189,6 @@ def is_from_pool_attribute(self) -> bool:
True if the attribute value is a resource pool node or was explicitly allocated from a pool.

"""
return (isinstance(self.value, CoreNodeBase) and self.value.is_resource_pool()) or self._from_pool is not None
return (
hasattr(self.value, "is_resource_pool") and self.value.is_resource_pool()
) or self._from_pool is not None
Loading