Skip to content

Commit 0828e6d

Browse files
authored
support for truncated_at_depth and shortest_paths_only (#1119)
* support for truncated_at_depth and shortest_paths_only * docs formatting update
1 parent 1694cd0 commit 0828e6d

7 files changed

Lines changed: 93 additions & 0 deletions

File tree

.vale/styles/spelling-exceptions.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ JSONSchema
7878
kbps
7979
Keycloak
8080
Loopbacks
81+
loopless
8182
markdownlint
8283
MDX
8384
max_count

docs/docs/python-sdk/guides/graph_traversal.mdx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ result = await client.traverse_paths(
8484

8585
:::
8686

87+
`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).
88+
8789
<Tabs groupId="async-sync">
8890
<TabItem value="Async" default>
8991

@@ -96,6 +98,7 @@ result = await client.traverse_paths(
9698
kind_filter=["DcimCable", "InterfacePhysical"], # only traverse through these kinds
9799
relationship_filter=["dcimconnector__dcimendpoint"], # schema relationship identifier
98100
included_kinds=["IpamIPPrefix"], # re-include a default-excluded kind
101+
shortest_paths_only=False, # return all loopless paths, not just the shortest
99102
)
100103
```
101104

@@ -111,6 +114,7 @@ result = await client.traverse_paths(
111114
kind_filter=["DcimCable", "InterfacePhysical"],
112115
relationship_filter=["dcimconnector__dcimendpoint"],
113116
included_kinds=["IpamIPPrefix"],
117+
shortest_paths_only=False,
114118
)
115119
```
116120

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

236+
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.
237+
238+
```python
239+
result = await client.traverse_paths(source=src, destination=dst)
240+
if result.truncated_at_depth is not None:
241+
print(f"Search ran out of budget at depth {result.truncated_at_depth}; deeper paths may exist.")
242+
```
243+
232244
## Common check patterns
233245

234246
These are the recurring questions graph traversal answers in an Infrahub check. The examples are async; the sync client mirrors them without `await`.

docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ Requires Infrahub 1.10 or later.
145145
- `excluded_namespaces`: Schema namespaces to exclude from traversal.
146146
- `excluded_kinds`: Node kinds to exclude from traversal.
147147
- `included_kinds`: Node kinds to re-include when otherwise excluded by default.
148+
- `shortest_paths_only`: When True (the server default), only return the shortest
149+
path(s); when False, return all loopless paths (exhaustive mode).
148150
- `branch`: Name of the branch to query from. Defaults to default_branch.
149151
- `at`: Time of the query. Defaults to now.
150152
- `timeout`: Overrides the default GraphQL timeout, in seconds.
@@ -687,6 +689,8 @@ Requires Infrahub 1.10 or later.
687689
- `excluded_namespaces`: Schema namespaces to exclude from traversal.
688690
- `excluded_kinds`: Node kinds to exclude from traversal.
689691
- `included_kinds`: Node kinds to re-include when otherwise excluded by default.
692+
- `shortest_paths_only`: When True (the server default), only return the shortest
693+
path(s); when False, return all loopless paths (exhaustive mode).
690694
- `branch`: Name of the branch to query from. Defaults to default_branch.
691695
- `at`: Time of the query. Defaults to now.
692696
- `timeout`: Overrides the default GraphQL timeout, in seconds.

infrahub_sdk/client.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,7 @@ async def traverse_paths(
698698
excluded_namespaces: list[str] | None = None,
699699
excluded_kinds: list[str | type[SchemaType]] | None = None,
700700
included_kinds: list[str | type[SchemaType]] | None = None,
701+
shortest_paths_only: bool | None = None,
701702
branch: str | None = None,
702703
at: Timestamp | str | None = None,
703704
timeout: int | None = None,
@@ -721,6 +722,8 @@ async def traverse_paths(
721722
excluded_namespaces: Schema namespaces to exclude from traversal.
722723
excluded_kinds: Node kinds to exclude from traversal.
723724
included_kinds: Node kinds to re-include when otherwise excluded by default.
725+
shortest_paths_only: When True (the server default), only return the shortest
726+
path(s); when False, return all loopless paths (exhaustive mode).
724727
branch: Name of the branch to query from. Defaults to default_branch.
725728
at: Time of the query. Defaults to now.
726729
timeout: Overrides the default GraphQL timeout, in seconds.
@@ -740,6 +743,7 @@ async def traverse_paths(
740743
excluded_namespaces=excluded_namespaces,
741744
excluded_kinds=_normalize_kinds(excluded_kinds),
742745
included_kinds=_normalize_kinds(included_kinds),
746+
shortest_paths_only=shortest_paths_only,
743747
)
744748
try:
745749
response = await self.execute_graphql(
@@ -2394,6 +2398,7 @@ def traverse_paths(
23942398
excluded_namespaces: list[str] | None = None,
23952399
excluded_kinds: list[str | type[SchemaTypeSync]] | None = None,
23962400
included_kinds: list[str | type[SchemaTypeSync]] | None = None,
2401+
shortest_paths_only: bool | None = None,
23972402
branch: str | None = None,
23982403
at: Timestamp | str | None = None,
23992404
timeout: int | None = None,
@@ -2417,6 +2422,8 @@ def traverse_paths(
24172422
excluded_namespaces: Schema namespaces to exclude from traversal.
24182423
excluded_kinds: Node kinds to exclude from traversal.
24192424
included_kinds: Node kinds to re-include when otherwise excluded by default.
2425+
shortest_paths_only: When True (the server default), only return the shortest
2426+
path(s); when False, return all loopless paths (exhaustive mode).
24202427
branch: Name of the branch to query from. Defaults to default_branch.
24212428
at: Time of the query. Defaults to now.
24222429
timeout: Overrides the default GraphQL timeout, in seconds.
@@ -2436,6 +2443,7 @@ def traverse_paths(
24362443
excluded_namespaces=excluded_namespaces,
24372444
excluded_kinds=_normalize_kinds(excluded_kinds),
24382445
included_kinds=_normalize_kinds(included_kinds),
2446+
shortest_paths_only=shortest_paths_only,
24392447
)
24402448
try:
24412449
response = self.execute_graphql(

infrahub_sdk/graph_traversal/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ class PathTraversalResult(GraphTraversalModel):
106106
destination: PathNode
107107
count: int
108108
excluded_kinds: list[str] = Field(default_factory=list)
109+
# None when the search completed within budget; otherwise the depth at which the
110+
# server ran out of budget. Returned paths are complete only for depths below this value.
111+
truncated_at_depth: int | None = None
109112

110113
def _bind(self, client: InfrahubClient | InfrahubClientSync, branch: str | None) -> PathTraversalResult:
111114
self.source._bind(client, branch)

infrahub_sdk/graph_traversal/query.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
destination {{ {_PATH_NODE_FIELDS} }}
2323
count
2424
excluded_kinds
25+
truncated_at_depth
2526
}}
2627
}}"""
2728

@@ -64,6 +65,7 @@ def build_path_traversal_input(
6465
excluded_namespaces: list[str] | None = None,
6566
excluded_kinds: list[str] | None = None,
6667
included_kinds: list[str] | None = None,
68+
shortest_paths_only: bool | None = None,
6769
) -> dict[str, Any]:
6870
"""Build the ``PathTraversalInput`` variable, omitting unset optional fields."""
6971
data: dict[str, Any] = {"source_id": source_id, "destination_id": destination_id}
@@ -75,6 +77,7 @@ def build_path_traversal_input(
7577
"excluded_namespaces": excluded_namespaces,
7678
"excluded_kinds": excluded_kinds,
7779
"included_kinds": included_kinds,
80+
"shortest_paths_only": shortest_paths_only,
7881
}
7982
data.update({key: value for key, value in optional.items() if value is not None})
8083
return data

tests/unit/sdk/test_graph_traversal.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,16 @@ def test_build_path_traversal_input_includes_set_values() -> None:
121121
}
122122

123123

124+
def test_build_path_traversal_input_includes_shortest_paths_only() -> None:
125+
data = build_path_traversal_input("a", "b", shortest_paths_only=False)
126+
assert data == {"source_id": "a", "destination_id": "b", "shortest_paths_only": False}
127+
128+
129+
def test_build_path_traversal_input_omits_shortest_paths_only_when_none() -> None:
130+
data = build_path_traversal_input("a", "b", shortest_paths_only=None)
131+
assert "shortest_paths_only" not in data
132+
133+
124134
def test_build_reachable_nodes_input() -> None:
125135
data = build_reachable_nodes_input("a", ["DcimCable"], max_results=10, shortest_paths_only=True)
126136
assert data == {
@@ -159,6 +169,20 @@ def test_path_traversal_result_parsing() -> None:
159169
assert result.source.hfid == []
160170

161171

172+
def test_path_traversal_result_parses_truncated_at_depth() -> None:
173+
result = PathTraversalResult.model_validate({**PATH_RESULT, "truncated_at_depth": 5})
174+
assert result.truncated_at_depth == 5
175+
176+
177+
def test_path_traversal_result_truncated_at_depth_defaults_none() -> None:
178+
# Absent field (older server / completed search) parses as None.
179+
result = PathTraversalResult.model_validate(PATH_RESULT)
180+
assert result.truncated_at_depth is None
181+
# Explicit null (search completed within budget) also parses as None.
182+
result_null = PathTraversalResult.model_validate({**PATH_RESULT, "truncated_at_depth": None})
183+
assert result_null.truncated_at_depth is None
184+
185+
162186
def test_reachable_nodes_result_parsing() -> None:
163187
result = ReachableNodesResult.model_validate(REACHABLE_RESULT)
164188
assert result.count == 1
@@ -187,6 +211,44 @@ class DcimCable(CoreNode): ...
187211
assert sent["variables"]["data"]["kind_filter"] == ["DcimCable", "InterfacePhysical"]
188212

189213

214+
@pytest.mark.parametrize("client_type", ["standard", "sync"])
215+
async def test_traverse_paths_sends_shortest_paths_only(
216+
clients: BothClients, client_type: str, httpx_mock: HTTPXMock
217+
) -> None:
218+
httpx_mock.add_response(
219+
method="POST",
220+
url=PATH_TRAVERSAL_URL,
221+
match_headers={"X-Infrahub-Tracker": "query-path-traversal"},
222+
json={"data": {"InfrahubPathTraversal": PATH_RESULT}},
223+
)
224+
if client_type == "standard":
225+
await clients.standard.traverse_paths("a", "b", shortest_paths_only=False)
226+
else:
227+
clients.sync.traverse_paths("a", "b", shortest_paths_only=False)
228+
229+
sent = json.loads(httpx_mock.get_requests()[0].content)
230+
assert sent["variables"]["data"]["shortest_paths_only"] is False
231+
232+
233+
@pytest.mark.parametrize("client_type", ["standard", "sync"])
234+
async def test_traverse_paths_omits_shortest_paths_only_by_default(
235+
clients: BothClients, client_type: str, httpx_mock: HTTPXMock
236+
) -> None:
237+
httpx_mock.add_response(
238+
method="POST",
239+
url=PATH_TRAVERSAL_URL,
240+
match_headers={"X-Infrahub-Tracker": "query-path-traversal"},
241+
json={"data": {"InfrahubPathTraversal": PATH_RESULT}},
242+
)
243+
if client_type == "standard":
244+
await clients.standard.traverse_paths("a", "b")
245+
else:
246+
clients.sync.traverse_paths("a", "b")
247+
248+
sent = json.loads(httpx_mock.get_requests()[0].content)
249+
assert "shortest_paths_only" not in sent["variables"]["data"]
250+
251+
190252
async def test_traverse_paths_accepts_node_objects(
191253
clients: BothClients, location_schema: NodeSchemaAPI, httpx_mock: HTTPXMock
192254
) -> None:

0 commit comments

Comments
 (0)