Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vale/styles/spelling-exceptions.txt
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ JSONSchema
kbps
Keycloak
Loopbacks
loopless
markdownlint
MDX
max_count
Expand Down
12 changes: 12 additions & 0 deletions docs/docs/python-sdk/guides/graph_traversal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ result = await client.traverse_paths(

:::

`shortest_paths_only` controls how many paths are considered through each intermediate node. When `True` (the server default), only the shortest path through each intermediate object is returned; when `False`, every loopless path up to `max_paths` is returned (exhaustive mode).

<Tabs groupId="async-sync">
<TabItem value="Async" default>

Expand All @@ -96,6 +98,7 @@ result = await client.traverse_paths(
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
shortest_paths_only=False, # return all loopless paths, not just the shortest
)
```

Expand All @@ -111,6 +114,7 @@ result = await client.traverse_paths(
kind_filter=["DcimCable", "InterfacePhysical"],
relationship_filter=["dcimconnector__dcimendpoint"],
included_kinds=["IpamIPPrefix"],
shortest_paths_only=False,
)
```

Expand Down Expand Up @@ -229,6 +233,14 @@ if len(reached) >= 50:
print("Result may be truncated — increase max_results to see more.")
```

For `traverse_paths`, the `PathTraversalResult` reports truncation directly via `result.truncated_at_depth`. It is `None` when the search completed within budget; otherwise it is the depth at which the server ran out of budget. In that case the returned paths are complete only for depths *less than* that value, and deeper paths may exist.

```python
result = await client.traverse_paths(source=src, destination=dst)
if result.truncated_at_depth is not None:
print(f"Search ran out of budget at depth {result.truncated_at_depth}; deeper paths may exist.")
```

## 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`.
Expand Down
4 changes: 4 additions & 0 deletions docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ Requires Infrahub 1.10 or later.
- `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.
- `shortest_paths_only`: When True (the server default), only return the shortest
path(s); when False, return all loopless paths (exhaustive mode).
- `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.
Expand Down Expand Up @@ -687,6 +689,8 @@ Requires Infrahub 1.10 or later.
- `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.
- `shortest_paths_only`: When True (the server default), only return the shortest
path(s); when False, return all loopless paths (exhaustive mode).
- `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.
Expand Down
8 changes: 8 additions & 0 deletions infrahub_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,7 @@ async def traverse_paths(
excluded_namespaces: list[str] | None = None,
excluded_kinds: list[str | type[SchemaType]] | None = None,
included_kinds: list[str | type[SchemaType]] | None = None,
shortest_paths_only: bool | None = None,
branch: str | None = None,
at: Timestamp | str | None = None,
timeout: int | None = None,
Expand All @@ -721,6 +722,8 @@ async def traverse_paths(
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.
shortest_paths_only: When True (the server default), only return the shortest
path(s); when False, return all loopless paths (exhaustive mode).
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.
Expand All @@ -740,6 +743,7 @@ async def traverse_paths(
excluded_namespaces=excluded_namespaces,
excluded_kinds=_normalize_kinds(excluded_kinds),
included_kinds=_normalize_kinds(included_kinds),
shortest_paths_only=shortest_paths_only,
)
try:
response = await self.execute_graphql(
Expand Down Expand Up @@ -2394,6 +2398,7 @@ def traverse_paths(
excluded_namespaces: list[str] | None = None,
excluded_kinds: list[str | type[SchemaTypeSync]] | None = None,
included_kinds: list[str | type[SchemaTypeSync]] | None = None,
shortest_paths_only: bool | None = None,
branch: str | None = None,
at: Timestamp | str | None = None,
timeout: int | None = None,
Expand All @@ -2417,6 +2422,8 @@ def traverse_paths(
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.
shortest_paths_only: When True (the server default), only return the shortest
path(s); when False, return all loopless paths (exhaustive mode).
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.
Expand All @@ -2436,6 +2443,7 @@ def traverse_paths(
excluded_namespaces=excluded_namespaces,
excluded_kinds=_normalize_kinds(excluded_kinds),
included_kinds=_normalize_kinds(included_kinds),
shortest_paths_only=shortest_paths_only,
)
try:
response = self.execute_graphql(
Expand Down
3 changes: 3 additions & 0 deletions infrahub_sdk/graph_traversal/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ class PathTraversalResult(GraphTraversalModel):
destination: PathNode
count: int
excluded_kinds: list[str] = Field(default_factory=list)
# None when the search completed within budget; otherwise the depth at which the
# server ran out of budget. Returned paths are complete only for depths below this value.
truncated_at_depth: int | None = None

def _bind(self, client: InfrahubClient | InfrahubClientSync, branch: str | None) -> PathTraversalResult:
self.source._bind(client, branch)
Expand Down
3 changes: 3 additions & 0 deletions infrahub_sdk/graph_traversal/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
destination {{ {_PATH_NODE_FIELDS} }}
count
excluded_kinds
truncated_at_depth
}}
}}"""

Expand Down Expand Up @@ -64,6 +65,7 @@ def build_path_traversal_input(
excluded_namespaces: list[str] | None = None,
excluded_kinds: list[str] | None = None,
included_kinds: list[str] | None = None,
shortest_paths_only: bool | 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}
Expand All @@ -75,6 +77,7 @@ def build_path_traversal_input(
"excluded_namespaces": excluded_namespaces,
"excluded_kinds": excluded_kinds,
"included_kinds": included_kinds,
"shortest_paths_only": shortest_paths_only,
}
data.update({key: value for key, value in optional.items() if value is not None})
return data
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/sdk/test_graph_traversal.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,16 @@ def test_build_path_traversal_input_includes_set_values() -> None:
}


def test_build_path_traversal_input_includes_shortest_paths_only() -> None:
data = build_path_traversal_input("a", "b", shortest_paths_only=False)
assert data == {"source_id": "a", "destination_id": "b", "shortest_paths_only": False}


def test_build_path_traversal_input_omits_shortest_paths_only_when_none() -> None:
data = build_path_traversal_input("a", "b", shortest_paths_only=None)
assert "shortest_paths_only" not in data


def test_build_reachable_nodes_input() -> None:
data = build_reachable_nodes_input("a", ["DcimCable"], max_results=10, shortest_paths_only=True)
assert data == {
Expand Down Expand Up @@ -159,6 +169,20 @@ def test_path_traversal_result_parsing() -> None:
assert result.source.hfid == []


def test_path_traversal_result_parses_truncated_at_depth() -> None:
result = PathTraversalResult.model_validate({**PATH_RESULT, "truncated_at_depth": 5})
assert result.truncated_at_depth == 5


def test_path_traversal_result_truncated_at_depth_defaults_none() -> None:
# Absent field (older server / completed search) parses as None.
result = PathTraversalResult.model_validate(PATH_RESULT)
assert result.truncated_at_depth is None
# Explicit null (search completed within budget) also parses as None.
result_null = PathTraversalResult.model_validate({**PATH_RESULT, "truncated_at_depth": None})
assert result_null.truncated_at_depth is None


def test_reachable_nodes_result_parsing() -> None:
result = ReachableNodesResult.model_validate(REACHABLE_RESULT)
assert result.count == 1
Expand Down Expand Up @@ -187,6 +211,44 @@ class DcimCable(CoreNode): ...
assert sent["variables"]["data"]["kind_filter"] == ["DcimCable", "InterfacePhysical"]


@pytest.mark.parametrize("client_type", ["standard", "sync"])
async def test_traverse_paths_sends_shortest_paths_only(
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":
await clients.standard.traverse_paths("a", "b", shortest_paths_only=False)
else:
clients.sync.traverse_paths("a", "b", shortest_paths_only=False)

sent = json.loads(httpx_mock.get_requests()[0].content)
assert sent["variables"]["data"]["shortest_paths_only"] is False


@pytest.mark.parametrize("client_type", ["standard", "sync"])
async def test_traverse_paths_omits_shortest_paths_only_by_default(
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":
await clients.standard.traverse_paths("a", "b")
else:
clients.sync.traverse_paths("a", "b")

sent = json.loads(httpx_mock.get_requests()[0].content)
assert "shortest_paths_only" not in sent["variables"]["data"]


async def test_traverse_paths_accepts_node_objects(
clients: BothClients, location_schema: NodeSchemaAPI, httpx_mock: HTTPXMock
) -> None:
Expand Down