From 1390e2fea01452047c5f1b513eed161934cbcbc2 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Thu, 18 Jun 2026 10:53:05 +0100 Subject: [PATCH 1/5] feat(sdk): add graph traversal support for Infrahub 1.10+ Add traverse_paths(), path_exists(), and reachable_nodes() client methods (with sync equivalents) for discovering how nodes are connected without knowing the relationship path in advance. - traverse_paths: shortest path(s) between a source and destination node - path_exists: boolean convenience wrapper for checks - reachable_nodes: nodes of given kinds reachable from a source Source/destination accept node ids or InfrahubNode instances; kind filters accept kind strings or protocol classes; each result PathNode exposes .fetch() to resolve the full node (store-backed). Calling against a pre-1.10 server raises a clear VersionNotSupportedError. Co-Authored-By: Claude Opus 4.8 (1M context) --- changelog/+graph-traversal.added.md | 1 + .../python-sdk/guides/graph_traversal.mdx | 330 +++++++++++++++++ .../sdk_ref/infrahub_sdk/client.mdx | 116 ++++++ .../infrahub_sdk/graph_traversal/models.mdx | 70 ++++ .../infrahub_sdk/graph_traversal/query.mdx | 43 +++ docs/sidebars/sidebars-python-sdk.ts | 1 + infrahub_sdk/client.py | 340 +++++++++++++++++- infrahub_sdk/exceptions.py | 10 + infrahub_sdk/graph_traversal/__init__.py | 23 ++ infrahub_sdk/graph_traversal/models.py | 143 ++++++++ infrahub_sdk/graph_traversal/query.py | 101 ++++++ tasks.py | 1 + tests/unit/sdk/test_graph_traversal.py | 323 +++++++++++++++++ 13 files changed, 1501 insertions(+), 1 deletion(-) create mode 100644 changelog/+graph-traversal.added.md create mode 100644 docs/docs/python-sdk/guides/graph_traversal.mdx create mode 100644 docs/docs/python-sdk/sdk_ref/infrahub_sdk/graph_traversal/models.mdx create mode 100644 docs/docs/python-sdk/sdk_ref/infrahub_sdk/graph_traversal/query.mdx create mode 100644 infrahub_sdk/graph_traversal/__init__.py create mode 100644 infrahub_sdk/graph_traversal/models.py create mode 100644 infrahub_sdk/graph_traversal/query.py create mode 100644 tests/unit/sdk/test_graph_traversal.py diff --git a/changelog/+graph-traversal.added.md b/changelog/+graph-traversal.added.md new file mode 100644 index 00000000..fa3b780a --- /dev/null +++ b/changelog/+graph-traversal.added.md @@ -0,0 +1 @@ +Added graph traversal support for Infrahub 1.10: `InfrahubClient.traverse_paths()` (shortest path(s) between two nodes), `InfrahubClient.reachable_nodes()` (nodes of given kinds reachable from a source), and `InfrahubClient.path_exists()` (boolean convenience for checks) — all with sync equivalents. Source and destination accept either a node id or an `InfrahubNode`; kind filters accept kind strings or protocol classes; each `PathNode` in the result exposes `.fetch()` to resolve the full node (store-backed). Calling these against a pre-1.10 server raises a clear `VersionNotSupportedError`. diff --git a/docs/docs/python-sdk/guides/graph_traversal.mdx b/docs/docs/python-sdk/guides/graph_traversal.mdx new file mode 100644 index 00000000..6c692c34 --- /dev/null +++ b/docs/docs/python-sdk/guides/graph_traversal.mdx @@ -0,0 +1,330 @@ +--- +title: Traversing the graph +--- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Traversing the graph + +Graph traversal lets you discover how nodes are connected without knowing the relationship path in advance. The SDK exposes two client methods: + +- `traverse_paths` — find the shortest path(s) between a **source** and a **destination** node. +- `reachable_nodes` — find every node of given **kinds** that is reachable from a **source** node, with the path used to reach each one. + +:::note + +Graph traversal requires **Infrahub 1.10 or later**. Calling either method against an older server raises `VersionNotSupportedError`. + +::: + +## Finding the path between two nodes + +Pass the source and destination as node UUID strings **or** `InfrahubNode` instances (the SDK reads the node's id for you). Paths are returned shortest-first. Each path is a list of `hops`, and each hop exposes the `node` visited and the `relationship` traversed to reach it (the first hop's `relationship` is `None`). + +```python +# both of these work — and you can mix them +device = await client.get(kind="InterfacePhysical", id=src_id) +await client.traverse_paths(device, dst_id) # node + id +await client.traverse_paths(src_id, dst_id) # id + id +``` + + + + + ```python + result = await client.traverse_paths( + source="1891a122-8875-bae7-3866-10658751d7cc", + destination="1891a12b-27e5-fe3e-386c-1065983045b0", + max_depth=8, + ) + + print(f"{result.count} path(s) found") + for path in result.paths: + for hop in path.hops: + arrow = f" --[{hop.relationship.from_rel}]--> " if hop.relationship else "" + print(f"{arrow}{hop.node.display_label}", end="") + print() + ``` + + + + + ```python + result = client.traverse_paths( + source="1891a122-8875-bae7-3866-10658751d7cc", + destination="1891a12b-27e5-fe3e-386c-1065983045b0", + max_depth=8, + ) + + print(f"{result.count} path(s) found") + ``` + + + + +### Constraining the traversal + +All limits and filters are optional; when omitted, the server applies its own defaults. Unknown kinds (for example IP namespaces) are excluded by default and reported back in `result.excluded_kinds`. + +The kind filters (`kind_filter`, `excluded_kinds`, `included_kinds`, and `target_kinds` on `reachable_nodes`) accept either kind-name strings or generated protocol classes — mix them freely: + +```python +from infrahub_sdk.protocols import DcimCable, InterfacePhysical + +result = await client.traverse_paths( + source=src, + destination=dst, + kind_filter=[DcimCable, InterfacePhysical, "DcimFrontPatchPanelInterface"], +) +``` + +:::note + +`relationship_filter` matches the schema **relationship identifier** (the canonical name shared by both sides of a relationship, for example `dcimconnector__dcimendpoint`) — **not** the per-side `from_rel` / `to_rel` names shown in the result. Find identifiers via the schema: `(await client.schema.get(kind="InterfacePhysical")).relationships[i].identifier`. + +::: + + + + + ```python + result = await client.traverse_paths( + source=src, + destination=dst, + max_depth=10, + max_paths=5, + kind_filter=["DcimCable", "InterfacePhysical"], # only traverse through these kinds + relationship_filter=["dcimconnector__dcimendpoint"], # schema relationship identifier + included_kinds=["IpamIPPrefix"], # re-include a default-excluded kind + ) + ``` + + + + + ```python + result = client.traverse_paths( + source=src, + destination=dst, + max_depth=10, + max_paths=5, + kind_filter=["DcimCable", "InterfacePhysical"], + relationship_filter=["dcimconnector__dcimendpoint"], + included_kinds=["IpamIPPrefix"], + ) + ``` + + + + +## Checking that a path exists + +A common use in an Infrahub check is "is A still connected to B?". `path_exists` answers that in one call — it requests a single path (the cheapest way to know) and returns a boolean. It takes the same source/destination and filter arguments as `traverse_paths`. + + + + + ```python + connected = await client.path_exists( + device_a, + device_b, + max_depth=8, + kind_filter=["DcimCable", "InterfacePhysical"], + ) + if not connected: + self.log_error(message=f"No path between {device_a.display_label} and {device_b.display_label}") + ``` + + + + + ```python + connected = client.path_exists(device_a, device_b, max_depth=8) + if not connected: + self.log_error(message="Expected path is missing") + ``` + + + + +## Discovering reachable nodes + +Use `reachable_nodes` for impact or dependency analysis: "what nodes of these kinds can I reach from here?" Each entry includes the reachable `node`, the `depth` at which it was found, and the full `path` from the source. + + + + + ```python + result = await client.reachable_nodes( + source=device_id, + target_kinds=["DcimCable", "InfraCircuit"], + max_depth=5, + max_results=100, + shortest_paths_only=True, + ) + + for dep in result.dependencies: + print(f"{dep.node.kind:20} {dep.node.display_label} (depth {dep.depth})") + ``` + + + + + ```python + result = client.reachable_nodes( + source=device_id, + target_kinds=["DcimCable", "InfraCircuit"], + max_depth=5, + max_results=100, + shortest_paths_only=True, + ) + ``` + + + + +## Working with the results + +Results are typed objects, not raw dictionaries. A returned `PathNode` is a lightweight identity (`id`, `kind`, `label`, `display_label`, `hfid`) — it does not carry the node's attributes or relationships. + +To get the full node, call `.fetch()` on any `PathNode`. It resolves the node through the same client (and branch) that produced the traversal and adds it to the client store, so fetching the same id again is served from the store. + + + + + ```python + for hop in result.paths[0].hops: + print(hop.node.display_label) # identity, no request + full = await hop.node.fetch() # resolve the full node + print(full.name.value) + ``` + + + + + ```python + for hop in result.paths[0].hops: + print(hop.node.display_label) # identity, no request + full = hop.node.fetch() # resolve the full node + print(full.name.value) + ``` + + + + +:::note + +`.fetch()` issues one request per node. For large results where you need many full nodes, prefer fetching only the ones you actually need, or batch them with `client.get` inside an `InfrahubBatch`. + +::: + +### Detecting truncated results + +The server caps the number of paths/results. Compare `count` to your requested limit to know whether more may exist: + +```python +result = await client.reachable_nodes(source=src, target_kinds=["InfraDevice"], max_results=50) +if result.count >= 50: + print("Result may be truncated — increase max_results to see more.") +``` + +## Common check patterns + +These are the recurring questions graph traversal answers in an Infrahub check. The examples are async; the sync client mirrors them without `await`. + +**Connectivity required** — A must reach B: + +```python +if not await client.path_exists(a, b): + self.log_error(message="A is no longer connected to B") +``` + +**Isolation / segmentation** — A must *not* reach B (the same primitive, negated): + +```python +if await client.path_exists(a, b): + self.log_error(message="A and B must remain isolated") +``` + +**Path must avoid a kind** — no path may pass through a given kind: + +```python +if not await client.path_exists(a, b, excluded_kinds=["LabReservedThing"]): + self.log_error(message="No compliant path that avoids lab-reserved nodes") +``` + +**Path must traverse a kind** — every A→B path must pass through, for example, a firewall: + +```python +result = await client.traverse_paths(a, b) +for path in result.paths: + if not any(hop.node.kind == "SecurityFirewall" for hop in path.hops): + self.log_error(message="Found a path that bypasses the firewall") +``` + +**Within N hops** — A and B must be no more than 3 hops apart: + +```python +result = await client.traverse_paths(a, b, max_depth=3) +if not result.paths: + self.log_error(message="A and B are more than 3 hops apart (or not connected)") +``` + +**Reach all required targets** — several required services must each be reachable from a device: + +```python +for service_id in dns_and_ntp_ids: + if not await client.path_exists(device, service_id): + self.log_error(message=f"{device.display_label} cannot reach required service {service_id}") +``` + +The patterns above use `path_exists` because they ask about a *specific* destination. When the check is about a *kind* of node rather than a specific one, use `reachable_nodes` instead. + +**Reach at least one node of a kind** — a device must be able to reach some DNS server: + +```python +result = await client.reachable_nodes(device, target_kinds=["NetworkDnsServer"], max_results=1) +if result.count == 0: + self.log_error(message=f"{device.display_label} cannot reach any DNS server") +``` + +**Redundancy by kind** — at least two NTP servers must be reachable: + +```python +result = await client.reachable_nodes(device, target_kinds=["NetworkNtpServer"]) +if result.count < 2: + self.log_error(message=f"{device.display_label} reaches only {result.count} NTP server(s); expected at least 2") +``` + +**Forbidden reach (blast radius / segmentation)** — a device must not reach any sensitive asset: + +```python +result = await client.reachable_nodes(device, target_kinds=["SecuritySensitiveAsset"], max_results=1) +if result.count: + self.log_error(message=f"{device.display_label} can reach a sensitive asset it should be isolated from") +``` + +**Report dependencies** — enumerate what a node depends on, with how far away each is: + +```python +result = await client.reachable_nodes(device, target_kinds=["InfraCircuit", "DcimCable"]) +for dep in result.dependencies: + self.log_info(message=f"depends on {dep.node.display_label} ({dep.node.kind}) at depth {dep.depth}") +``` + +## Handling older servers + +```python +from infrahub_sdk.exceptions import VersionNotSupportedError + +try: + result = await client.traverse_paths(source=src, destination=dst) +except VersionNotSupportedError as exc: + print(exc) # Graph path traversal requires Infrahub 1.10 or later. +``` + +:::note + +An empty result is **not** an error: when no path or no reachable node exists within the limits, `count` is `0` and the list is empty. A request for a node that does not exist raises a `GraphQLError`. + +::: diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx index 11d55b1c..a4146bd8 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx @@ -119,6 +119,64 @@ count(self, kind: str | type[SchemaType], at: Timestamp | None = None, branch: s Return the number of nodes of a given kind. +#### `traverse_paths` + +```python +traverse_paths(self, source: str | InfrahubNode, destination: str | InfrahubNode) -> PathTraversalResult +``` + +Find the shortest path(s) between two nodes in the graph. + +``source`` and ``destination`` accept either a node UUID string or an +``InfrahubNode`` instance. Kind filters (``kind_filter``, ``excluded_kinds``, +``included_kinds``) accept kind-name strings and/or generated protocol classes. +``relationship_filter`` matches schema relationship identifiers (for example +``dcimconnector__dcimendpoint``), not the per-side names shown in the result. + +Requires Infrahub 1.10 or later. See https://docs.infrahub.app for details. + +**Raises:** + +- `VersionNotSupportedError`: If the server does not support graph traversal (pre-1.10). +- `GraphQLError`: When the GraphQL response contains errors (e.g. unknown node). + +#### `path_exists` + +```python +path_exists(self, source: str | InfrahubNode, destination: str | InfrahubNode) -> bool +``` + +Return whether at least one path connects ``source`` to ``destination``. + +Convenience wrapper around :meth:`traverse_paths` for checks: it requests a single +path (the cheapest way to answer "is there a path?") and returns ``True`` if one was +found. Accepts the same source/destination and filter arguments as ``traverse_paths``. + +Requires Infrahub 1.10 or later. + +**Raises:** + +- `VersionNotSupportedError`: If the server does not support graph traversal (pre-1.10). +- `GraphQLError`: When the GraphQL response contains errors (e.g. unknown node). + +#### `reachable_nodes` + +```python +reachable_nodes(self, source: str | InfrahubNode, target_kinds: list[str | type[SchemaType]]) -> ReachableNodesResult +``` + +Find all nodes of the given kinds reachable from a source node. + +``source`` accepts either a node UUID string or an ``InfrahubNode`` instance. +``target_kinds`` accepts kind-name strings and/or generated protocol classes. + +Requires Infrahub 1.10 or later. See https://docs.infrahub.app for details. + +**Raises:** + +- `VersionNotSupportedError`: If the server does not support graph traversal (pre-1.10). +- `GraphQLError`: When the GraphQL response contains errors (e.g. unknown node). + #### `all` ```python @@ -564,6 +622,64 @@ count(self, kind: str | type[SchemaType], at: Timestamp | None = None, branch: s Return the number of nodes of a given kind. +#### `traverse_paths` + +```python +traverse_paths(self, source: str | InfrahubNodeSync, destination: str | InfrahubNodeSync) -> PathTraversalResult +``` + +Find the shortest path(s) between two nodes in the graph. + +``source`` and ``destination`` accept either a node UUID string or an +``InfrahubNode`` instance. Kind filters (``kind_filter``, ``excluded_kinds``, +``included_kinds``) accept kind-name strings and/or generated protocol classes. +``relationship_filter`` matches schema relationship identifiers (for example +``dcimconnector__dcimendpoint``), not the per-side names shown in the result. + +Requires Infrahub 1.10 or later. See https://docs.infrahub.app for details. + +**Raises:** + +- `VersionNotSupportedError`: If the server does not support graph traversal (pre-1.10). +- `GraphQLError`: When the GraphQL response contains errors (e.g. unknown node). + +#### `path_exists` + +```python +path_exists(self, source: str | InfrahubNodeSync, destination: str | InfrahubNodeSync) -> bool +``` + +Return whether at least one path connects ``source`` to ``destination``. + +Convenience wrapper around :meth:`traverse_paths` for checks: it requests a single +path (the cheapest way to answer "is there a path?") and returns ``True`` if one was +found. Accepts the same source/destination and filter arguments as ``traverse_paths``. + +Requires Infrahub 1.10 or later. + +**Raises:** + +- `VersionNotSupportedError`: If the server does not support graph traversal (pre-1.10). +- `GraphQLError`: When the GraphQL response contains errors (e.g. unknown node). + +#### `reachable_nodes` + +```python +reachable_nodes(self, source: str | InfrahubNodeSync, target_kinds: list[str | type[SchemaTypeSync]]) -> ReachableNodesResult +``` + +Find all nodes of the given kinds reachable from a source node. + +``source`` accepts either a node UUID string or an ``InfrahubNode`` instance. +``target_kinds`` accepts kind-name strings and/or generated protocol classes. + +Requires Infrahub 1.10 or later. See https://docs.infrahub.app for details. + +**Raises:** + +- `VersionNotSupportedError`: If the server does not support graph traversal (pre-1.10). +- `GraphQLError`: When the GraphQL response contains errors (e.g. unknown node). + #### `all` ```python diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/graph_traversal/models.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/graph_traversal/models.mdx new file mode 100644 index 00000000..abde8526 --- /dev/null +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/graph_traversal/models.mdx @@ -0,0 +1,70 @@ +--- +title: models +sidebarTitle: models +--- + +# `infrahub_sdk.graph_traversal.models` + +Pydantic models for the Infrahub graph-traversal queries (Infrahub 1.10+). + +These mirror the server GraphQL types for ``InfrahubPathTraversal`` and +``InfrahubReachableNodes``. The server returns snake_case field names, so the +Python attributes map directly without aliasing. Models ignore unknown fields +so additive server changes do not break parsing. + +## Classes + +### `GraphTraversalModel` + +Base for all traversal models: tolerate unknown/extra server fields. + +### `PathNode` + +Identity of a node encountered during a traversal. + +This is a lightweight identity (no attributes or relationships). Use +:meth:`fetch` to resolve it into the full SDK node when needed. + +**Methods:** + +#### `fetch` + +```python +fetch(self, timeout: int | None = None) -> Any +``` + +Resolve this node into the full SDK node. + +On an async client you await the return value (``await node.fetch()``); on a +sync client it returns the node directly. The result is added to the client store, +so fetching the same id again is served from the store. + +**Raises:** + +- `Error`: If this node is not bound to a client (for example, constructed manually). + +### `PathRelationship` + +A relationship (edge) traversed between two nodes. + +### `PathHop` + +A single step in a path: the node visited and the relationship used to reach it. + +``relationship`` is ``None`` for the source-anchored first hop. + +### `Path` + +One route between two nodes, as an ordered list of hops. + +### `PathTraversalResult` + +Result of :meth:`InfrahubClient.traverse_paths`. + +### `ReachableNode` + +A node reachable from the source, with the path used to reach it. + +### `ReachableNodesResult` + +Result of :meth:`InfrahubClient.reachable_nodes`. diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/graph_traversal/query.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/graph_traversal/query.mdx new file mode 100644 index 00000000..a96c4ae4 --- /dev/null +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/graph_traversal/query.mdx @@ -0,0 +1,43 @@ +--- +title: query +sidebarTitle: query +--- + +# `infrahub_sdk.graph_traversal.query` + +GraphQL query strings and variable builders for graph traversal (Infrahub 1.10+). + +Both server queries accept a single complex input object passed as the GraphQL +variable ``$data``. The input field names are snake_case on the wire, matching +the SDK keyword arguments, so the variable dict is built directly with unset +optional fields omitted (the server applies its own defaults). + +## Functions + +### `is_unknown_field_error` + +```python +is_unknown_field_error(errors: list[dict[str, Any]], field_name: str) -> bool +``` + +Return True if the GraphQL errors indicate ``field_name`` is an unknown query field. + +Used to detect a pre-1.10 server that lacks the traversal queries, so the SDK can +raise a clear version error instead of surfacing an opaque validation failure. The +server's own runtime errors (such as "Source node not found") do not match. + +### `build_path_traversal_input` + +```python +build_path_traversal_input(source_id: str, destination_id: str) -> dict[str, Any] +``` + +Build the ``PathTraversalInput`` variable, omitting unset optional fields. + +### `build_reachable_nodes_input` + +```python +build_reachable_nodes_input(source_id: str, target_kinds: list[str]) -> dict[str, Any] +``` + +Build the ``ReachableNodesInput`` variable, omitting unset optional fields. diff --git a/docs/sidebars/sidebars-python-sdk.ts b/docs/sidebars/sidebars-python-sdk.ts index ea68b6ca..82c461a1 100644 --- a/docs/sidebars/sidebars-python-sdk.ts +++ b/docs/sidebars/sidebars-python-sdk.ts @@ -11,6 +11,7 @@ const guidesItems = getItemsWithOrder( 'guides/installation', 'guides/client', 'guides/query_data', + 'guides/graph_traversal', 'guides/create_update_delete', 'guides/branches', 'guides/store', diff --git a/infrahub_sdk/client.py b/infrahub_sdk/client.py index fd516f1a..b0f08eec 100644 --- a/infrahub_sdk/client.py +++ b/infrahub_sdk/client.py @@ -4,7 +4,7 @@ import copy import logging import time -from collections.abc import AsyncIterator, Callable, Coroutine, Iterator, Mapping, MutableMapping +from collections.abc import AsyncIterator, Callable, Coroutine, Iterable, Iterator, Mapping, MutableMapping from contextlib import asynccontextmanager, contextmanager from datetime import datetime from enum import Enum @@ -32,6 +32,15 @@ ServerNotReachableError, ServerNotResponsiveError, URLNotFoundError, + VersionNotSupportedError, +) +from .graph_traversal.models import PathTraversalResult, ReachableNodesResult +from .graph_traversal.query import ( + PATH_TRAVERSAL_QUERY, + REACHABLE_NODES_QUERY, + build_path_traversal_input, + build_reachable_nodes_input, + is_unknown_field_error, ) from .graphql import MultipartBuilder, Mutation, Query from .node import InfrahubNode, InfrahubNodeSync @@ -117,6 +126,29 @@ def get_kind_as_string(kind: str | type[SchemaType | SchemaTypeSync]) -> str: return kind.__name__ +def _normalize_kinds( + kinds: Iterable[str | type[CoreNode | CoreNodeSync]] | None, +) -> list[str] | None: + """Convert a list of kind names and/or protocol classes into kind-name strings.""" + if kinds is None: + return None + return [get_kind_as_string(kind) for kind in kinds] + + +def _resolve_node_id(node: str | InfrahubNode | InfrahubNodeSync) -> str: + """Return a node UUID from either an id string or an InfrahubNode instance. + + Raises: + Error: If a node instance without an id is provided. + + """ + if isinstance(node, str): + return node + if node.id is None: + raise Error("Cannot use a node without an id as a traversal source or destination.") + return node.id + + class BaseClient: """Base class for InfrahubClient and InfrahubClientSync.""" @@ -640,6 +672,159 @@ async def count( ) return int(response.get(schema.kind, {}).get("count", 0)) + async def traverse_paths( + self, + source: str | InfrahubNode, + destination: str | InfrahubNode, + *, + max_depth: int | None = None, + max_paths: int | None = None, + kind_filter: list[str | type[SchemaType]] | None = None, + relationship_filter: list[str] | None = None, + excluded_namespaces: list[str] | None = None, + excluded_kinds: list[str | type[SchemaType]] | None = None, + included_kinds: list[str | type[SchemaType]] | None = None, + branch: str | None = None, + at: Timestamp | str | None = None, + timeout: int | None = None, + ) -> PathTraversalResult: + """Find the shortest path(s) between two nodes in the graph. + + ``source`` and ``destination`` accept either a node UUID string or an + ``InfrahubNode`` instance. Kind filters (``kind_filter``, ``excluded_kinds``, + ``included_kinds``) accept kind-name strings and/or generated protocol classes. + ``relationship_filter`` matches schema relationship identifiers (for example + ``dcimconnector__dcimendpoint``), not the per-side names shown in the result. + + Requires Infrahub 1.10 or later. See https://docs.infrahub.app for details. + + Raises: + VersionNotSupportedError: If the server does not support graph traversal (pre-1.10). + GraphQLError: When the GraphQL response contains errors (e.g. unknown node). + + """ + data = build_path_traversal_input( + _resolve_node_id(source), + _resolve_node_id(destination), + max_depth=max_depth, + max_paths=max_paths, + kind_filter=_normalize_kinds(kind_filter), + relationship_filter=relationship_filter, + excluded_namespaces=excluded_namespaces, + excluded_kinds=_normalize_kinds(excluded_kinds), + included_kinds=_normalize_kinds(included_kinds), + ) + try: + response = await self.execute_graphql( + query=PATH_TRAVERSAL_QUERY, + variables={"data": data}, + branch_name=branch or self.default_branch, + at=Timestamp(at) if at else None, + timeout=timeout, + tracker="query-path-traversal", + operation_name="InfrahubPathTraversal", + ) + except GraphQLError as exc: + if is_unknown_field_error(exc.errors, "InfrahubPathTraversal"): + raise VersionNotSupportedError("Graph path traversal", "1.10") from exc + raise + result = PathTraversalResult.model_validate(response["InfrahubPathTraversal"]) + return result._bind(self, branch or self.default_branch) + + async def path_exists( + self, + source: str | InfrahubNode, + destination: str | InfrahubNode, + *, + max_depth: int | None = None, + kind_filter: list[str | type[SchemaType]] | None = None, + relationship_filter: list[str] | None = None, + excluded_namespaces: list[str] | None = None, + excluded_kinds: list[str | type[SchemaType]] | None = None, + included_kinds: list[str | type[SchemaType]] | None = None, + branch: str | None = None, + at: Timestamp | str | None = None, + timeout: int | None = None, + ) -> bool: + """Return whether at least one path connects ``source`` to ``destination``. + + Convenience wrapper around :meth:`traverse_paths` for checks: it requests a single + path (the cheapest way to answer "is there a path?") and returns ``True`` if one was + found. Accepts the same source/destination and filter arguments as ``traverse_paths``. + + Requires Infrahub 1.10 or later. + + Raises: + VersionNotSupportedError: If the server does not support graph traversal (pre-1.10). + GraphQLError: When the GraphQL response contains errors (e.g. unknown node). + + """ + result = await self.traverse_paths( + source, + destination, + max_depth=max_depth, + max_paths=1, + kind_filter=kind_filter, + relationship_filter=relationship_filter, + excluded_namespaces=excluded_namespaces, + excluded_kinds=excluded_kinds, + included_kinds=included_kinds, + branch=branch, + at=at, + timeout=timeout, + ) + return bool(result.paths) + + async def reachable_nodes( + self, + source: str | InfrahubNode, + target_kinds: list[str | type[SchemaType]], + *, + max_depth: int | None = None, + max_results: int | None = None, + max_paths: int | None = None, + shortest_paths_only: bool | None = None, + branch: str | None = None, + at: Timestamp | str | None = None, + timeout: int | None = None, + ) -> ReachableNodesResult: + """Find all nodes of the given kinds reachable from a source node. + + ``source`` accepts either a node UUID string or an ``InfrahubNode`` instance. + ``target_kinds`` accepts kind-name strings and/or generated protocol classes. + + Requires Infrahub 1.10 or later. See https://docs.infrahub.app for details. + + Raises: + VersionNotSupportedError: If the server does not support graph traversal (pre-1.10). + GraphQLError: When the GraphQL response contains errors (e.g. unknown node). + + """ + data = build_reachable_nodes_input( + _resolve_node_id(source), + _normalize_kinds(target_kinds) or [], + max_depth=max_depth, + max_results=max_results, + max_paths=max_paths, + shortest_paths_only=shortest_paths_only, + ) + try: + response = await self.execute_graphql( + query=REACHABLE_NODES_QUERY, + variables={"data": data}, + branch_name=branch or self.default_branch, + at=Timestamp(at) if at else None, + timeout=timeout, + tracker="query-reachable-nodes", + operation_name="InfrahubReachableNodes", + ) + except GraphQLError as exc: + if is_unknown_field_error(exc.errors, "InfrahubReachableNodes"): + raise VersionNotSupportedError("Graph reachable-nodes traversal", "1.10") from exc + raise + result = ReachableNodesResult.model_validate(response["InfrahubReachableNodes"]) + return result._bind(self, branch or self.default_branch) + @overload async def all( self, @@ -2147,6 +2332,159 @@ def count( ) return int(response.get(schema.kind, {}).get("count", 0)) + def traverse_paths( + self, + source: str | InfrahubNodeSync, + destination: str | InfrahubNodeSync, + *, + max_depth: int | None = None, + max_paths: int | None = None, + kind_filter: list[str | type[SchemaTypeSync]] | None = None, + relationship_filter: list[str] | None = None, + excluded_namespaces: list[str] | None = None, + excluded_kinds: list[str | type[SchemaTypeSync]] | None = None, + included_kinds: list[str | type[SchemaTypeSync]] | None = None, + branch: str | None = None, + at: Timestamp | str | None = None, + timeout: int | None = None, + ) -> PathTraversalResult: + """Find the shortest path(s) between two nodes in the graph. + + ``source`` and ``destination`` accept either a node UUID string or an + ``InfrahubNode`` instance. Kind filters (``kind_filter``, ``excluded_kinds``, + ``included_kinds``) accept kind-name strings and/or generated protocol classes. + ``relationship_filter`` matches schema relationship identifiers (for example + ``dcimconnector__dcimendpoint``), not the per-side names shown in the result. + + Requires Infrahub 1.10 or later. See https://docs.infrahub.app for details. + + Raises: + VersionNotSupportedError: If the server does not support graph traversal (pre-1.10). + GraphQLError: When the GraphQL response contains errors (e.g. unknown node). + + """ + data = build_path_traversal_input( + _resolve_node_id(source), + _resolve_node_id(destination), + max_depth=max_depth, + max_paths=max_paths, + kind_filter=_normalize_kinds(kind_filter), + relationship_filter=relationship_filter, + excluded_namespaces=excluded_namespaces, + excluded_kinds=_normalize_kinds(excluded_kinds), + included_kinds=_normalize_kinds(included_kinds), + ) + try: + response = self.execute_graphql( + query=PATH_TRAVERSAL_QUERY, + variables={"data": data}, + branch_name=branch or self.default_branch, + at=Timestamp(at) if at else None, + timeout=timeout, + tracker="query-path-traversal", + operation_name="InfrahubPathTraversal", + ) + except GraphQLError as exc: + if is_unknown_field_error(exc.errors, "InfrahubPathTraversal"): + raise VersionNotSupportedError("Graph path traversal", "1.10") from exc + raise + result = PathTraversalResult.model_validate(response["InfrahubPathTraversal"]) + return result._bind(self, branch or self.default_branch) + + def path_exists( + self, + source: str | InfrahubNodeSync, + destination: str | InfrahubNodeSync, + *, + max_depth: int | None = None, + kind_filter: list[str | type[SchemaTypeSync]] | None = None, + relationship_filter: list[str] | None = None, + excluded_namespaces: list[str] | None = None, + excluded_kinds: list[str | type[SchemaTypeSync]] | None = None, + included_kinds: list[str | type[SchemaTypeSync]] | None = None, + branch: str | None = None, + at: Timestamp | str | None = None, + timeout: int | None = None, + ) -> bool: + """Return whether at least one path connects ``source`` to ``destination``. + + Convenience wrapper around :meth:`traverse_paths` for checks: it requests a single + path (the cheapest way to answer "is there a path?") and returns ``True`` if one was + found. Accepts the same source/destination and filter arguments as ``traverse_paths``. + + Requires Infrahub 1.10 or later. + + Raises: + VersionNotSupportedError: If the server does not support graph traversal (pre-1.10). + GraphQLError: When the GraphQL response contains errors (e.g. unknown node). + + """ + result = self.traverse_paths( + source, + destination, + max_depth=max_depth, + max_paths=1, + kind_filter=kind_filter, + relationship_filter=relationship_filter, + excluded_namespaces=excluded_namespaces, + excluded_kinds=excluded_kinds, + included_kinds=included_kinds, + branch=branch, + at=at, + timeout=timeout, + ) + return bool(result.paths) + + def reachable_nodes( + self, + source: str | InfrahubNodeSync, + target_kinds: list[str | type[SchemaTypeSync]], + *, + max_depth: int | None = None, + max_results: int | None = None, + max_paths: int | None = None, + shortest_paths_only: bool | None = None, + branch: str | None = None, + at: Timestamp | str | None = None, + timeout: int | None = None, + ) -> ReachableNodesResult: + """Find all nodes of the given kinds reachable from a source node. + + ``source`` accepts either a node UUID string or an ``InfrahubNode`` instance. + ``target_kinds`` accepts kind-name strings and/or generated protocol classes. + + Requires Infrahub 1.10 or later. See https://docs.infrahub.app for details. + + Raises: + VersionNotSupportedError: If the server does not support graph traversal (pre-1.10). + GraphQLError: When the GraphQL response contains errors (e.g. unknown node). + + """ + data = build_reachable_nodes_input( + _resolve_node_id(source), + _normalize_kinds(target_kinds) or [], + max_depth=max_depth, + max_results=max_results, + max_paths=max_paths, + shortest_paths_only=shortest_paths_only, + ) + try: + response = self.execute_graphql( + query=REACHABLE_NODES_QUERY, + variables={"data": data}, + branch_name=branch or self.default_branch, + at=Timestamp(at) if at else None, + timeout=timeout, + tracker="query-reachable-nodes", + operation_name="InfrahubReachableNodes", + ) + except GraphQLError as exc: + if is_unknown_field_error(exc.errors, "InfrahubReachableNodes"): + raise VersionNotSupportedError("Graph reachable-nodes traversal", "1.10") from exc + raise + result = ReachableNodesResult.model_validate(response["InfrahubReachableNodes"]) + return result._bind(self, branch or self.default_branch) + @overload def all( self, diff --git a/infrahub_sdk/exceptions.py b/infrahub_sdk/exceptions.py index af4237ee..2fc41203 100644 --- a/infrahub_sdk/exceptions.py +++ b/infrahub_sdk/exceptions.py @@ -48,6 +48,16 @@ def __init__(self, errors: list[dict[str, Any]], query: str | None = None, varia super().__init__(self.message) +class VersionNotSupportedError(Error): + """Raised when a feature is used against an Infrahub server version that does not support it.""" + + def __init__(self, feature: str, required_version: str) -> None: + self.feature = feature + self.required_version = required_version + self.message = f"{feature} requires Infrahub {required_version} or later." + super().__init__(self.message) + + class BranchNotFoundError(Error): def __init__(self, identifier: str, message: str | None = None) -> None: self.identifier = identifier diff --git a/infrahub_sdk/graph_traversal/__init__.py b/infrahub_sdk/graph_traversal/__init__.py new file mode 100644 index 00000000..08dcffb1 --- /dev/null +++ b/infrahub_sdk/graph_traversal/__init__.py @@ -0,0 +1,23 @@ +"""Graph traversal support for the Infrahub SDK (requires Infrahub 1.10+).""" + +from __future__ import annotations + +from .models import ( + Path, + PathHop, + PathNode, + PathRelationship, + PathTraversalResult, + ReachableNode, + ReachableNodesResult, +) + +__all__ = [ + "Path", + "PathHop", + "PathNode", + "PathRelationship", + "PathTraversalResult", + "ReachableNode", + "ReachableNodesResult", +] diff --git a/infrahub_sdk/graph_traversal/models.py b/infrahub_sdk/graph_traversal/models.py new file mode 100644 index 00000000..3100766f --- /dev/null +++ b/infrahub_sdk/graph_traversal/models.py @@ -0,0 +1,143 @@ +"""Pydantic models for the Infrahub graph-traversal queries (Infrahub 1.10+). + +These mirror the server GraphQL types for ``InfrahubPathTraversal`` and +``InfrahubReachableNodes``. The server returns snake_case field names, so the +Python attributes map directly without aliasing. Models ignore unknown fields +so additive server changes do not break parsing. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr + +from ..exceptions import Error + +if TYPE_CHECKING: + from ..client import InfrahubClient, InfrahubClientSync + + +class GraphTraversalModel(BaseModel): + """Base for all traversal models: tolerate unknown/extra server fields.""" + + model_config = ConfigDict(extra="ignore") + + +# --- Shared building blocks ------------------------------------------------- + + +class PathNode(GraphTraversalModel): + """Identity of a node encountered during a traversal. + + This is a lightweight identity (no attributes or relationships). Use + :meth:`fetch` to resolve it into the full SDK node when needed. + """ + + id: str + kind: str + label: str + display_label: str + hfid: list[str] = Field(default_factory=list) + + # Bound by the client after parsing so ``fetch()`` can resolve the full node. + _client: InfrahubClient | InfrahubClientSync | None = PrivateAttr(default=None) + _branch: str | None = PrivateAttr(default=None) + + def _bind(self, client: InfrahubClient | InfrahubClientSync, branch: str | None) -> None: + self._client = client + self._branch = branch + + def fetch(self, timeout: int | None = None) -> Any: + """Resolve this node into the full SDK node. + + On an async client you await the return value (``await node.fetch()``); on a + sync client it returns the node directly. The result is added to the client store, + so fetching the same id again is served from the store. + + Raises: + Error: If this node is not bound to a client (for example, constructed manually). + + """ + if self._client is None: + raise Error("This PathNode is not bound to a client and cannot be fetched.") + return self._client.get( + kind=self.kind, id=self.id, populate_store=True, branch=self._branch, timeout=timeout + ) + + +class PathRelationship(GraphTraversalModel): + """A relationship (edge) traversed between two nodes.""" + + from_rel: str + from_label: str + to_rel: str + to_label: str + kind: str + + +class PathHop(GraphTraversalModel): + """A single step in a path: the node visited and the relationship used to reach it. + + ``relationship`` is ``None`` for the source-anchored first hop. + """ + + node: PathNode + relationship: PathRelationship | None = None + + +class Path(GraphTraversalModel): + """One route between two nodes, as an ordered list of hops.""" + + hops: list[PathHop] = Field(default_factory=list) + depth: int + + def _bind(self, client: InfrahubClient | InfrahubClientSync, branch: str | None) -> None: + for hop in self.hops: + hop.node._bind(client, branch) + + +# --- InfrahubPathTraversal result ------------------------------------------- + + +class PathTraversalResult(GraphTraversalModel): + """Result of :meth:`InfrahubClient.traverse_paths`.""" + + paths: list[Path] = Field(default_factory=list) + source: PathNode + destination: PathNode + count: int + excluded_kinds: list[str] = Field(default_factory=list) + + def _bind(self, client: InfrahubClient | InfrahubClientSync, branch: str | None) -> PathTraversalResult: + self.source._bind(client, branch) + self.destination._bind(client, branch) + for path in self.paths: + path._bind(client, branch) + return self + + +# --- InfrahubReachableNodes result ------------------------------------------ + + +class ReachableNode(GraphTraversalModel): + """A node reachable from the source, with the path used to reach it.""" + + node: PathNode + depth: int + path: Path + + +class ReachableNodesResult(GraphTraversalModel): + """Result of :meth:`InfrahubClient.reachable_nodes`.""" + + source: PathNode + dependencies: list[ReachableNode] = Field(default_factory=list) + count: int + + def _bind(self, client: InfrahubClient | InfrahubClientSync, branch: str | None) -> ReachableNodesResult: + self.source._bind(client, branch) + for dependency in self.dependencies: + dependency.node._bind(client, branch) + dependency.path._bind(client, branch) + return self diff --git a/infrahub_sdk/graph_traversal/query.py b/infrahub_sdk/graph_traversal/query.py new file mode 100644 index 00000000..d03cd313 --- /dev/null +++ b/infrahub_sdk/graph_traversal/query.py @@ -0,0 +1,101 @@ +"""GraphQL query strings and variable builders for graph traversal (Infrahub 1.10+). + +Both server queries accept a single complex input object passed as the GraphQL +variable ``$data``. The input field names are snake_case on the wire, matching +the SDK keyword arguments, so the variable dict is built directly with unset +optional fields omitted (the server applies its own defaults). +""" + +from __future__ import annotations + +from typing import Any + +# Selection set shared by both queries. +_PATH_NODE_FIELDS = "id kind label display_label hfid" +_RELATIONSHIP_FIELDS = "from_rel from_label to_rel to_label kind" +_PATH_FIELDS = f"hops {{ node {{ {_PATH_NODE_FIELDS} }} relationship {{ {_RELATIONSHIP_FIELDS} }} }} depth" + +PATH_TRAVERSAL_QUERY = f"""query InfrahubPathTraversal($data: PathTraversalInput!) {{ + InfrahubPathTraversal(data: $data) {{ + paths {{ {_PATH_FIELDS} }} + source {{ {_PATH_NODE_FIELDS} }} + destination {{ {_PATH_NODE_FIELDS} }} + count + excluded_kinds + }} +}}""" + +REACHABLE_NODES_QUERY = f"""query InfrahubReachableNodes($data: ReachableNodesInput!) {{ + InfrahubReachableNodes(data: $data) {{ + source {{ {_PATH_NODE_FIELDS} }} + dependencies {{ + node {{ {_PATH_NODE_FIELDS} }} + depth + path {{ {_PATH_FIELDS} }} + }} + count + }} +}}""" + + +def is_unknown_field_error(errors: list[dict[str, Any]], field_name: str) -> bool: + """Return True if the GraphQL errors indicate ``field_name`` is an unknown query field. + + Used to detect a pre-1.10 server that lacks the traversal queries, so the SDK can + raise a clear version error instead of surfacing an opaque validation failure. The + server's own runtime errors (such as "Source node not found") do not match. + """ + markers = ("cannot query field", "unknown field", "doesn't exist", "does not exist") + for error in errors: + message = str(error.get("message") or "").lower() + if field_name.lower() in message and any(marker in message for marker in markers): + return True + return False + + +def build_path_traversal_input( + source_id: str, + destination_id: str, + *, + max_depth: int | None = None, + max_paths: int | None = None, + kind_filter: list[str] | None = None, + relationship_filter: list[str] | None = None, + excluded_namespaces: list[str] | None = None, + excluded_kinds: list[str] | None = None, + included_kinds: list[str] | None = None, +) -> dict[str, Any]: + """Build the ``PathTraversalInput`` variable, omitting unset optional fields.""" + data: dict[str, Any] = {"source_id": source_id, "destination_id": destination_id} + optional = { + "max_depth": max_depth, + "max_paths": max_paths, + "kind_filter": kind_filter, + "relationship_filter": relationship_filter, + "excluded_namespaces": excluded_namespaces, + "excluded_kinds": excluded_kinds, + "included_kinds": included_kinds, + } + data.update({key: value for key, value in optional.items() if value is not None}) + return data + + +def build_reachable_nodes_input( + source_id: str, + target_kinds: list[str], + *, + max_depth: int | None = None, + max_results: int | None = None, + max_paths: int | None = None, + shortest_paths_only: bool | None = None, +) -> dict[str, Any]: + """Build the ``ReachableNodesInput`` variable, omitting unset optional fields.""" + data: dict[str, Any] = {"source_id": source_id, "target_kinds": target_kinds} + optional = { + "max_depth": max_depth, + "max_results": max_results, + "max_paths": max_paths, + "shortest_paths_only": shortest_paths_only, + } + data.update({key: value for key, value in optional.items() if value is not None}) + return data diff --git a/tasks.py b/tasks.py index 8ff4cb38..048789a6 100644 --- a/tasks.py +++ b/tasks.py @@ -188,6 +188,7 @@ def get_modules_to_document() -> list[str]: # Packages (sub-folders of infrahub_sdk/) to document. # Passed to mdxify as "infrahub_sdk.". packages_to_document = [ + "graph_traversal", "node", ] diff --git a/tests/unit/sdk/test_graph_traversal.py b/tests/unit/sdk/test_graph_traversal.py new file mode 100644 index 00000000..5a92d9f1 --- /dev/null +++ b/tests/unit/sdk/test_graph_traversal.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest + +from infrahub_sdk.exceptions import Error, GraphQLError, VersionNotSupportedError +from infrahub_sdk.graph_traversal.models import PathNode, PathTraversalResult, ReachableNodesResult +from infrahub_sdk.graph_traversal.query import ( + build_path_traversal_input, + build_reachable_nodes_input, + is_unknown_field_error, +) +from infrahub_sdk.node import InfrahubNode +from infrahub_sdk.protocols_base import CoreNode + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + + from infrahub_sdk.schema import NodeSchemaAPI + + from .conftest import BothClients + +PATH_TRAVERSAL_URL = "http://mock/graphql/main" + +PATH_RESULT = { + "paths": [ + { + "depth": 2, + "hops": [ + { + "node": { + "id": "n1", + "kind": "InterfacePhysical", + "label": "Physical Interface", + "display_label": "ha1-a", + "hfid": ["dev", "ha1-a"], + }, + "relationship": None, + }, + { + "node": { + "id": "n2", + "kind": "DcimCable", + "label": "Cable", + "display_label": "Cable-1", + "hfid": [], + }, + "relationship": { + "from_rel": "connector", + "from_label": "Connector", + "to_rel": "connected_endpoints", + "to_label": "Connected Endpoints", + "kind": "Attribute", + }, + }, + ], + } + ], + "source": { + "id": "n1", + "kind": "InterfacePhysical", + "label": "Physical Interface", + "display_label": "ha1-a", + "hfid": [], + }, + "destination": {"id": "n2", "kind": "DcimCable", "label": "Cable", "display_label": "Cable-1", "hfid": []}, + "count": 1, + "excluded_kinds": ["IpamNamespace"], +} + +REACHABLE_RESULT = { + "source": { + "id": "n1", + "kind": "InterfacePhysical", + "label": "Physical Interface", + "display_label": "ha1-a", + "hfid": [], + }, + "dependencies": [ + { + "node": {"id": "n2", "kind": "DcimCable", "label": "Cable", "display_label": "Cable-1", "hfid": []}, + "depth": 1, + "path": { + "depth": 1, + "hops": [ + { + "node": { + "id": "n2", + "kind": "DcimCable", + "label": "Cable", + "display_label": "Cable-1", + "hfid": [], + }, + "relationship": None, + } + ], + }, + } + ], + "count": 1, +} + + +# --- input builders (pure logic) -------------------------------------------- + + +def test_build_path_traversal_input_omits_unset() -> None: + assert build_path_traversal_input("a", "b") == {"source_id": "a", "destination_id": "b"} + + +def test_build_path_traversal_input_includes_set_values() -> None: + data = build_path_traversal_input("a", "b", max_depth=8, max_paths=5, kind_filter=["DcimCable"]) + assert data == { + "source_id": "a", + "destination_id": "b", + "max_depth": 8, + "max_paths": 5, + "kind_filter": ["DcimCable"], + } + + +def test_build_reachable_nodes_input() -> None: + data = build_reachable_nodes_input("a", ["DcimCable"], max_results=10, shortest_paths_only=True) + assert data == { + "source_id": "a", + "target_kinds": ["DcimCable"], + "max_results": 10, + "shortest_paths_only": True, + } + + +# --- unknown-field detection (pure logic) ----------------------------------- + + +def test_is_unknown_field_error_positive() -> None: + errors = [{"message": "Cannot query field 'InfrahubPathTraversal' on type 'Query'."}] + assert is_unknown_field_error(errors, "InfrahubPathTraversal") is True + + +def test_is_unknown_field_error_ignores_runtime_errors() -> None: + errors = [{"message": "Source node not found: abc"}] + assert is_unknown_field_error(errors, "InfrahubPathTraversal") is False + + +# --- model parsing (pure logic) --------------------------------------------- + + +def test_path_traversal_result_parsing() -> None: + result = PathTraversalResult.model_validate(PATH_RESULT) + assert result.count == 1 + assert result.excluded_kinds == ["IpamNamespace"] + path = result.paths[0] + assert path.depth == 2 + assert path.hops[0].relationship is None # source-anchored first hop + assert path.hops[1].relationship is not None + assert path.hops[1].relationship.from_rel == "connector" + assert result.source.hfid == [] + + +def test_reachable_nodes_result_parsing() -> None: + result = ReachableNodesResult.model_validate(REACHABLE_RESULT) + assert result.count == 1 + assert result.dependencies[0].depth == 1 + assert result.dependencies[0].node.kind == "DcimCable" + + +def test_unbound_path_node_fetch_raises() -> None: + node = PathNode.model_validate(PATH_RESULT["source"]) + with pytest.raises(Error, match="not bound to a client"): + node.fetch() + + +async def test_kind_filter_accepts_protocol_classes(clients: BothClients, httpx_mock: HTTPXMock) -> None: + class DcimCable(CoreNode): ... + + httpx_mock.add_response( + method="POST", + url=PATH_TRAVERSAL_URL, + match_headers={"X-Infrahub-Tracker": "query-path-traversal"}, + json={"data": {"InfrahubPathTraversal": PATH_RESULT}}, + ) + await clients.standard.traverse_paths("a", "b", kind_filter=[DcimCable, "InterfacePhysical"]) + + sent = json.loads(httpx_mock.get_requests()[0].content) + assert sent["variables"]["data"]["kind_filter"] == ["DcimCable", "InterfacePhysical"] + + +async def test_traverse_paths_accepts_node_objects( + clients: BothClients, location_schema: NodeSchemaAPI, httpx_mock: HTTPXMock +) -> None: + httpx_mock.add_response( + method="POST", + url=PATH_TRAVERSAL_URL, + match_headers={"X-Infrahub-Tracker": "query-path-traversal"}, + json={"data": {"InfrahubPathTraversal": PATH_RESULT}}, + ) + src = InfrahubNode(client=clients.standard, schema=location_schema, data={"id": "src-uuid"}) + dst = InfrahubNode(client=clients.standard, schema=location_schema, data={"id": "dst-uuid"}) + await clients.standard.traverse_paths(src, dst) + + sent = json.loads(httpx_mock.get_requests()[0].content) + assert sent["variables"]["data"]["source_id"] == "src-uuid" + assert sent["variables"]["data"]["destination_id"] == "dst-uuid" + + +async def test_traverse_paths_node_without_id_raises(clients: BothClients, location_schema: NodeSchemaAPI) -> None: + node = InfrahubNode(client=clients.standard, schema=location_schema, data={}) + with pytest.raises(Error, match="without an id"): + await clients.standard.traverse_paths(node, "dst-uuid") + + +# --- client methods (httpx_mock at the transport boundary) ------------------ + + +@pytest.mark.parametrize("client_type", ["standard", "sync"]) +async def test_traverse_paths_query_and_parse(clients: BothClients, client_type: str, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + url=PATH_TRAVERSAL_URL, + match_headers={"X-Infrahub-Tracker": "query-path-traversal"}, + json={"data": {"InfrahubPathTraversal": PATH_RESULT}}, + ) + if client_type == "standard": + result = await clients.standard.traverse_paths("a", "b", max_depth=8, kind_filter=["DcimCable"]) + else: + result = clients.sync.traverse_paths("a", "b", max_depth=8, kind_filter=["DcimCable"]) + + assert isinstance(result, PathTraversalResult) + assert result.count == 1 + relationship = result.paths[0].hops[1].relationship + assert relationship is not None + assert relationship.to_rel == "connected_endpoints" + assert result.excluded_kinds == ["IpamNamespace"] + # nodes are bound to the originating client so .fetch() can resolve them + expected_client = clients.standard if client_type == "standard" else clients.sync + assert result.paths[0].hops[0].node._client is expected_client + assert result.source._client is expected_client + + +@pytest.mark.parametrize("client_type", ["standard", "sync"]) +async def test_path_exists_true(clients: BothClients, client_type: str, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + url=PATH_TRAVERSAL_URL, + match_headers={"X-Infrahub-Tracker": "query-path-traversal"}, + json={"data": {"InfrahubPathTraversal": PATH_RESULT}}, + ) + if client_type == "standard": + exists = await clients.standard.path_exists("a", "b") + else: + exists = clients.sync.path_exists("a", "b") + + assert exists is True + # path_exists only needs a single path to answer the question + sent = json.loads(httpx_mock.get_requests()[0].content) + assert sent["variables"]["data"]["max_paths"] == 1 + + +@pytest.mark.parametrize("client_type", ["standard", "sync"]) +async def test_path_exists_false(clients: BothClients, client_type: str, httpx_mock: HTTPXMock) -> None: + empty = {**PATH_RESULT, "paths": [], "count": 0} + httpx_mock.add_response( + method="POST", + url=PATH_TRAVERSAL_URL, + match_headers={"X-Infrahub-Tracker": "query-path-traversal"}, + json={"data": {"InfrahubPathTraversal": empty}}, + ) + if client_type == "standard": + exists = await clients.standard.path_exists("a", "b") + else: + exists = clients.sync.path_exists("a", "b") + + assert exists is False + + +@pytest.mark.parametrize("client_type", ["standard", "sync"]) +async def test_reachable_nodes_query_and_parse(clients: BothClients, client_type: str, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + url=PATH_TRAVERSAL_URL, + match_headers={"X-Infrahub-Tracker": "query-reachable-nodes"}, + json={"data": {"InfrahubReachableNodes": REACHABLE_RESULT}}, + ) + if client_type == "standard": + result = await clients.standard.reachable_nodes("a", ["DcimCable"], max_results=5) + else: + result = clients.sync.reachable_nodes("a", ["DcimCable"], max_results=5) + + assert isinstance(result, ReachableNodesResult) + assert result.count == 1 + assert result.dependencies[0].node.kind == "DcimCable" + + +@pytest.mark.parametrize("client_type", ["standard", "sync"]) +async def test_traverse_paths_version_guard(clients: BothClients, client_type: str, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + url=PATH_TRAVERSAL_URL, + json={"errors": [{"message": "Cannot query field 'InfrahubPathTraversal' on type 'Query'."}]}, + ) + with pytest.raises(VersionNotSupportedError, match=r"1\.10"): + if client_type == "standard": + await clients.standard.traverse_paths("a", "b") + else: + clients.sync.traverse_paths("a", "b") + + +@pytest.mark.parametrize("client_type", ["standard", "sync"]) +async def test_traverse_paths_other_graphql_error_propagates( + clients: BothClients, client_type: str, httpx_mock: HTTPXMock +) -> None: + httpx_mock.add_response( + method="POST", + url=PATH_TRAVERSAL_URL, + json={"errors": [{"message": "Source node not found: a"}]}, + ) + with pytest.raises(GraphQLError, match="Source node not found"): + if client_type == "standard": + await clients.standard.traverse_paths("a", "b") + else: + clients.sync.traverse_paths("a", "b") From 3e89d57ce0ad5d6b239f096e1dd7c821a2915397 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Thu, 18 Jun 2026 11:20:41 +0100 Subject: [PATCH 2/5] style: apply ruff format to graph_traversal models Co-Authored-By: Claude Opus 4.8 (1M context) --- infrahub_sdk/graph_traversal/models.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/infrahub_sdk/graph_traversal/models.py b/infrahub_sdk/graph_traversal/models.py index 3100766f..ac35dca0 100644 --- a/infrahub_sdk/graph_traversal/models.py +++ b/infrahub_sdk/graph_traversal/models.py @@ -61,9 +61,7 @@ def fetch(self, timeout: int | None = None) -> Any: """ if self._client is None: raise Error("This PathNode is not bound to a client and cannot be fetched.") - return self._client.get( - kind=self.kind, id=self.id, populate_store=True, branch=self._branch, timeout=timeout - ) + return self._client.get(kind=self.kind, id=self.id, populate_store=True, branch=self._branch, timeout=timeout) class PathRelationship(GraphTraversalModel): From 0415f11aa6856885108365b1bd8376208c006b66 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Thu, 18 Jun 2026 11:24:35 +0100 Subject: [PATCH 3/5] test: register graph traversal annotations in signature parity map The async/sync signature parity test matches annotation strings against a known-equivalence map. Add the new traversal parameter annotations (str | InfrahubNode, list[str | type[SchemaType]] and its optional variant) so traverse_paths/path_exists/reachable_nodes pass the parity check. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/sdk/conftest.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/sdk/conftest.py b/tests/unit/sdk/conftest.py index f862374e..ad5b70fd 100644 --- a/tests/unit/sdk/conftest.py +++ b/tests/unit/sdk/conftest.py @@ -82,6 +82,9 @@ def return_annotation_map() -> dict[str, str]: "InfrahubBatch": "InfrahubBatchSync", "CoreNode | None": "CoreNodeSync | None", "str | type[SchemaType]": "str | type[SchemaTypeSync]", + "str | InfrahubNode": "str | InfrahubNodeSync", + "list[str | type[SchemaType]]": "list[str | type[SchemaTypeSync]]", + "list[str | type[SchemaType]] | None": "list[str | type[SchemaTypeSync]] | None", "InfrahubNode | SchemaType": "InfrahubNodeSync | SchemaTypeSync", "InfrahubNode | SchemaType | None": "InfrahubNodeSync | SchemaTypeSync | None", "list[InfrahubNode] | list[SchemaType]": "list[InfrahubNodeSync] | list[SchemaTypeSync]", From b7e2b18e88fd4e70ba4b1d9b310feb42bd0dd3f9 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Fri, 19 Jun 2026 14:56:29 +0100 Subject: [PATCH 4/5] fix(sdk): address graph traversal PR review feedback - Raise generic NodeNotSavedError in _resolve_node_id; wrap with traversal-specific context at the caller - Guard _get_schema_name against missing save attribute - Use isinstance(InfrahubNodeBase) instead of broad hasattr for node detection - Add full Args sections to traversal docstrings so generated reference documents keyword-only arguments - Clarify excluded-kinds and count/truncation guidance in the guide Co-Authored-By: Claude Opus 4.8 (1M context) --- .../python-sdk/guides/graph_traversal.mdx | 7 +- .../sdk_ref/infrahub_sdk/client.mdx | 110 +++++++++++--- infrahub_sdk/client.py | 134 ++++++++++++++---- infrahub_sdk/exceptions.py | 8 ++ infrahub_sdk/node/related_node.py | 8 +- infrahub_sdk/schema/__init__.py | 5 +- tests/unit/sdk/test_graph_traversal.py | 6 +- 7 files changed, 228 insertions(+), 50 deletions(-) diff --git a/docs/docs/python-sdk/guides/graph_traversal.mdx b/docs/docs/python-sdk/guides/graph_traversal.mdx index 6c692c34..094c94fc 100644 --- a/docs/docs/python-sdk/guides/graph_traversal.mdx +++ b/docs/docs/python-sdk/guides/graph_traversal.mdx @@ -64,7 +64,7 @@ await client.traverse_paths(src_id, dst_id) # id + id ### Constraining the traversal -All limits and filters are optional; when omitted, the server applies its own defaults. Unknown kinds (for example IP namespaces) are excluded by default and reported back in `result.excluded_kinds`. +All limits and filters are optional; when omitted, the server applies its own defaults. By default the server excludes IP namespaces — they act as hubs and are rarely useful for path tracing — along with internal namespaces such as `Builtin`, `Internal`, and `Core`. The excluded kinds are reported back in `result.excluded_kinds`, and any of them can be brought back into the traversal with `included_kinds`. The kind filters (`kind_filter`, `excluded_kinds`, `included_kinds`, and `target_kinds` on `reachable_nodes`) accept either kind-name strings or generated protocol classes — mix them freely: @@ -220,11 +220,12 @@ To get the full node, call `.fetch()` on any `PathNode`. It resolves the node th ### Detecting truncated results -The server caps the number of paths/results. Compare `count` to your requested limit to know whether more may exist: +The server caps the number of results. Note that `result.count` is the number of *paths* returned, not the number of distinct nodes — when several paths lead to the same node, one node is reported multiple times. To check whether `max_results` (the cap on distinct target nodes) was reached, count the distinct node UUIDs: ```python result = await client.reachable_nodes(source=src, target_kinds=["InfraDevice"], max_results=50) -if result.count >= 50: +reached = {dep.node.id for dep in result.dependencies} +if len(reached) >= 50: print("Result may be truncated — increase max_results to see more.") ``` diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx index a4146bd8..c23de529 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx @@ -127,13 +127,27 @@ traverse_paths(self, source: str | InfrahubNode, destination: str | InfrahubNode Find the shortest path(s) between two nodes in the graph. -``source`` and ``destination`` accept either a node UUID string or an -``InfrahubNode`` instance. Kind filters (``kind_filter``, ``excluded_kinds``, -``included_kinds``) accept kind-name strings and/or generated protocol classes. -``relationship_filter`` matches schema relationship identifiers (for example -``dcimconnector__dcimendpoint``), not the per-side names shown in the result. +Kind filters (``kind_filter``, ``excluded_kinds``, ``included_kinds``) accept +kind-name strings and/or generated protocol classes. ``relationship_filter`` +matches schema relationship identifiers (for example ``dcimconnector__dcimendpoint``), +not the per-side names shown in the result. -Requires Infrahub 1.10 or later. See https://docs.infrahub.app for details. +Requires Infrahub 1.10 or later. + +**Args:** + +- `source`: Node to start from, as a UUID string or an ``InfrahubNode`` instance. +- `destination`: Node to reach, as a UUID string or an ``InfrahubNode`` instance. +- `max_depth`: Maximum number of relationship hops to explore. +- `max_paths`: Maximum number of paths to return. +- `kind_filter`: Only traverse through nodes of these kinds. +- `relationship_filter`: Only traverse through these schema relationship identifiers. +- `excluded_namespaces`: Schema namespaces to exclude from traversal. +- `excluded_kinds`: Node kinds to exclude from traversal. +- `included_kinds`: Node kinds to re-include when otherwise excluded by default. +- `branch`: Name of the branch to query from. Defaults to default_branch. +- `at`: Time of the query. Defaults to now. +- `timeout`: Overrides the default GraphQL timeout, in seconds. **Raises:** @@ -154,6 +168,20 @@ found. Accepts the same source/destination and filter arguments as ``traverse_pa Requires Infrahub 1.10 or later. +**Args:** + +- `source`: Node to start from, as a UUID string or an ``InfrahubNode`` instance. +- `destination`: Node to reach, as a UUID string or an ``InfrahubNode`` instance. +- `max_depth`: Maximum number of relationship hops to explore. +- `kind_filter`: Only traverse through nodes of these kinds. +- `relationship_filter`: Only traverse through these schema relationship identifiers. +- `excluded_namespaces`: Schema namespaces to exclude from traversal. +- `excluded_kinds`: Node kinds to exclude from traversal. +- `included_kinds`: Node kinds to re-include when otherwise excluded by default. +- `branch`: Name of the branch to query from. Defaults to default_branch. +- `at`: Time of the query. Defaults to now. +- `timeout`: Overrides the default GraphQL timeout, in seconds. + **Raises:** - `VersionNotSupportedError`: If the server does not support graph traversal (pre-1.10). @@ -167,10 +195,21 @@ reachable_nodes(self, source: str | InfrahubNode, target_kinds: list[str | type[ Find all nodes of the given kinds reachable from a source node. -``source`` accepts either a node UUID string or an ``InfrahubNode`` instance. ``target_kinds`` accepts kind-name strings and/or generated protocol classes. -Requires Infrahub 1.10 or later. See https://docs.infrahub.app for details. +Requires Infrahub 1.10 or later. + +**Args:** + +- `source`: Node to start from, as a UUID string or an ``InfrahubNode`` instance. +- `target_kinds`: Kinds of nodes to look for, as kind-name strings or protocol classes. +- `max_depth`: Maximum number of relationship hops to explore. +- `max_results`: Maximum number of reachable nodes to return. +- `max_paths`: Maximum number of paths to compute per reachable node. +- `shortest_paths_only`: When True, only return the shortest path(s) to each node. +- `branch`: Name of the branch to query from. Defaults to default_branch. +- `at`: Time of the query. Defaults to now. +- `timeout`: Overrides the default GraphQL timeout, in seconds. **Raises:** @@ -630,13 +669,27 @@ traverse_paths(self, source: str | InfrahubNodeSync, destination: str | Infrahub Find the shortest path(s) between two nodes in the graph. -``source`` and ``destination`` accept either a node UUID string or an -``InfrahubNode`` instance. Kind filters (``kind_filter``, ``excluded_kinds``, -``included_kinds``) accept kind-name strings and/or generated protocol classes. -``relationship_filter`` matches schema relationship identifiers (for example -``dcimconnector__dcimendpoint``), not the per-side names shown in the result. +Kind filters (``kind_filter``, ``excluded_kinds``, ``included_kinds``) accept +kind-name strings and/or generated protocol classes. ``relationship_filter`` +matches schema relationship identifiers (for example ``dcimconnector__dcimendpoint``), +not the per-side names shown in the result. -Requires Infrahub 1.10 or later. See https://docs.infrahub.app for details. +Requires Infrahub 1.10 or later. + +**Args:** + +- `source`: Node to start from, as a UUID string or an ``InfrahubNode`` instance. +- `destination`: Node to reach, as a UUID string or an ``InfrahubNode`` instance. +- `max_depth`: Maximum number of relationship hops to explore. +- `max_paths`: Maximum number of paths to return. +- `kind_filter`: Only traverse through nodes of these kinds. +- `relationship_filter`: Only traverse through these schema relationship identifiers. +- `excluded_namespaces`: Schema namespaces to exclude from traversal. +- `excluded_kinds`: Node kinds to exclude from traversal. +- `included_kinds`: Node kinds to re-include when otherwise excluded by default. +- `branch`: Name of the branch to query from. Defaults to default_branch. +- `at`: Time of the query. Defaults to now. +- `timeout`: Overrides the default GraphQL timeout, in seconds. **Raises:** @@ -657,6 +710,20 @@ found. Accepts the same source/destination and filter arguments as ``traverse_pa Requires Infrahub 1.10 or later. +**Args:** + +- `source`: Node to start from, as a UUID string or an ``InfrahubNode`` instance. +- `destination`: Node to reach, as a UUID string or an ``InfrahubNode`` instance. +- `max_depth`: Maximum number of relationship hops to explore. +- `kind_filter`: Only traverse through nodes of these kinds. +- `relationship_filter`: Only traverse through these schema relationship identifiers. +- `excluded_namespaces`: Schema namespaces to exclude from traversal. +- `excluded_kinds`: Node kinds to exclude from traversal. +- `included_kinds`: Node kinds to re-include when otherwise excluded by default. +- `branch`: Name of the branch to query from. Defaults to default_branch. +- `at`: Time of the query. Defaults to now. +- `timeout`: Overrides the default GraphQL timeout, in seconds. + **Raises:** - `VersionNotSupportedError`: If the server does not support graph traversal (pre-1.10). @@ -670,10 +737,21 @@ reachable_nodes(self, source: str | InfrahubNodeSync, target_kinds: list[str | t Find all nodes of the given kinds reachable from a source node. -``source`` accepts either a node UUID string or an ``InfrahubNode`` instance. ``target_kinds`` accepts kind-name strings and/or generated protocol classes. -Requires Infrahub 1.10 or later. See https://docs.infrahub.app for details. +Requires Infrahub 1.10 or later. + +**Args:** + +- `source`: Node to start from, as a UUID string or an ``InfrahubNode`` instance. +- `target_kinds`: Kinds of nodes to look for, as kind-name strings or protocol classes. +- `max_depth`: Maximum number of relationship hops to explore. +- `max_results`: Maximum number of reachable nodes to return. +- `max_paths`: Maximum number of paths to compute per reachable node. +- `shortest_paths_only`: When True, only return the shortest path(s) to each node. +- `branch`: Name of the branch to query from. Defaults to default_branch. +- `at`: Time of the query. Defaults to now. +- `timeout`: Overrides the default GraphQL timeout, in seconds. **Raises:** diff --git a/infrahub_sdk/client.py b/infrahub_sdk/client.py index b0f08eec..01e8d5f0 100644 --- a/infrahub_sdk/client.py +++ b/infrahub_sdk/client.py @@ -29,6 +29,7 @@ Error, GraphQLError, NodeNotFoundError, + NodeNotSavedError, ServerNotReachableError, ServerNotResponsiveError, URLNotFoundError, @@ -139,16 +140,29 @@ def _resolve_node_id(node: str | InfrahubNode | InfrahubNodeSync) -> str: """Return a node UUID from either an id string or an InfrahubNode instance. Raises: - Error: If a node instance without an id is provided. + NodeNotSavedError: If a node instance without an id is provided. """ if isinstance(node, str): return node if node.id is None: - raise Error("Cannot use a node without an id as a traversal source or destination.") + raise NodeNotSavedError("Cannot resolve the id of a node that has not been saved yet.") return node.id +def _resolve_traversal_node_id(node: str | InfrahubNode | InfrahubNodeSync, *, role: str) -> str: + """Resolve a node id for graph traversal, adding traversal context to unsaved-node errors. + + Raises: + Error: If a node instance without an id is provided. + + """ + try: + return _resolve_node_id(node) + except NodeNotSavedError as exc: + raise Error(f"Cannot use an unsaved node as the graph traversal {role}; save it first.") from exc + + class BaseClient: """Base class for InfrahubClient and InfrahubClientSync.""" @@ -690,13 +704,26 @@ async def traverse_paths( ) -> PathTraversalResult: """Find the shortest path(s) between two nodes in the graph. - ``source`` and ``destination`` accept either a node UUID string or an - ``InfrahubNode`` instance. Kind filters (``kind_filter``, ``excluded_kinds``, - ``included_kinds``) accept kind-name strings and/or generated protocol classes. - ``relationship_filter`` matches schema relationship identifiers (for example - ``dcimconnector__dcimendpoint``), not the per-side names shown in the result. + Kind filters (``kind_filter``, ``excluded_kinds``, ``included_kinds``) accept + kind-name strings and/or generated protocol classes. ``relationship_filter`` + matches schema relationship identifiers (for example ``dcimconnector__dcimendpoint``), + not the per-side names shown in the result. + + Requires Infrahub 1.10 or later. - Requires Infrahub 1.10 or later. See https://docs.infrahub.app for details. + Args: + source: Node to start from, as a UUID string or an ``InfrahubNode`` instance. + destination: Node to reach, as a UUID string or an ``InfrahubNode`` instance. + max_depth: Maximum number of relationship hops to explore. + max_paths: Maximum number of paths to return. + kind_filter: Only traverse through nodes of these kinds. + relationship_filter: Only traverse through these schema relationship identifiers. + excluded_namespaces: Schema namespaces to exclude from traversal. + excluded_kinds: Node kinds to exclude from traversal. + included_kinds: Node kinds to re-include when otherwise excluded by default. + branch: Name of the branch to query from. Defaults to default_branch. + at: Time of the query. Defaults to now. + timeout: Overrides the default GraphQL timeout, in seconds. Raises: VersionNotSupportedError: If the server does not support graph traversal (pre-1.10). @@ -704,8 +731,8 @@ async def traverse_paths( """ data = build_path_traversal_input( - _resolve_node_id(source), - _resolve_node_id(destination), + _resolve_traversal_node_id(source, role="source"), + _resolve_traversal_node_id(destination, role="destination"), max_depth=max_depth, max_paths=max_paths, kind_filter=_normalize_kinds(kind_filter), @@ -754,6 +781,19 @@ async def path_exists( Requires Infrahub 1.10 or later. + Args: + source: Node to start from, as a UUID string or an ``InfrahubNode`` instance. + destination: Node to reach, as a UUID string or an ``InfrahubNode`` instance. + max_depth: Maximum number of relationship hops to explore. + kind_filter: Only traverse through nodes of these kinds. + relationship_filter: Only traverse through these schema relationship identifiers. + excluded_namespaces: Schema namespaces to exclude from traversal. + excluded_kinds: Node kinds to exclude from traversal. + included_kinds: Node kinds to re-include when otherwise excluded by default. + branch: Name of the branch to query from. Defaults to default_branch. + at: Time of the query. Defaults to now. + timeout: Overrides the default GraphQL timeout, in seconds. + Raises: VersionNotSupportedError: If the server does not support graph traversal (pre-1.10). GraphQLError: When the GraphQL response contains errors (e.g. unknown node). @@ -790,10 +830,20 @@ async def reachable_nodes( ) -> ReachableNodesResult: """Find all nodes of the given kinds reachable from a source node. - ``source`` accepts either a node UUID string or an ``InfrahubNode`` instance. ``target_kinds`` accepts kind-name strings and/or generated protocol classes. - Requires Infrahub 1.10 or later. See https://docs.infrahub.app for details. + Requires Infrahub 1.10 or later. + + Args: + source: Node to start from, as a UUID string or an ``InfrahubNode`` instance. + target_kinds: Kinds of nodes to look for, as kind-name strings or protocol classes. + max_depth: Maximum number of relationship hops to explore. + max_results: Maximum number of reachable nodes to return. + max_paths: Maximum number of paths to compute per reachable node. + shortest_paths_only: When True, only return the shortest path(s) to each node. + branch: Name of the branch to query from. Defaults to default_branch. + at: Time of the query. Defaults to now. + timeout: Overrides the default GraphQL timeout, in seconds. Raises: VersionNotSupportedError: If the server does not support graph traversal (pre-1.10). @@ -801,7 +851,7 @@ async def reachable_nodes( """ data = build_reachable_nodes_input( - _resolve_node_id(source), + _resolve_traversal_node_id(source, role="source"), _normalize_kinds(target_kinds) or [], max_depth=max_depth, max_results=max_results, @@ -2350,13 +2400,26 @@ def traverse_paths( ) -> PathTraversalResult: """Find the shortest path(s) between two nodes in the graph. - ``source`` and ``destination`` accept either a node UUID string or an - ``InfrahubNode`` instance. Kind filters (``kind_filter``, ``excluded_kinds``, - ``included_kinds``) accept kind-name strings and/or generated protocol classes. - ``relationship_filter`` matches schema relationship identifiers (for example - ``dcimconnector__dcimendpoint``), not the per-side names shown in the result. + Kind filters (``kind_filter``, ``excluded_kinds``, ``included_kinds``) accept + kind-name strings and/or generated protocol classes. ``relationship_filter`` + matches schema relationship identifiers (for example ``dcimconnector__dcimendpoint``), + not the per-side names shown in the result. + + Requires Infrahub 1.10 or later. - Requires Infrahub 1.10 or later. See https://docs.infrahub.app for details. + Args: + source: Node to start from, as a UUID string or an ``InfrahubNode`` instance. + destination: Node to reach, as a UUID string or an ``InfrahubNode`` instance. + max_depth: Maximum number of relationship hops to explore. + max_paths: Maximum number of paths to return. + kind_filter: Only traverse through nodes of these kinds. + relationship_filter: Only traverse through these schema relationship identifiers. + excluded_namespaces: Schema namespaces to exclude from traversal. + excluded_kinds: Node kinds to exclude from traversal. + included_kinds: Node kinds to re-include when otherwise excluded by default. + branch: Name of the branch to query from. Defaults to default_branch. + at: Time of the query. Defaults to now. + timeout: Overrides the default GraphQL timeout, in seconds. Raises: VersionNotSupportedError: If the server does not support graph traversal (pre-1.10). @@ -2364,8 +2427,8 @@ def traverse_paths( """ data = build_path_traversal_input( - _resolve_node_id(source), - _resolve_node_id(destination), + _resolve_traversal_node_id(source, role="source"), + _resolve_traversal_node_id(destination, role="destination"), max_depth=max_depth, max_paths=max_paths, kind_filter=_normalize_kinds(kind_filter), @@ -2414,6 +2477,19 @@ def path_exists( Requires Infrahub 1.10 or later. + Args: + source: Node to start from, as a UUID string or an ``InfrahubNode`` instance. + destination: Node to reach, as a UUID string or an ``InfrahubNode`` instance. + max_depth: Maximum number of relationship hops to explore. + kind_filter: Only traverse through nodes of these kinds. + relationship_filter: Only traverse through these schema relationship identifiers. + excluded_namespaces: Schema namespaces to exclude from traversal. + excluded_kinds: Node kinds to exclude from traversal. + included_kinds: Node kinds to re-include when otherwise excluded by default. + branch: Name of the branch to query from. Defaults to default_branch. + at: Time of the query. Defaults to now. + timeout: Overrides the default GraphQL timeout, in seconds. + Raises: VersionNotSupportedError: If the server does not support graph traversal (pre-1.10). GraphQLError: When the GraphQL response contains errors (e.g. unknown node). @@ -2450,10 +2526,20 @@ def reachable_nodes( ) -> ReachableNodesResult: """Find all nodes of the given kinds reachable from a source node. - ``source`` accepts either a node UUID string or an ``InfrahubNode`` instance. ``target_kinds`` accepts kind-name strings and/or generated protocol classes. - Requires Infrahub 1.10 or later. See https://docs.infrahub.app for details. + Requires Infrahub 1.10 or later. + + Args: + source: Node to start from, as a UUID string or an ``InfrahubNode`` instance. + target_kinds: Kinds of nodes to look for, as kind-name strings or protocol classes. + max_depth: Maximum number of relationship hops to explore. + max_results: Maximum number of reachable nodes to return. + max_paths: Maximum number of paths to compute per reachable node. + shortest_paths_only: When True, only return the shortest path(s) to each node. + branch: Name of the branch to query from. Defaults to default_branch. + at: Time of the query. Defaults to now. + timeout: Overrides the default GraphQL timeout, in seconds. Raises: VersionNotSupportedError: If the server does not support graph traversal (pre-1.10). @@ -2461,7 +2547,7 @@ def reachable_nodes( """ data = build_reachable_nodes_input( - _resolve_node_id(source), + _resolve_traversal_node_id(source, role="source"), _normalize_kinds(target_kinds) or [], max_depth=max_depth, max_results=max_results, diff --git a/infrahub_sdk/exceptions.py b/infrahub_sdk/exceptions.py index 2fc41203..f0774c2d 100644 --- a/infrahub_sdk/exceptions.py +++ b/infrahub_sdk/exceptions.py @@ -104,6 +104,14 @@ class NodeInvalidError(NodeNotFoundError): pass +class NodeNotSavedError(Error): + """Raised when an operation requires a node that has been saved (has an id) but it has not.""" + + def __init__(self, message: str | None = None) -> None: + self.message = message or "The node has not been saved yet and does not have an id." + super().__init__(self.message) + + class ResourceNotDefinedError(Error): """Raised when trying to access a resource that hasn't been defined.""" diff --git a/infrahub_sdk/node/related_node.py b/infrahub_sdk/node/related_node.py index 2dc7bf96..52e45340 100644 --- a/infrahub_sdk/node/related_node.py +++ b/infrahub_sdk/node/related_node.py @@ -58,9 +58,11 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, # so we don't silently null-clear unfetched relationships on save. self._peer_has_been_mutated: bool = False - # Check for InfrahubNodeBase instances using duck-typing (_schema attribute) - # to avoid circular imports, or CoreNodeBase instances - if isinstance(data, CoreNodeBase) or hasattr(data, "_schema"): + # Detect node instances. InfrahubNodeBase is imported lazily here to avoid a + # circular import (node.py imports this module at load time). + from .node import InfrahubNodeBase as _InfrahubNodeBase # noqa: PLC0415 + + if isinstance(data, (CoreNodeBase, _InfrahubNodeBase)): self._peer = cast("InfrahubNodeBase | CoreNodeBase", data) for prop in self._properties: setattr(self, prop, None) diff --git a/infrahub_sdk/schema/__init__.py b/infrahub_sdk/schema/__init__.py index 051485fd..82292e2a 100644 --- a/infrahub_sdk/schema/__init__.py +++ b/infrahub_sdk/schema/__init__.py @@ -254,8 +254,9 @@ def _get_schema_name(schema: type[SchemaType | SchemaTypeSync] | str) -> str: if isinstance(schema, str): return schema - if issubclass(schema, CoreNodeBase): - if inspect.iscoroutinefunction(schema.save): + if isinstance(schema, type) and issubclass(schema, CoreNodeBase): + save = getattr(schema, "save", None) + if save is not None and inspect.iscoroutinefunction(save): return schema.__name__ if schema.__name__[-4:] == "Sync": return schema.__name__[:-4] diff --git a/tests/unit/sdk/test_graph_traversal.py b/tests/unit/sdk/test_graph_traversal.py index 5a92d9f1..5a5cfa44 100644 --- a/tests/unit/sdk/test_graph_traversal.py +++ b/tests/unit/sdk/test_graph_traversal.py @@ -5,7 +5,7 @@ import pytest -from infrahub_sdk.exceptions import Error, GraphQLError, VersionNotSupportedError +from infrahub_sdk.exceptions import Error, GraphQLError, NodeNotSavedError, VersionNotSupportedError from infrahub_sdk.graph_traversal.models import PathNode, PathTraversalResult, ReachableNodesResult from infrahub_sdk.graph_traversal.query import ( build_path_traversal_input, @@ -207,8 +207,10 @@ async def test_traverse_paths_accepts_node_objects( async def test_traverse_paths_node_without_id_raises(clients: BothClients, location_schema: NodeSchemaAPI) -> None: node = InfrahubNode(client=clients.standard, schema=location_schema, data={}) - with pytest.raises(Error, match="without an id"): + with pytest.raises(Error, match="unsaved node as the graph traversal source") as exc_info: await clients.standard.traverse_paths(node, "dst-uuid") + # The generic NodeNotSavedError is wrapped with traversal-specific context. + assert isinstance(exc_info.value.__cause__, NodeNotSavedError) # --- client methods (httpx_mock at the transport boundary) ------------------ From 1fbfe9b49682d7645744e0cb08eb9c9c9bf6c977 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Fri, 19 Jun 2026 15:00:38 +0100 Subject: [PATCH 5/5] docs: reword to satisfy vale spelling check Avoid plural 'UUIDs' which is not in the Infrahub vocab. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/docs/python-sdk/guides/graph_traversal.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/python-sdk/guides/graph_traversal.mdx b/docs/docs/python-sdk/guides/graph_traversal.mdx index 094c94fc..41fca5d9 100644 --- a/docs/docs/python-sdk/guides/graph_traversal.mdx +++ b/docs/docs/python-sdk/guides/graph_traversal.mdx @@ -220,7 +220,7 @@ To get the full node, call `.fetch()` on any `PathNode`. It resolves the node th ### Detecting truncated results -The server caps the number of results. Note that `result.count` is the number of *paths* returned, not the number of distinct nodes — when several paths lead to the same node, one node is reported multiple times. To check whether `max_results` (the cap on distinct target nodes) was reached, count the distinct node UUIDs: +The server caps the number of results. Note that `result.count` is the number of *paths* returned, not the number of distinct nodes — when several paths lead to the same node, one node is reported multiple times. To check whether `max_results` (the cap on distinct target nodes) was reached, count the distinct nodes by their UUID: ```python result = await client.reachable_nodes(source=src, target_kinds=["InfraDevice"], max_results=50)