diff --git a/CHANGELOG.md b/CHANGELOG.md index a33e7899..d9399cd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the chang +## [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 `//.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 `///.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 diff --git a/changelog/+ed38b6b.added.md b/changelog/+ed38b6b.added.md new file mode 100644 index 00000000..5962c226 --- /dev/null +++ b/changelog/+ed38b6b.added.md @@ -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. diff --git a/changelog/1047.fixed.md b/changelog/1047.fixed.md deleted file mode 100644 index dac17c83..00000000 --- a/changelog/1047.fixed.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/changelog/1063.fixed.md b/changelog/1063.fixed.md new file mode 100644 index 00000000..5fde0057 --- /dev/null +++ b/changelog/1063.fixed.md @@ -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)) diff --git a/docs/docs/infrahubctl/infrahubctl-graphql.mdx b/docs/docs/infrahubctl/infrahubctl-graphql.mdx index 0796ce8b..e97d5216 100644 --- a/docs/docs/infrahubctl/infrahubctl-graphql.mdx +++ b/docs/docs/infrahubctl/infrahubctl-graphql.mdx @@ -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. diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx index 8f0ec245..f326e155 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx @@ -98,7 +98,7 @@ 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. @@ -106,7 +106,7 @@ 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. @@ -135,7 +135,7 @@ 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. @@ -143,7 +143,7 @@ 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. diff --git a/infrahub_sdk/ctl/branch.py b/infrahub_sdk/ctl/branch.py index 923b8e55..e0555c5f 100644 --- a/infrahub_sdk/ctl/branch.py +++ b/infrahub_sdk/ctl/branch.py @@ -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, diff --git a/infrahub_sdk/ctl/graphql.py b/infrahub_sdk/ctl/graphql.py index 9bef44e8..37d55137 100644 --- a/infrahub_sdk/ctl/graphql.py +++ b/infrahub_sdk/ctl/graphql.py @@ -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() @@ -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( diff --git a/infrahub_sdk/ctl/marketplace.py b/infrahub_sdk/ctl/marketplace.py index df1cc9a7..1b12e279 100644 --- a/infrahub_sdk/ctl/marketplace.py +++ b/infrahub_sdk/ctl/marketplace.py @@ -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 @@ -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: @@ -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. @@ -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 @@ -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") @@ -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//.yml``. If two + members share a name across namespaces, those members are disambiguated into + ``output_dir///.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 @@ -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() diff --git a/infrahub_sdk/node/attribute.py b/infrahub_sdk/node/attribute.py index c1a424a8..0d5bc05b 100644 --- a/infrahub_sdk/node/attribute.py +++ b/infrahub_sdk/node/attribute.py @@ -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 @@ -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 ) @@ -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 diff --git a/infrahub_sdk/node/related_node.py b/infrahub_sdk/node/related_node.py index 0e496f89..372e553f 100644 --- a/infrahub_sdk/node/related_node.py +++ b/infrahub_sdk/node/related_node.py @@ -1,7 +1,9 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Generic, cast + +from typing_extensions import TypeVar from ..exceptions import Error from ..protocols_base import CoreNodeBase @@ -11,7 +13,14 @@ if TYPE_CHECKING: from ..client import InfrahubClient, InfrahubClientSync from ..schema import RelationshipSchemaAPI - from .node import InfrahubNode, InfrahubNodeSync + from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync + +# Type of the related peer node. Defaults to ``InfrahubNode``/``InfrahubNodeSync`` so that +# existing un-parameterised ``RelatedNode`` / ``RelatedNodeSync`` usage keeps returning the +# dynamic node, while generated protocols can parameterise it (e.g. ``RelatedNode[CoreDevice]``) +# to preserve the peer type through ``.peer`` / ``.get()``. +PeerT = TypeVar("PeerT", default="InfrahubNode") +PeerTSync = TypeVar("PeerTSync", default="InfrahubNodeSync") class RelatedNodeBase: @@ -36,7 +45,7 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, self._properties_object = PROPERTIES_OBJECT self._properties = self._properties_flag + self._properties_object - self._peer = None + self._peer: InfrahubNodeBase | CoreNodeBase | None = None self._id: str | None = None self._hfid: list[str] | None = None self._display_label: str | None = None @@ -45,8 +54,10 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, self._source_typename: str | None = None self._relationship_metadata: RelationshipMetadata | None = None - if isinstance(data, (CoreNodeBase)): - self._peer = data + # Check for InfrahubNodeBase instances using duck-typing (_schema attribute) + # to avoid circular imports, or CoreNodeBase instances + if isinstance(data, CoreNodeBase) or hasattr(data, "_schema"): + self._peer = cast("InfrahubNodeBase | CoreNodeBase", data) for prop in self._properties: setattr(self, prop, None) self._relationship_metadata = None @@ -219,7 +230,7 @@ def _generate_query_data( return data -class RelatedNode(RelatedNodeBase): +class RelatedNode(RelatedNodeBase, Generic[PeerT]): """Represents a RelatedNodeBase in an asynchronous context.""" def __init__( @@ -252,11 +263,11 @@ async def fetch(self, timeout: int | None = None) -> None: ) @property - def peer(self) -> InfrahubNode: + def peer(self) -> PeerT: """Return the peer node, or raise ValueError if no identifier is available.""" return self.get() - def get(self) -> InfrahubNode: + def get(self) -> PeerT: """Return the peer node, performing a store lookup if not materialized. When resolving via hfid_str the returned node has a non-None id even @@ -269,18 +280,18 @@ def get(self) -> InfrahubNode: """ if self._peer: - return self._peer # type: ignore[return-value] + return cast("PeerT", self._peer) if self.id and self.typename: - return self._client.store.get(key=self.id, kind=self.typename, branch=self._branch) # type: ignore[return-value] + return cast("PeerT", self._client.store.get(key=self.id, kind=self.typename, branch=self._branch)) if self.hfid_str: - return self._client.store.get(key=self.hfid_str, branch=self._branch) # type: ignore[return-value] + return cast("PeerT", self._client.store.get(key=self.hfid_str, branch=self._branch)) raise ValueError("Node must have at least one identifier (ID or HFID) to query it.") -class RelatedNodeSync(RelatedNodeBase): +class RelatedNodeSync(RelatedNodeBase, Generic[PeerTSync]): """Represents a related node in a synchronous context.""" def __init__( @@ -313,11 +324,11 @@ def fetch(self, timeout: int | None = None) -> None: ) @property - def peer(self) -> InfrahubNodeSync: + def peer(self) -> PeerTSync: """Return the peer node, or raise ValueError if no identifier is available.""" return self.get() - def get(self) -> InfrahubNodeSync: + def get(self) -> PeerTSync: """Return the peer node, performing a store lookup if not materialized. When resolving via hfid_str the returned node has a non-None id even @@ -330,12 +341,12 @@ def get(self) -> InfrahubNodeSync: """ if self._peer: - return self._peer # type: ignore[return-value] + return cast("PeerTSync", self._peer) if self.id and self.typename: - return self._client.store.get(key=self.id, kind=self.typename, branch=self._branch) # type: ignore[return-value] + return cast("PeerTSync", self._client.store.get(key=self.id, kind=self.typename, branch=self._branch)) if self.hfid_str: - return self._client.store.get(key=self.hfid_str, branch=self._branch) # type: ignore[return-value] + return cast("PeerTSync", self._client.store.get(key=self.hfid_str, branch=self._branch)) raise ValueError("Node must have at least one identifier (ID or HFID) to query it.") diff --git a/infrahub_sdk/node/relationship.py b/infrahub_sdk/node/relationship.py index ae09edfe..338d1299 100644 --- a/infrahub_sdk/node/relationship.py +++ b/infrahub_sdk/node/relationship.py @@ -2,7 +2,7 @@ from collections import defaultdict from collections.abc import Iterable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Generic, cast from ..exceptions import ( Error, @@ -11,7 +11,7 @@ from ..types import Order from .constants import PROPERTIES_FLAG, PROPERTIES_OBJECT from .metadata import NodeMetadata, RelationshipMetadata -from .related_node import RelatedNode, RelatedNodeSync +from .related_node import PeerT, PeerTSync, RelatedNode, RelatedNodeSync if TYPE_CHECKING: from ..client import InfrahubClient, InfrahubClientSync @@ -19,7 +19,7 @@ from .node import InfrahubNode, InfrahubNodeSync -class RelationshipManagerBase: +class RelationshipManagerBase(Generic[PeerT]): """Base class for RelationshipManager and RelationshipManagerSync.""" def __init__(self, name: str, branch: str, schema: RelationshipSchemaAPI) -> None: @@ -41,7 +41,7 @@ def __init__(self, name: str, branch: str, schema: RelationshipSchemaAPI) -> Non self._properties_object = PROPERTIES_OBJECT self._properties = self._properties_flag + self._properties_object - self.peers: list[RelatedNode | RelatedNodeSync] = [] + self.peers: list[RelatedNode[PeerT] | RelatedNodeSync[PeerT]] = [] @property def peer_ids(self) -> list[str]: @@ -115,7 +115,7 @@ def _generate_query_data( return data -class RelationshipManager(RelationshipManagerBase): +class RelationshipManager(RelationshipManagerBase[PeerT]): """Manages relationships of a node in an asynchronous context.""" def __init__( @@ -155,12 +155,18 @@ def __init__( if isinstance(data, list): for item in data: self.peers.append( - RelatedNode(name=name, client=self.client, branch=self.branch, schema=schema, data=item) + cast( + "RelatedNode[PeerT]", + RelatedNode(name=name, client=self.client, branch=self.branch, schema=schema, data=item), + ) ) elif isinstance(data, dict) and "edges" in data: for item in data["edges"]: self.peers.append( - RelatedNode(name=name, client=self.client, branch=self.branch, schema=schema, data=item) + cast( + "RelatedNode[PeerT]", + RelatedNode(name=name, client=self.client, branch=self.branch, schema=schema, data=item), + ) ) else: raise ValueError( @@ -169,8 +175,8 @@ def __init__( f"Wrap the value in a list, e.g. {name}=[value]." ) - def __getitem__(self, item: int) -> RelatedNode: - return self.peers[item] # type: ignore[return-value] + def __getitem__(self, item: int) -> RelatedNode[PeerT]: + return cast("RelatedNode[PeerT]", self.peers[item]) async def fetch(self) -> None: if not self.initialized: @@ -217,7 +223,9 @@ def add(self, data: str | RelatedNode | dict) -> None: """ if not self.initialized: raise UninitializedError("Must call fetch() on RelationshipManager before editing members") - new_node = RelatedNode(schema=self.schema, client=self.client, branch=self.branch, data=data) + new_node = cast( + "RelatedNode[PeerT]", RelatedNode(schema=self.schema, client=self.client, branch=self.branch, data=data) + ) if (new_node.id and new_node.id not in self.peer_ids) or ( new_node.hfid and new_node.hfid not in self.peer_hfids @@ -252,7 +260,7 @@ def remove(self, data: str | RelatedNode | dict) -> None: self._has_update = True -class RelationshipManagerSync(RelationshipManagerBase): +class RelationshipManagerSync(RelationshipManagerBase[PeerTSync]): """Manages relationships of a node in a synchronous context.""" def __init__( @@ -292,12 +300,18 @@ def __init__( if isinstance(data, list): for item in data: self.peers.append( - RelatedNodeSync(name=name, client=self.client, branch=self.branch, schema=schema, data=item) + cast( + "RelatedNodeSync[PeerTSync]", + RelatedNodeSync(name=name, client=self.client, branch=self.branch, schema=schema, data=item), + ) ) elif isinstance(data, dict) and "edges" in data: for item in data["edges"]: self.peers.append( - RelatedNodeSync(name=name, client=self.client, branch=self.branch, schema=schema, data=item) + cast( + "RelatedNodeSync[PeerTSync]", + RelatedNodeSync(name=name, client=self.client, branch=self.branch, schema=schema, data=item), + ) ) else: raise ValueError( @@ -306,8 +320,8 @@ def __init__( f"Wrap the value in a list, e.g. {name}=[value]." ) - def __getitem__(self, item: int) -> RelatedNodeSync: - return self.peers[item] # type: ignore[return-value] + def __getitem__(self, item: int) -> RelatedNodeSync[PeerTSync]: + return cast("RelatedNodeSync[PeerTSync]", self.peers[item]) def fetch(self) -> None: if not self.initialized: @@ -354,7 +368,10 @@ def add(self, data: str | RelatedNodeSync | dict) -> None: """ if not self.initialized: raise UninitializedError("Must call fetch() on RelationshipManager before editing members") - new_node = RelatedNodeSync(schema=self.schema, client=self.client, branch=self.branch, data=data) + new_node = cast( + "RelatedNodeSync[PeerTSync]", + RelatedNodeSync(schema=self.schema, client=self.client, branch=self.branch, data=data), + ) if (new_node.id and new_node.id not in self.peer_ids) or ( new_node.hfid and new_node.hfid not in self.peer_hfids diff --git a/infrahub_sdk/protocols_base.py b/infrahub_sdk/protocols_base.py index cacd8025..57d4f23f 100644 --- a/infrahub_sdk/protocols_base.py +++ b/infrahub_sdk/protocols_base.py @@ -172,8 +172,7 @@ class AnyAttributeOptional(Attribute): value: float | None -@runtime_checkable -class CoreNodeBase(Protocol): +class CoreNodeBase: _schema: MainSchemaTypes _internal_id: str id: str # NOTE this is incorrect, should be str | None @@ -190,25 +189,30 @@ def get_human_friendly_id(self) -> list[str] | None: ... def get_human_friendly_id_as_string(self, include_kind: bool = False) -> str | None: ... - def get_kind(self) -> str: ... + def get_kind(self) -> str: + raise NotImplementedError - def get_all_kinds(self) -> list[str]: ... + def get_all_kinds(self) -> list[str]: + raise NotImplementedError - def get_branch(self) -> str: ... + def get_branch(self) -> str: + raise NotImplementedError - def is_ip_prefix(self) -> bool: ... + def is_ip_prefix(self) -> bool: + raise NotImplementedError - def is_ip_address(self) -> bool: ... + def is_ip_address(self) -> bool: + raise NotImplementedError - def is_resource_pool(self) -> bool: ... + def is_resource_pool(self) -> bool: + raise NotImplementedError def get_raw_graphql_data(self) -> dict | None: ... def get_node_metadata(self) -> NodeMetadata | None: ... -@runtime_checkable -class CoreNode(CoreNodeBase, Protocol): +class CoreNode(CoreNodeBase): async def save( self, allow_upsert: bool = False, @@ -232,8 +236,7 @@ async def add_relationships(self, relation_to_update: str, related_nodes: list[s async def remove_relationships(self, relation_to_update: str, related_nodes: list[str]) -> None: ... -@runtime_checkable -class CoreNodeSync(CoreNodeBase, Protocol): +class CoreNodeSync(CoreNodeBase): def save( self, allow_upsert: bool = False, diff --git a/infrahub_sdk/protocols_generator/generator.py b/infrahub_sdk/protocols_generator/generator.py index 0d4c6108..d26e9313 100644 --- a/infrahub_sdk/protocols_generator/generator.py +++ b/infrahub_sdk/protocols_generator/generator.py @@ -123,10 +123,10 @@ def _jinja2_filter_render_attribute(value: AttributeSchemaAPI) -> str: return f"{value.name}: {attribute_kind}" - @staticmethod - def _jinja2_filter_render_relationship(value: RelationshipSchemaAPI, sync: bool = False) -> str: + def _jinja2_filter_render_relationship(self, value: RelationshipSchemaAPI, sync: bool = False) -> str: name = value.name cardinality = value.cardinality + peer = value.peer type_ = "RelatedNode" if cardinality == RelationshipCardinality.MANY: @@ -134,8 +134,13 @@ def _jinja2_filter_render_relationship(value: RelationshipSchemaAPI, sync: bool if sync: type_ += "Sync" + # Core peer protocols expose a dedicated ``*Sync`` variant; reference it in sync + # output. Locally generated node/generic classes keep their name (they already + # inherit the sync base class), so only swap when a ``*Sync`` peer actually exists. + if f"{peer}Sync" in self.base_protocols: + peer = f"{peer}Sync" - return f"{name}: {type_}" + return f"{name}: {type_}[{peer}]" @staticmethod def _sort_and_filter_models( diff --git a/infrahub_sdk/protocols_generator/template.j2 b/infrahub_sdk/protocols_generator/template.j2 index 2f4f9e89..eeb2ef6c 100644 --- a/infrahub_sdk/protocols_generator/template.j2 +++ b/infrahub_sdk/protocols_generator/template.j2 @@ -52,8 +52,8 @@ class {{ generic.namespace + generic.name }}({{core_node_name}}): {{ relationship | render_relationship(sync) }} {% endfor %} {% if generic.hierarchical | default(false) %} - parent: {{ "RelatedNode" | syncify(sync) }} - children: {{ "RelationshipManager" | syncify(sync) }} + parent: {{ "RelatedNode" | syncify(sync) }}[{{ generic.namespace + generic.name }}] + children: {{ "RelationshipManager" | syncify(sync) }}[{{ generic.namespace + generic.name }}] {% endif %} {% endfor %} @@ -71,8 +71,8 @@ class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | jo {{ relationship | render_relationship(sync) }} {% endfor %} {% if node.hierarchical | default(false) %} - parent: {{ "RelatedNode" | syncify(sync) }} - children: {{ "RelationshipManager" | syncify(sync) }} + parent: {{ "RelatedNode" | syncify(sync) }}[{{ node.namespace + node.name }}] + children: {{ "RelationshipManager" | syncify(sync) }}[{{ node.namespace + node.name }}] {% endif %} {% endfor %} @@ -91,8 +91,8 @@ class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | jo {{ relationship | render_relationship(sync) }} {% endfor %} {% if node.hierarchical | default(false) %} - parent: {{ "RelatedNode" | syncify(sync) }} - children: {{ "RelationshipManager" | syncify(sync) }} + parent: {{ "RelatedNode" | syncify(sync) }}[{{ node.namespace + node.name }}] + children: {{ "RelationshipManager" | syncify(sync) }}[{{ node.namespace + node.name }}] {% endif %} {% endfor %} diff --git a/infrahub_sdk/schema/__init__.py b/infrahub_sdk/schema/__init__.py index 31b5444a..051485fd 100644 --- a/infrahub_sdk/schema/__init__.py +++ b/infrahub_sdk/schema/__init__.py @@ -21,6 +21,7 @@ ValidationError, ) from ..graphql import Mutation +from ..protocols_base import CoreNodeBase from ..queries import SCHEMA_HASH_SYNC_STATUS from .export import RESTRICTED_NAMESPACES, NamespaceExport, SchemaExport, schema_to_export_dict from .main import ( @@ -253,14 +254,14 @@ def _get_schema_name(schema: type[SchemaType | SchemaTypeSync] | str) -> str: if isinstance(schema, str): return schema - if hasattr(schema, "_is_runtime_protocol") and getattr(schema, "_is_runtime_protocol", None): + if issubclass(schema, CoreNodeBase): if inspect.iscoroutinefunction(schema.save): return schema.__name__ if schema.__name__[-4:] == "Sync": return schema.__name__[:-4] return schema.__name__ - raise ValueError("schema must be a protocol or a string") + raise ValueError("schema must be a CoreNode subclass or a string") @staticmethod def _parse_schema_response(response: httpx.Response, branch: str) -> MutableMapping[str, Any]: diff --git a/infrahub_sdk/store.py b/infrahub_sdk/store.py index 479badd1..9b061ec3 100644 --- a/infrahub_sdk/store.py +++ b/infrahub_sdk/store.py @@ -1,8 +1,11 @@ from __future__ import annotations +import inspect import warnings from typing import TYPE_CHECKING, Literal, overload +from infrahub_sdk.protocols_base import CoreNodeBase + from .exceptions import NodeInvalidError, NodeNotFoundError from .node.parsers import parse_human_friendly_id @@ -16,8 +19,15 @@ def get_schema_name(schema: type[SchemaType | SchemaTypeSync] | str | None = Non if isinstance(schema, str): return schema - if hasattr(schema, "_is_runtime_protocol") and schema._is_runtime_protocol: # type: ignore[union-attr] - return schema.__name__ # type: ignore[union-attr] + if schema is None: + return None + + if issubclass(schema, CoreNodeBase): + if inspect.iscoroutinefunction(schema.save): + return schema.__name__ + if schema.__name__[-4:] == "Sync": + return schema.__name__[:-4] + return schema.__name__ return None diff --git a/infrahub_sdk/testing/repository.py b/infrahub_sdk/testing/repository.py index fab269d1..941f118e 100644 --- a/infrahub_sdk/testing/repository.py +++ b/infrahub_sdk/testing/repository.py @@ -98,7 +98,7 @@ async def wait_for_sync_to_complete( ) -> bool: for _ in range(retries): repo = await client.get( - kind=CoreGenericRepository, # type: ignore[type-abstract] + kind=CoreGenericRepository, name__value=self.name, branch=branch or self.initial_branch, ) diff --git a/pyproject.toml b/pyproject.toml index ba9d7465..b09850ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"} diff --git a/tests/integration/test_infrahubctl.py b/tests/integration/test_infrahubctl.py index 35971df5..6fb20190 100644 --- a/tests/integration/test_infrahubctl.py +++ b/tests/integration/test_infrahubctl.py @@ -2,6 +2,7 @@ import json import os +import re import shutil import tempfile from pathlib import Path @@ -116,6 +117,25 @@ def test_infrahubctl_transform_cmd_convert_animal_person(self, repository: str, "person": "Liam Walker", } + def test_infrahubctl_graphql_query_report(self, repository: str, base_dataset: None) -> None: + """Run query-report end-to-end against the live backend resolver.""" + with change_directory(repository): + result = runner.invoke(app, ["graphql", "query-report", "tags_query"]) + + assert result.exit_code == 0, strip_color(result.stdout) + output = re.sub(r"\s+", " ", strip_color(result.stdout)) + assert "tags_query" in output + assert "Targets unique nodes: true" in output + + def test_infrahubctl_graphql_query_report_branch(self, repository: str, base_dataset: None) -> None: + """The --branch flag routes the report to the requested branch.""" + with change_directory(repository): + result = runner.invoke(app, ["graphql", "query-report", "tags_query", "--branch", "branch01"]) + + assert result.exit_code == 0, strip_color(result.stdout) + output = re.sub(r"\s+", " ", strip_color(result.stdout)) + assert "branch: branch01" in output + async def test_infrahubctl_generator_cmd_animal_tags( self, repository: str, base_dataset: None, client: InfrahubClient ) -> None: diff --git a/tests/unit/ctl/test_graphql_query_report.py b/tests/unit/ctl/test_graphql_query_report.py new file mode 100644 index 00000000..d29c3708 --- /dev/null +++ b/tests/unit/ctl/test_graphql_query_report.py @@ -0,0 +1,222 @@ +"""Tests for `infrahubctl graphql query-report`.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +from typer.testing import CliRunner + +from infrahub_sdk.ctl.graphql import app +from tests.constants import FIXTURE_REPOS_DIR +from tests.helpers.utils import strip_color, temp_repo_and_cd + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + + +def _flatten(text: str) -> str: + """Strip ANSI colors and collapse whitespace so wrapped Rich output can be substring-matched.""" + return re.sub(r"\s+", " ", strip_color(text)).strip() + + +pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True) + +runner = CliRunner() + +CTL_INTEGRATION_FIXTURE = FIXTURE_REPOS_DIR / "ctl_integration" + +REPORT_RESPONSE_TRUE = {"data": {"InfrahubGraphQLQueryReport": {"targets_unique_nodes": True}}} +REPORT_RESPONSE_FALSE = {"data": {"InfrahubGraphQLQueryReport": {"targets_unique_nodes": False}}} + + +def test_query_report_local_returns_true(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/main", + json=REPORT_RESPONSE_TRUE, + match_headers={"X-Infrahub-Tracker": "query-graphql-query-report"}, + ) + + with temp_repo_and_cd(source_dir=CTL_INTEGRATION_FIXTURE): + result = runner.invoke(app, ["query-report", "tags_query"]) + + assert result.exit_code == 0, strip_color(result.stdout) + output = _flatten(result.stdout) + assert "Query 'tags_query' (local: templates/tags_query.gql)" in output + assert "branch:" not in output + assert "Targets unique nodes: true" in output + + +def test_query_report_local_returns_false(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/main", + json=REPORT_RESPONSE_FALSE, + match_headers={"X-Infrahub-Tracker": "query-graphql-query-report"}, + ) + + with temp_repo_and_cd(source_dir=CTL_INTEGRATION_FIXTURE): + result = runner.invoke(app, ["query-report", "tags_query"]) + + assert result.exit_code == 0, strip_color(result.stdout) + output = _flatten(result.stdout) + assert "Targets unique nodes: false" in output + + +def test_query_report_local_uses_branch(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/feature-x", + json=REPORT_RESPONSE_TRUE, + match_headers={"X-Infrahub-Tracker": "query-graphql-query-report"}, + ) + + with temp_repo_and_cd(source_dir=CTL_INTEGRATION_FIXTURE): + result = runner.invoke(app, ["query-report", "tags_query", "--branch", "feature-x"]) + + assert result.exit_code == 0, strip_color(result.stdout) + output = _flatten(result.stdout) + assert "branch: feature-x" in output + assert "local: templates/tags_query.gql" in output + assert "Targets unique nodes: true" in output + + +def test_query_report_local_unknown_query(httpx_mock: HTTPXMock) -> None: + with temp_repo_and_cd(source_dir=CTL_INTEGRATION_FIXTURE): + result = runner.invoke(app, ["query-report", "does_not_exist"]) + + assert result.exit_code == 1 + assert "does_not_exist" in strip_color(result.stdout) + + +def test_query_report_local_inlines_fragments(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """When the query uses fragments, the rendered query sent to the server has them inlined.""" + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/main", + json=REPORT_RESPONSE_TRUE, + match_headers={"X-Infrahub-Tracker": "query-graphql-query-report"}, + ) + + config_file = tmp_path / ".infrahub.yml" + config_file.write_text( + """ +queries: + - name: with_fragment + file_path: queries/with_fragment.gql +graphql_fragments: + - name: tag_fields + file_path: fragments/tag_fields.gql +""".strip(), + encoding="UTF-8", + ) + queries_dir = tmp_path / "queries" + queries_dir.mkdir() + (queries_dir / "with_fragment.gql").write_text( + "query WithFragment { BuiltinTag { edges { node { ...tag_fields } } } }", + encoding="UTF-8", + ) + fragments_dir = tmp_path / "fragments" + fragments_dir.mkdir() + (fragments_dir / "tag_fields.gql").write_text( + "fragment tag_fields on BuiltinTag { id name { value } }", + encoding="UTF-8", + ) + + with temp_repo_and_cd(source_dir=tmp_path): + result = runner.invoke(app, ["query-report", "with_fragment"]) + + assert result.exit_code == 0, strip_color(result.stdout) + + requests = httpx_mock.get_requests( + method="POST", + url="http://mock/graphql/main", + match_headers={"X-Infrahub-Tracker": "query-graphql-query-report"}, + ) + assert len(requests) == 1 + sent_body = requests[0].content.decode("utf-8") + assert "fragment tag_fields" in sent_body + assert "...tag_fields" in sent_body + + +@pytest.fixture +def mock_core_graphql_query_lookup(httpx_mock: HTTPXMock) -> HTTPXMock: + response = { + "data": { + "CoreGraphQLQuery": { + "count": 1, + "edges": [ + { + "node": { + "id": "11111111-1111-1111-1111-111111111111", + "display_label": "remote_query", + "__typename": "CoreGraphQLQuery", + "name": { + "value": "remote_query", + "is_default": False, + "is_from_profile": False, + "source": None, + "owner": None, + }, + "query": { + "value": "query Remote { BuiltinTag { edges { node { id } } } }", + "is_default": False, + "is_from_profile": False, + "source": None, + "owner": None, + }, + } + } + ], + } + } + } + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/main", + json=response, + match_headers={"X-Infrahub-Tracker": "query-coregraphqlquery-page1"}, + is_reusable=True, + ) + return httpx_mock + + +def test_query_report_online_happy_path( + mock_schema_query_05: HTTPXMock, + mock_core_graphql_query_lookup: HTTPXMock, +) -> None: + mock_core_graphql_query_lookup.add_response( + method="POST", + url="http://mock/graphql/main", + json=REPORT_RESPONSE_TRUE, + match_headers={"X-Infrahub-Tracker": "query-graphql-query-report"}, + ) + + result = runner.invoke(app, ["query-report", "remote_query", "--online"]) + + assert result.exit_code == 0, strip_color(result.stdout) + output = _flatten(result.stdout) + assert "Query 'remote_query' (online: id=11111111-1111-1111-1111-111111111111)" in output + assert "branch:" not in output + assert "Targets unique nodes: true" in output + + +def test_query_report_online_not_found( + mock_schema_query_05: HTTPXMock, +) -> None: + mock_schema_query_05.add_response( + method="POST", + url="http://mock/graphql/main", + json={"data": {"CoreGraphQLQuery": {"count": 0, "edges": []}}}, + match_headers={"X-Infrahub-Tracker": "query-coregraphqlquery-page1"}, + ) + + result = runner.invoke(app, ["query-report", "missing", "--online"]) + + assert result.exit_code == 1 + output = strip_color(result.stdout) + assert "missing" in output + assert "not found" in output diff --git a/tests/unit/ctl/test_marketplace_app.py b/tests/unit/ctl/test_marketplace_app.py index 8edcc6ef..4f302a51 100644 --- a/tests/unit/ctl/test_marketplace_app.py +++ b/tests/unit/ctl/test_marketplace_app.py @@ -1,5 +1,3 @@ -import io -import zipfile from pathlib import Path import httpx @@ -21,12 +19,17 @@ """ -def _make_zip(files: dict[str, str]) -> bytes: - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w") as zf: - for filename, content in files.items(): - zf.writestr(filename, content) - return buf.getvalue() +def _collection_json(members: list[tuple[str, str, str]]) -> dict: + """Build collection metadata mimicking the marketplace endpoint. + + ``members`` is a list of ``(namespace, name, semver)`` tuples. + """ + return { + "items": [ + {"schema": {"namespace": ns, "name": name, "latest_version": {"semver": semver}}} + for ns, name, semver in members + ] + } def test_download_schema_specific_version(httpx_mock: HTTPXMock, tmp_path: Path) -> None: @@ -39,7 +42,7 @@ def test_download_schema_specific_version(httpx_mock: HTTPXMock, tmp_path: Path) ) httpx_mock.add_response( method="GET", - url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base/download", + url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base", status_code=404, json={"detail": "Collection not found"}, ) @@ -61,22 +64,27 @@ def test_download_schema_specific_version(httpx_mock: HTTPXMock, tmp_path: Path) 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", - content=_make_zip( - { - "acme-network-base-1.0.0.yml": SCHEMA_YAML, - "acme-dcim-2.1.0.yml": SCHEMA_YAML, - } - ), + url="https://marketplace.infrahub.app/api/v1/collections/acme/starter-pack", + json=_collection_json([("acme", "network-base", "1.0.0"), ("acme", "dcim", "2.1.0")]), + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/network-base/versions/1.0.0/download", + text=SCHEMA_YAML, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/dcim/versions/2.1.0/download", + text=SCHEMA_YAML, ) result = runner.invoke(app, ["get", "acme/starter-pack", "-c", "-o", str(tmp_path)]) assert result.exit_code == 0 - assert "Downloaded acme-network-base-1.0.0.yml" in result.output - assert "Downloaded acme-dcim-2.1.0.yml" in result.output + assert "Downloaded schema acme/network-base v1.0.0" in result.output + assert "Downloaded schema acme/dcim v2.1.0" in result.output assert "2 schemas downloaded" in result.output - assert (tmp_path / "acme-network-base-1.0.0.yml").exists() - assert (tmp_path / "acme-dcim-2.1.0.yml").exists() + 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: @@ -88,7 +96,7 @@ 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/collections/acme/nonexistent/download", + url="https://marketplace.infrahub.app/api/v1/collections/acme/nonexistent", status_code=404, json={"detail": "Collection not found"}, ) @@ -115,7 +123,7 @@ def test_download_custom_marketplace_url(httpx_mock: HTTPXMock, tmp_path: Path) ) httpx_mock.add_response( method="GET", - url="http://localhost:8000/api/v1/collections/acme/test/download", + url="http://localhost:8000/api/v1/collections/acme/test", status_code=404, json={"detail": "Collection not found"}, ) @@ -141,7 +149,7 @@ def test_marketplace_url_from_env(httpx_mock: HTTPXMock, tmp_path: Path, monkeyp ) httpx_mock.add_response( method="GET", - url="http://staging.example.com/api/v1/collections/acme/network-base/download", + url="http://staging.example.com/api/v1/collections/acme/network-base", status_code=404, json={"detail": "Collection not found"}, ) @@ -160,7 +168,7 @@ def test_autodetect_schema(httpx_mock: HTTPXMock, tmp_path: Path) -> None: ) httpx_mock.add_response( method="GET", - url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base/download", + url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base", status_code=404, json={"detail": "Collection not found"}, ) @@ -180,15 +188,20 @@ def test_autodetect_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", - content=_make_zip({"acme-network-base-1.0.0.yml": SCHEMA_YAML}), + url="https://marketplace.infrahub.app/api/v1/collections/acme/starter-pack", + json=_collection_json([("acme", "network-base", "1.0.0")]), + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/network-base/versions/1.0.0/download", + text=SCHEMA_YAML, ) result = runner.invoke(app, ["get", "acme/starter-pack", "-o", str(tmp_path)]) assert result.exit_code == 0 assert "Collection acme/starter-pack" in result.output assert "1 schemas downloaded" in result.output - assert (tmp_path / "acme-network-base-1.0.0.yml").exists() + assert (tmp_path / "starter-pack" / "network-base.yml").exists() def test_autodetect_collision_schema_wins(httpx_mock: HTTPXMock, tmp_path: Path) -> None: @@ -200,8 +213,8 @@ def test_autodetect_collision_schema_wins(httpx_mock: HTTPXMock, tmp_path: Path) ) httpx_mock.add_response( method="GET", - url="https://marketplace.infrahub.app/api/v1/collections/acme/network/download", - content=_make_zip({}), + url="https://marketplace.infrahub.app/api/v1/collections/acme/network", + json=_collection_json([]), ) result = runner.invoke(app, ["get", "acme/network", "-o", str(tmp_path)]) @@ -230,7 +243,7 @@ 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/collections/acme/network-base/download", + url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base", status_code=404, json={"detail": "Collection not found"}, ) @@ -257,23 +270,33 @@ def test_version_ignored_on_autodetected_collection(httpx_mock: HTTPXMock, tmp_p ) httpx_mock.add_response( method="GET", - url="https://marketplace.infrahub.app/api/v1/collections/acme/starter-pack/download", - content=_make_zip({"acme-network-base-1.0.0.yml": SCHEMA_YAML}), + url="https://marketplace.infrahub.app/api/v1/collections/acme/starter-pack", + json=_collection_json([("acme", "network-base", "1.0.0")]), + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/network-base/versions/1.0.0/download", + text=SCHEMA_YAML, ) result = runner.invoke(app, ["get", "acme/starter-pack", "-v", "1.0.0", "-o", str(tmp_path)]) assert result.exit_code == 0 assert "Warning: --version is ignored" in result.output - assert (tmp_path / "acme-network-base-1.0.0.yml").exists() + 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", - content=_make_zip({"acme-network-base-1.0.0.yml": SCHEMA_YAML}), + url="https://marketplace.infrahub.app/api/v1/collections/acme/starter-pack", + json=_collection_json([("acme", "network-base", "1.0.0")]), + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/network-base/versions/1.0.0/download", + text=SCHEMA_YAML, ) - # No schema endpoint mock — if the implementation probes it, pytest-httpx + # No schema-detect endpoint mock — if the implementation probes it, pytest-httpx # will raise "request not expected". result = runner.invoke(app, ["get", "acme/starter-pack", "-c", "-o", str(tmp_path)]) @@ -290,7 +313,7 @@ def test_output_dir_creates_nested_missing_parents(httpx_mock: HTTPXMock, tmp_pa ) httpx_mock.add_response( method="GET", - url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base/download", + url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base", status_code=404, json={"detail": "Collection not found"}, ) @@ -310,7 +333,7 @@ def test_output_dir_default_is_schemas(httpx_mock: HTTPXMock, tmp_path: Path, mo ) httpx_mock.add_response( method="GET", - url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base/download", + url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base", status_code=404, json={"detail": "Collection not found"}, ) @@ -330,7 +353,7 @@ def test_output_dir_permission_error(httpx_mock: HTTPXMock, tmp_path: Path, monk ) httpx_mock.add_response( method="GET", - url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base/download", + url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base", status_code=404, json={"detail": "Collection not found"}, ) @@ -351,17 +374,61 @@ def raising_mkdir(self: Path, *args: object, **kwargs: object) -> None: assert "unwritable" in result.output -def test_download_collection_with_skipped(httpx_mock: HTTPXMock, tmp_path: Path) -> None: +def test_download_collection_skips_members_missing_identity(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """A member entry missing namespace/name is skipped rather than aborting the download.""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/mixed", + json={ + "items": [ + {"schema": {"namespace": "acme", "name": "good", "latest_version": {"semver": "1.0.0"}}}, + {"schema": {"name": "orphan", "latest_version": {"semver": "1.0.0"}}}, + ] + }, + ) httpx_mock.add_response( method="GET", - url="https://marketplace.infrahub.app/api/v1/collections/acme/mixed/download", - content=_make_zip({"acme-good-1.0.0.yml": SCHEMA_YAML}), + url="https://marketplace.infrahub.app/api/v1/schemas/acme/good/versions/1.0.0/download", + text=SCHEMA_YAML, ) result = runner.invoke(app, ["get", "acme/mixed", "-c", "-o", str(tmp_path)]) assert result.exit_code == 0 + assert "Downloaded schema acme/good v1.0.0" in result.output + assert "Warning: skipping a collection member" in result.output assert "1 schemas downloaded" in result.output - assert (tmp_path / "acme-good-1.0.0.yml").exists() + assert (tmp_path / "mixed" / "good.yml").exists() + + +def test_download_collection_duplicate_names_across_namespaces(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """Members sharing a name across namespaces land in namespace subdirectories instead of overwriting.""" + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/collections/acme/clash", + json=_collection_json([("acme", "dcim", "1.0.0"), ("other", "dcim", "2.0.0"), ("acme", "ipam", "1.0.0")]), + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/dcim/versions/1.0.0/download", + text=SCHEMA_YAML, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/other/dcim/versions/2.0.0/download", + text=SCHEMA_YAML, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/ipam/versions/1.0.0/download", + text=SCHEMA_YAML, + ) + result = runner.invoke(app, ["get", "acme/clash", "-c", "-o", str(tmp_path)]) + + assert result.exit_code == 0 + assert "3 schemas downloaded" in result.output + assert (tmp_path / "clash" / "acme" / "dcim.yml").exists() + assert (tmp_path / "clash" / "other" / "dcim.yml").exists() + assert (tmp_path / "clash" / "ipam.yml").exists() def test_autodetect_partial_probe_failure_is_network(httpx_mock: HTTPXMock, tmp_path: Path) -> None: @@ -374,7 +441,7 @@ def test_autodetect_partial_probe_failure_is_network(httpx_mock: HTTPXMock, tmp_ ) httpx_mock.add_exception( httpx.ConnectError("connection refused"), - url="https://marketplace.infrahub.app/api/v1/collections/acme/foo/download", + url="https://marketplace.infrahub.app/api/v1/collections/acme/foo", ) result = runner.invoke(app, ["get", "acme/foo", "-o", str(tmp_path)]) @@ -392,7 +459,7 @@ def test_versioned_download_network_error(httpx_mock: HTTPXMock, tmp_path: Path) ) httpx_mock.add_response( method="GET", - url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base/download", + url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base", status_code=404, json={"detail": "Collection not found"}, ) @@ -410,7 +477,7 @@ def test_collection_flag_network_error(httpx_mock: HTTPXMock, tmp_path: Path) -> """A network failure on the explicit --collection fetch should exit with code 2.""" httpx_mock.add_exception( httpx.ConnectError("connection refused"), - url="https://marketplace.infrahub.app/api/v1/collections/acme/foo/download", + url="https://marketplace.infrahub.app/api/v1/collections/acme/foo", ) result = runner.invoke(app, ["get", "acme/foo", "-c", "-o", str(tmp_path)]) @@ -422,7 +489,7 @@ def test_network_error_empty_message_shows_exception_type(httpx_mock: HTTPXMock, """When an httpx exception has no message (e.g. ReadTimeout), the type name is shown.""" httpx_mock.add_exception( httpx.ReadTimeout(""), - url="https://marketplace.infrahub.app/api/v1/collections/acme/foo/download", + url="https://marketplace.infrahub.app/api/v1/collections/acme/foo", ) result = runner.invoke(app, ["get", "acme/foo", "-c", "-o", str(tmp_path)]) @@ -439,7 +506,7 @@ def test_get_schema_stdout(httpx_mock: HTTPXMock, tmp_path: Path) -> None: ) httpx_mock.add_response( method="GET", - url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base/download", + url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base", status_code=404, json={"detail": "Collection not found"}, ) @@ -454,20 +521,25 @@ def test_get_schema_stdout(httpx_mock: HTTPXMock, tmp_path: Path) -> None: def test_get_collection_stdout(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", - content=_make_zip( - { - "acme-network-base-1.0.0.yml": SCHEMA_YAML, - "acme-dcim-2.1.0.yml": SCHEMA_YAML, - } - ), + url="https://marketplace.infrahub.app/api/v1/collections/acme/starter-pack", + json=_collection_json([("acme", "network-base", "1.0.0"), ("acme", "dcim", "2.1.0")]), + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/network-base/versions/1.0.0/download", + text=SCHEMA_YAML, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/dcim/versions/2.1.0/download", + text=SCHEMA_YAML, ) result = runner.invoke(app, ["get", "acme/starter-pack", "-c", "--stdout", "-o", str(tmp_path)]) assert result.exit_code == 0 assert SCHEMA_YAML in result.output - assert "Fetched acme-network-base-1.0.0.yml" in result.output - assert "Fetched acme-dcim-2.1.0.yml" in result.output + assert "Fetched schema acme/network-base v1.0.0" in result.output + assert "Fetched schema acme/dcim v2.1.0" in result.output assert "2 schemas downloaded" in result.output assert not any(tmp_path.iterdir()) @@ -477,8 +549,18 @@ def test_get_collection_stdout_separator(httpx_mock: HTTPXMock, tmp_path: Path) bare_yaml = 'version: "1.0"\nnodes: []\n' httpx_mock.add_response( method="GET", - url="https://marketplace.infrahub.app/api/v1/collections/acme/bare/download", - content=_make_zip({"acme-a-1.0.0.yml": bare_yaml, "acme-b-1.0.0.yml": bare_yaml}), + url="https://marketplace.infrahub.app/api/v1/collections/acme/bare", + json=_collection_json([("acme", "a", "1.0.0"), ("acme", "b", "1.0.0")]), + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/a/versions/1.0.0/download", + text=bare_yaml, + ) + httpx_mock.add_response( + method="GET", + url="https://marketplace.infrahub.app/api/v1/schemas/acme/b/versions/1.0.0/download", + text=bare_yaml, ) result = runner.invoke(app, ["get", "acme/bare", "-c", "--stdout", "-o", str(tmp_path)]) @@ -498,7 +580,7 @@ async def test_collection_false_autodetects_schema(httpx_mock: HTTPXMock, tmp_pa ) httpx_mock.add_response( method="GET", - url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base/download", + url="https://marketplace.infrahub.app/api/v1/collections/acme/network-base", status_code=404, json={"detail": "Collection not found"}, ) diff --git a/tests/unit/sdk/test_protocols_generator.py b/tests/unit/sdk/test_protocols_generator.py index 730f962c..81d75fb2 100644 --- a/tests/unit/sdk/test_protocols_generator.py +++ b/tests/unit/sdk/test_protocols_generator.py @@ -113,13 +113,13 @@ class LocationSite(LocationGeneric): name: String physical_address: StringOptional shortname: String - children: RelationshipManagerSync - member_of_groups: RelationshipManagerSync - parent: RelatedNodeSync - profiles: RelationshipManagerSync - servers: RelationshipManagerSync - subscriber_of_groups: RelationshipManagerSync - tags: RelationshipManagerSync + children: RelationshipManagerSync[LocationRack] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + parent: RelatedNodeSync[LocationCountry] + profiles: RelationshipManagerSync[CoreProfileSync] + servers: RelationshipManagerSync[NetworkManagementServer] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + tags: RelationshipManagerSync[BuiltinTagSync] """ assert location_site_sync in sync_protocols diff --git a/tests/unit/sdk/test_store.py b/tests/unit/sdk/test_store.py index 83644aae..eaeab57c 100644 --- a/tests/unit/sdk/test_store.py +++ b/tests/unit/sdk/test_store.py @@ -6,7 +6,8 @@ from infrahub_sdk.exceptions import NodeInvalidError, NodeNotFoundError from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync -from infrahub_sdk.store import NodeStore, NodeStoreSync +from infrahub_sdk.protocols import BuiltinIPAddressSync, BuiltinIPPrefix +from infrahub_sdk.store import NodeStore, NodeStoreSync, get_schema_name if TYPE_CHECKING: from infrahub_sdk.schema import NodeSchemaAPI @@ -157,3 +158,8 @@ def test_node_store_get_with_hfid( store.get(kind="BuiltinLocation", key="anotherkey") with pytest.raises(NodeNotFoundError): store.get(key="anotherkey") + + +def test_store_get_schema_name() -> None: + assert get_schema_name(schema=BuiltinIPPrefix) == BuiltinIPPrefix.__name__ + assert get_schema_name(schema=BuiltinIPAddressSync) == BuiltinIPAddressSync.__name__[:-4] diff --git a/uv.lock b/uv.lock index e66623aa..70142375 100644 --- a/uv.lock +++ b/uv.lock @@ -725,7 +725,7 @@ wheels = [ [[package]] name = "infrahub-sdk" -version = "1.21.0" +version = "1.21.1" source = { editable = "." } dependencies = [ { name = "dulwich" },