From 0828e6d2b49a5e9ce7cdc61609be7f982ba6ffc4 Mon Sep 17 00:00:00 2001 From: Aaron McCarty Date: Wed, 1 Jul 2026 09:42:23 -0500 Subject: [PATCH] support for truncated_at_depth and shortest_paths_only (#1119) * support for truncated_at_depth and shortest_paths_only * docs formatting update --- .vale/styles/spelling-exceptions.txt | 1 + .../python-sdk/guides/graph_traversal.mdx | 12 ++++ .../sdk_ref/infrahub_sdk/client.mdx | 4 ++ infrahub_sdk/client.py | 8 +++ infrahub_sdk/graph_traversal/models.py | 3 + infrahub_sdk/graph_traversal/query.py | 3 + tests/unit/sdk/test_graph_traversal.py | 62 +++++++++++++++++++ 7 files changed, 93 insertions(+) diff --git a/.vale/styles/spelling-exceptions.txt b/.vale/styles/spelling-exceptions.txt index b0001351..068b304a 100644 --- a/.vale/styles/spelling-exceptions.txt +++ b/.vale/styles/spelling-exceptions.txt @@ -78,6 +78,7 @@ JSONSchema kbps Keycloak Loopbacks +loopless markdownlint MDX max_count diff --git a/docs/docs/python-sdk/guides/graph_traversal.mdx b/docs/docs/python-sdk/guides/graph_traversal.mdx index 41fca5d9..a800b076 100644 --- a/docs/docs/python-sdk/guides/graph_traversal.mdx +++ b/docs/docs/python-sdk/guides/graph_traversal.mdx @@ -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). + @@ -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 ) ``` @@ -111,6 +114,7 @@ result = await client.traverse_paths( kind_filter=["DcimCable", "InterfacePhysical"], relationship_filter=["dcimconnector__dcimendpoint"], included_kinds=["IpamIPPrefix"], + shortest_paths_only=False, ) ``` @@ -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`. 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 c23de529..00c44dc6 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx @@ -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. @@ -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. diff --git a/infrahub_sdk/client.py b/infrahub_sdk/client.py index 01e8d5f0..a948ecc9 100644 --- a/infrahub_sdk/client.py +++ b/infrahub_sdk/client.py @@ -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, @@ -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. @@ -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( @@ -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, @@ -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. @@ -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( diff --git a/infrahub_sdk/graph_traversal/models.py b/infrahub_sdk/graph_traversal/models.py index ac35dca0..65841b30 100644 --- a/infrahub_sdk/graph_traversal/models.py +++ b/infrahub_sdk/graph_traversal/models.py @@ -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) diff --git a/infrahub_sdk/graph_traversal/query.py b/infrahub_sdk/graph_traversal/query.py index d03cd313..9813e4dd 100644 --- a/infrahub_sdk/graph_traversal/query.py +++ b/infrahub_sdk/graph_traversal/query.py @@ -22,6 +22,7 @@ destination {{ {_PATH_NODE_FIELDS} }} count excluded_kinds + truncated_at_depth }} }}""" @@ -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} @@ -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 diff --git a/tests/unit/sdk/test_graph_traversal.py b/tests/unit/sdk/test_graph_traversal.py index 5a5cfa44..465895f2 100644 --- a/tests/unit/sdk/test_graph_traversal.py +++ b/tests/unit/sdk/test_graph_traversal.py @@ -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 == { @@ -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 @@ -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: