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..41fca5d9
--- /dev/null
+++ b/docs/docs/python-sdk/guides/graph_traversal.mdx
@@ -0,0 +1,331 @@
+---
+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. 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:
+
+```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 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)
+reached = {dep.node.id for dep in result.dependencies}
+if len(reached) >= 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..c23de529 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,103 @@ 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.
+
+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.
+
+**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).
+- `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.
+
+**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).
+
+#### `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.
+
+``target_kinds`` accepts kind-name strings and/or generated protocol classes.
+
+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).
+- `GraphQLError`: When the GraphQL response contains errors (e.g. unknown node).
+
#### `all`
```python
@@ -564,6 +661,103 @@ 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.
+
+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.
+
+**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).
+- `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.
+
+**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).
+
+#### `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.
+
+``target_kinds`` accepts kind-name strings and/or generated protocol classes.
+
+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).
+- `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..01e8d5f0 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
@@ -29,9 +29,19 @@
Error,
GraphQLError,
NodeNotFoundError,
+ NodeNotSavedError,
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 +127,42 @@ 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:
+ NodeNotSavedError: If a node instance without an id is provided.
+
+ """
+ if isinstance(node, str):
+ return node
+ if node.id is None:
+ 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."""
@@ -640,6 +686,195 @@ 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.
+
+ 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.
+
+ 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).
+ GraphQLError: When the GraphQL response contains errors (e.g. unknown node).
+
+ """
+ data = build_path_traversal_input(
+ _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),
+ 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.
+
+ 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).
+
+ """
+ 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.
+
+ ``target_kinds`` accepts kind-name strings and/or generated protocol classes.
+
+ 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).
+ GraphQLError: When the GraphQL response contains errors (e.g. unknown node).
+
+ """
+ data = build_reachable_nodes_input(
+ _resolve_traversal_node_id(source, role="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 +2382,195 @@ 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.
+
+ 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.
+
+ 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).
+ GraphQLError: When the GraphQL response contains errors (e.g. unknown node).
+
+ """
+ data = build_path_traversal_input(
+ _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),
+ 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.
+
+ 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).
+
+ """
+ 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.
+
+ ``target_kinds`` accepts kind-name strings and/or generated protocol classes.
+
+ 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).
+ GraphQLError: When the GraphQL response contains errors (e.g. unknown node).
+
+ """
+ data = build_reachable_nodes_input(
+ _resolve_traversal_node_id(source, role="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..f0774c2d 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
@@ -94,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/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..ac35dca0
--- /dev/null
+++ b/infrahub_sdk/graph_traversal/models.py
@@ -0,0 +1,141 @@
+"""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/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/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/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]",
diff --git a/tests/unit/sdk/test_graph_traversal.py b/tests/unit/sdk/test_graph_traversal.py
new file mode 100644
index 00000000..5a5cfa44
--- /dev/null
+++ b/tests/unit/sdk/test_graph_traversal.py
@@ -0,0 +1,325 @@
+from __future__ import annotations
+
+import json
+from typing import TYPE_CHECKING
+
+import pytest
+
+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,
+ 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="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) ------------------
+
+
+@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")