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..85c05ec9 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx @@ -216,6 +216,41 @@ Requires Infrahub 1.10 or later. - `VersionNotSupportedError`: If the server does not support graph traversal (pre-1.10). - `GraphQLError`: When the GraphQL response contains errors (e.g. unknown node). +#### `get_many` + +```python +get_many(self, spec: Mapping[str, Mapping[str, Sequence[str]]]) -> dict[str, list[InfrahubNode]] +``` + +Fetch nodes of multiple kinds in a single GraphQL operation. + +``spec`` maps each kind to a dict with a non-empty ``ids`` list (node UUIDs) +and an optional ``attributes`` list (attribute names to populate on the +returned nodes). One aliased subselection is emitted per kind, so the cost +is a single round-trip regardless of how many kinds the spec contains. + +Returned nodes only have the requested attributes populated; relationships +are not fetched. For full nodes, follow up with :meth:`get` or :meth:`filters`. + +**Args:** + +- `spec`: Mapping of kind name to a sub-mapping with keys ``ids`` (list of +node UUIDs, required and non-empty) and ``attributes`` (list of +attribute names, optional - defaults to none, in which case only +the id and typename are returned). +- `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. +- `populate_store`: When True (default), every hydrated node is added to +``client.store`` so later lookups by id can skip the network. + +**Raises:** + +- `ValidationError`: If the spec cannot be compiled into a query (empty, +missing ``ids``, malformed kind/attribute identifiers). +- `GraphQLError`: If the server rejects the query (for example, an unknown +kind or attribute name in the loaded schema). + #### `all` ```python @@ -758,6 +793,41 @@ Requires Infrahub 1.10 or later. - `VersionNotSupportedError`: If the server does not support graph traversal (pre-1.10). - `GraphQLError`: When the GraphQL response contains errors (e.g. unknown node). +#### `get_many` + +```python +get_many(self, spec: Mapping[str, Mapping[str, Sequence[str]]]) -> dict[str, list[InfrahubNodeSync]] +``` + +Fetch nodes of multiple kinds in a single GraphQL operation. + +``spec`` maps each kind to a dict with a non-empty ``ids`` list (node UUIDs) +and an optional ``attributes`` list (attribute names to populate on the +returned nodes). One aliased subselection is emitted per kind, so the cost +is a single round-trip regardless of how many kinds the spec contains. + +Returned nodes only have the requested attributes populated; relationships +are not fetched. For full nodes, follow up with :meth:`get` or :meth:`filters`. + +**Args:** + +- `spec`: Mapping of kind name to a sub-mapping with keys ``ids`` (list of +node UUIDs, required and non-empty) and ``attributes`` (list of +attribute names, optional - defaults to none, in which case only +the id and typename are returned). +- `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. +- `populate_store`: When True (default), every hydrated node is added to +``client.store`` so later lookups by id can skip the network. + +**Raises:** + +- `ValidationError`: If the spec cannot be compiled into a query (empty, +missing ``ids``, malformed kind/attribute identifiers). +- `GraphQLError`: If the server rejects the query (for example, an unknown +kind or attribute name in the loaded schema). + #### `all` ```python @@ -1075,3 +1145,25 @@ handle_relogin_sync(func: Callable[..., httpx.Response]) -> Callable[..., httpx. ```python get_kind_as_string(kind: str | type[SchemaType | SchemaTypeSync]) -> str ``` + +### `compile_get_many_query` + +```python +compile_get_many_query(spec: Mapping[str, Mapping[str, Sequence[str]]]) -> tuple[str, dict[str, list[str]], list[str]] +``` + +Compile a kind-to-spec mapping into a single aliased GraphQL query. + +Each entry of the input maps a kind name to a sub-mapping with an +``ids`` list (required, non-empty) and an optional ``attributes`` list. + +Returns ``(query, variables, kinds_ordered)``. The query is one operation with one +aliased block per kind (``k0``, ``k1``, ...) and one ``[ID]`` variable per kind +(``ids_0``, ``ids_1``, ...). Ids and attribute names are deduplicated and sorted +so the emitted query is deterministic. + +**Raises:** + +- `ValidationError`: If the spec is empty, has empty ``ids`` for a kind, omits +``ids`` entirely, or contains kind/attribute names that are not valid +GraphQL identifiers. diff --git a/infrahub_sdk/client.py b/infrahub_sdk/client.py index 01e8d5f0..8b794c45 100644 --- a/infrahub_sdk/client.py +++ b/infrahub_sdk/client.py @@ -3,8 +3,9 @@ import asyncio import copy import logging +import re import time -from collections.abc import AsyncIterator, Callable, Coroutine, Iterable, Iterator, Mapping, MutableMapping +from collections.abc import AsyncIterator, Callable, Coroutine, Iterable, Iterator, Mapping, MutableMapping, Sequence from contextlib import asynccontextmanager, contextmanager from datetime import datetime from enum import Enum @@ -33,6 +34,7 @@ ServerNotReachableError, ServerNotResponsiveError, URLNotFoundError, + ValidationError, VersionNotSupportedError, ) from .graph_traversal.models import PathTraversalResult, ReachableNodesResult @@ -163,6 +165,76 @@ def _resolve_traversal_node_id(node: str | InfrahubNode | InfrahubNodeSync, *, r raise Error(f"Cannot use an unsaved node as the graph traversal {role}; save it first.") from exc +_GRAPHQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def compile_get_many_query( + spec: Mapping[str, Mapping[str, Sequence[str]]], +) -> tuple[str, dict[str, list[str]], list[str]]: + """Compile a kind-to-spec mapping into a single aliased GraphQL query. + + Each entry of the input maps a kind name to a sub-mapping with an + ``ids`` list (required, non-empty) and an optional ``attributes`` list. + + Returns ``(query, variables, kinds_ordered)``. The query is one operation with one + aliased block per kind (``k0``, ``k1``, ...) and one ``[ID]`` variable per kind + (``ids_0``, ``ids_1``, ...). Ids and attribute names are deduplicated and sorted + so the emitted query is deterministic. + + Raises: + ValidationError: If the spec is empty, has empty ``ids`` for a kind, omits + ``ids`` entirely, or contains kind/attribute names that are not valid + GraphQL identifiers. + + """ + if not spec: + raise ValidationError(identifier="get_many.spec", message="spec must contain at least one kind") + + problems: list[str] = [] + kinds_ordered: list[str] = [] + variables: dict[str, list[str]] = {} + blocks: list[str] = [] + + for index, (kind, entry) in enumerate(spec.items()): + if not isinstance(kind, str) or not _GRAPHQL_IDENTIFIER_RE.match(kind): + problems.append(f"kind {kind!r} is not a valid GraphQL identifier") + continue + if not isinstance(entry, Mapping): + problems.append(f"{kind}: entry must be a mapping with an 'ids' key") + continue + ids = entry.get("ids") + if not ids or isinstance(ids, (str, bytes)): + # A bare string passed for ``ids`` would iterate character-by-character + # below and turn one id into N one-character GraphQL ids — silently + # wrong. Reject up front. + problems.append(f"{kind}: 'ids' must be a non-empty list of id strings") + continue + attributes = entry.get("attributes") or [] + if isinstance(attributes, (str, bytes)): + # Same trap as ``ids``: a bare string would split into single-char + # field names that each pass the identifier regex. + problems.append(f"{kind}: 'attributes' must be a list of attribute names") + continue + bad_attrs = [a for a in attributes if not isinstance(a, str) or not _GRAPHQL_IDENTIFIER_RE.match(a)] + if bad_attrs: + problems.append(f"{kind}: invalid attribute names {bad_attrs!r}") + continue + + var_name = f"ids_{index}" + variables[var_name] = sorted({str(node_id) for node_id in ids}) + attrs_selection = " ".join(f"{name} {{ value }}" for name in sorted(set(attributes))) + block_inner = f"__typename id {attrs_selection}".strip() + blocks.append(f"k{index}: {kind}(ids: ${var_name}) {{ edges {{ node {{ {block_inner} }} }} }}") + kinds_ordered.append(kind) + + if problems: + raise ValidationError(identifier="get_many.spec", messages=problems) + + declarations = ", ".join(f"${name}: [ID]" for name in variables) + query = f"query GetMany({declarations}) {{ {' '.join(blocks)} }}" + return query, variables, kinds_ordered + + class BaseClient: """Base class for InfrahubClient and InfrahubClientSync.""" @@ -875,6 +947,72 @@ async def reachable_nodes( result = ReachableNodesResult.model_validate(response["InfrahubReachableNodes"]) return result._bind(self, branch or self.default_branch) + async def get_many( + self, + spec: Mapping[str, Mapping[str, Sequence[str]]], + *, + branch: str | None = None, + at: Timestamp | str | None = None, + timeout: int | None = None, + populate_store: bool = True, + ) -> dict[str, list[InfrahubNode]]: + """Fetch nodes of multiple kinds in a single GraphQL operation. + + ``spec`` maps each kind to a dict with a non-empty ``ids`` list (node UUIDs) + and an optional ``attributes`` list (attribute names to populate on the + returned nodes). One aliased subselection is emitted per kind, so the cost + is a single round-trip regardless of how many kinds the spec contains. + + Returned nodes only have the requested attributes populated; relationships + are not fetched. For full nodes, follow up with :meth:`get` or :meth:`filters`. + + Args: + spec: Mapping of kind name to a sub-mapping with keys ``ids`` (list of + node UUIDs, required and non-empty) and ``attributes`` (list of + attribute names, optional - defaults to none, in which case only + the id and typename are returned). + 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. + populate_store: When True (default), every hydrated node is added to + ``client.store`` so later lookups by id can skip the network. + + Raises: + ValidationError: If the spec cannot be compiled into a query (empty, + missing ``ids``, malformed kind/attribute identifiers). + GraphQLError: If the server rejects the query (for example, an unknown + kind or attribute name in the loaded schema). + + """ + query, variables, kinds_ordered = compile_get_many_query(spec) + branch = branch or self.default_branch + response = await self.execute_graphql( + query=query, + variables=variables, + branch_name=branch, + at=Timestamp(at) if at else None, + timeout=timeout, + tracker="query-get-many", + operation_name="GetMany", + ) + + results: dict[str, list[InfrahubNode]] = {} + for index, kind in enumerate(kinds_ordered): + block = (response or {}).get(f"k{index}") or {} + nodes: list[InfrahubNode] = [] + for edge in block.get("edges") or []: + node = await InfrahubNode.from_graphql( + client=self, + branch=branch, + data=edge, + timeout=timeout, + ) + if populate_store: + self.store.set(node=node) + nodes.append(node) + results[kind] = nodes + return results + @overload async def all( self, @@ -2571,6 +2709,72 @@ def reachable_nodes( result = ReachableNodesResult.model_validate(response["InfrahubReachableNodes"]) return result._bind(self, branch or self.default_branch) + def get_many( + self, + spec: Mapping[str, Mapping[str, Sequence[str]]], + *, + branch: str | None = None, + at: Timestamp | str | None = None, + timeout: int | None = None, + populate_store: bool = True, + ) -> dict[str, list[InfrahubNodeSync]]: + """Fetch nodes of multiple kinds in a single GraphQL operation. + + ``spec`` maps each kind to a dict with a non-empty ``ids`` list (node UUIDs) + and an optional ``attributes`` list (attribute names to populate on the + returned nodes). One aliased subselection is emitted per kind, so the cost + is a single round-trip regardless of how many kinds the spec contains. + + Returned nodes only have the requested attributes populated; relationships + are not fetched. For full nodes, follow up with :meth:`get` or :meth:`filters`. + + Args: + spec: Mapping of kind name to a sub-mapping with keys ``ids`` (list of + node UUIDs, required and non-empty) and ``attributes`` (list of + attribute names, optional - defaults to none, in which case only + the id and typename are returned). + 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. + populate_store: When True (default), every hydrated node is added to + ``client.store`` so later lookups by id can skip the network. + + Raises: + ValidationError: If the spec cannot be compiled into a query (empty, + missing ``ids``, malformed kind/attribute identifiers). + GraphQLError: If the server rejects the query (for example, an unknown + kind or attribute name in the loaded schema). + + """ + query, variables, kinds_ordered = compile_get_many_query(spec) + branch = branch or self.default_branch + response = self.execute_graphql( + query=query, + variables=variables, + branch_name=branch, + at=Timestamp(at) if at else None, + timeout=timeout, + tracker="query-get-many", + operation_name="GetMany", + ) + + results: dict[str, list[InfrahubNodeSync]] = {} + for index, kind in enumerate(kinds_ordered): + block = (response or {}).get(f"k{index}") or {} + nodes: list[InfrahubNodeSync] = [] + for edge in block.get("edges") or []: + node = InfrahubNodeSync.from_graphql( + client=self, + branch=branch, + data=edge, + timeout=timeout, + ) + if populate_store: + self.store.set(node=node) + nodes.append(node) + results[kind] = nodes + return results + @overload def all( self, diff --git a/tests/unit/sdk/conftest.py b/tests/unit/sdk/conftest.py index ad5b70fd..43ec3599 100644 --- a/tests/unit/sdk/conftest.py +++ b/tests/unit/sdk/conftest.py @@ -76,6 +76,7 @@ def return_annotation_map() -> dict[str, str]: "InfrahubClient": "InfrahubClientSync", "InfrahubNode": "InfrahubNodeSync", "list[InfrahubNode]": "list[InfrahubNodeSync]", + "dict[str, list[InfrahubNode]]": "dict[str, list[InfrahubNodeSync]]", "Optional[InfrahubNode]": "Optional[InfrahubNodeSync]", "Optional[type[SchemaType]]": "Optional[type[SchemaTypeSync]]", "Optional[Union[CoreNode, SchemaType]]": "Optional[Union[CoreNodeSync, SchemaTypeSync]]", diff --git a/tests/unit/sdk/test_get_many.py b/tests/unit/sdk/test_get_many.py new file mode 100644 index 00000000..f5a6d1cf --- /dev/null +++ b/tests/unit/sdk/test_get_many.py @@ -0,0 +1,292 @@ +"""Tests for InfrahubClient.get_many and its query compiler. + +Pure-function tests cover the compiler's output and error paths; +parametrized BothClients tests cover the full HTTP round trip via +``httpx_mock`` at the transport boundary. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest + +from infrahub_sdk.client import compile_get_many_query +from infrahub_sdk.exceptions import NodeNotFoundError, ValidationError + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + + from infrahub_sdk.schema import NodeSchemaAPI + + from .conftest import BothClients + + +GET_MANY_URL = "http://mock/graphql/main" + + +# --- compiler (pure logic) -------------------------------------------------- + + +def test_single_kind_single_id_single_attribute() -> None: + query, variables, kinds = compile_get_many_query({"InfraDevice": {"ids": ["dev-1"], "attributes": ["name"]}}) + assert kinds == ["InfraDevice"] + assert variables == {"ids_0": ["dev-1"]} + assert query == ( + "query GetMany($ids_0: [ID]) { " + "k0: InfraDevice(ids: $ids_0) " + "{ edges { node { __typename id name { value } } } } }" + ) + + +def test_multiple_kinds_emit_one_alias_per_block() -> None: + query, variables, kinds = compile_get_many_query( + { + "InfraAutonomousSystem": {"ids": ["as-1", "as-2"], "attributes": ["asn"]}, + "InfraDevice": {"ids": ["dev-1"], "attributes": ["role", "name"]}, + } + ) + assert kinds == ["InfraAutonomousSystem", "InfraDevice"] + assert variables == {"ids_0": ["as-1", "as-2"], "ids_1": ["dev-1"]} + assert "k0: InfraAutonomousSystem(ids: $ids_0)" in query + assert "k1: InfraDevice(ids: $ids_1)" in query + assert "asn { value }" in query + # Attribute selection is sorted within a block. + assert query.index("name { value }") < query.index("role { value }") + assert "$ids_0: [ID]" in query + assert "$ids_1: [ID]" in query + + +def test_duplicate_ids_are_deduplicated_and_sorted() -> None: + _query, variables, _kinds = compile_get_many_query( + {"InfraDevice": {"ids": ["dev-2", "dev-1", "dev-1", "dev-2"], "attributes": ["name"]}} + ) + assert variables == {"ids_0": ["dev-1", "dev-2"]} + + +def test_duplicate_attributes_are_deduplicated() -> None: + query, _variables, _kinds = compile_get_many_query( + {"InfraDevice": {"ids": ["dev-1"], "attributes": ["name", "name", "role"]}} + ) + assert query.count("name { value }") == 1 + assert query.count("role { value }") == 1 + + +def test_omitted_attributes_emits_minimal_selection() -> None: + query, _variables, _kinds = compile_get_many_query({"InfraDevice": {"ids": ["dev-1"]}}) + assert "node { __typename id }" in query + assert "{ value }" not in query + + +def test_empty_attributes_list_emits_minimal_selection() -> None: + query, _variables, _kinds = compile_get_many_query({"InfraDevice": {"ids": ["dev-1"], "attributes": []}}) + assert "node { __typename id }" in query + + +def test_kind_order_in_spec_is_preserved() -> None: + _query, _variables, kinds = compile_get_many_query( + {"Zeta": {"ids": ["z"]}, "Alpha": {"ids": ["a"]}, "Mu": {"ids": ["m"]}} + ) + assert kinds == ["Zeta", "Alpha", "Mu"] + + +def test_empty_spec_raises() -> None: + with pytest.raises(ValidationError, match="spec must contain at least one kind"): + compile_get_many_query({}) + + +def test_empty_ids_list_raises() -> None: + with pytest.raises(ValidationError, match="'ids' must be a non-empty list"): + compile_get_many_query({"InfraDevice": {"ids": [], "attributes": ["name"]}}) + + +def test_missing_ids_key_raises() -> None: + with pytest.raises(ValidationError, match="'ids' must be a non-empty list"): + compile_get_many_query({"InfraDevice": {"attributes": ["name"]}}) + + +def test_non_mapping_entry_raises() -> None: + with pytest.raises(ValidationError, match="entry must be a mapping"): + compile_get_many_query({"InfraDevice": ["dev-1"]}) # type: ignore[dict-item] + + +def test_invalid_kind_identifier_raises() -> None: + with pytest.raises(ValidationError, match="not a valid GraphQL identifier"): + compile_get_many_query({"Infra Device": {"ids": ["dev-1"]}}) + + +def test_invalid_attribute_name_raises() -> None: + with pytest.raises(ValidationError, match="invalid attribute names"): + compile_get_many_query({"InfraDevice": {"ids": ["dev-1"], "attributes": ["name", "bad name"]}}) + + +def test_string_ids_rejected_not_iterated_as_chars() -> None: + # Passing a bare string would silently iterate character-by-character and + # send N one-character GraphQL ids. Reject up front. + with pytest.raises(ValidationError, match="'ids' must be a non-empty list"): + compile_get_many_query({"InfraDevice": {"ids": "dev-1"}}) # type: ignore[dict-item] + + +def test_bytes_ids_rejected() -> None: + with pytest.raises(ValidationError, match="'ids' must be a non-empty list"): + compile_get_many_query({"InfraDevice": {"ids": b"dev-1"}}) # type: ignore[dict-item] + + +def test_string_attributes_rejected_not_iterated_as_chars() -> None: + # Same trap as ``ids``: a bare string would split into single-character + # field names that each pass the GraphQL identifier regex. + with pytest.raises(ValidationError, match="'attributes' must be a list"): + compile_get_many_query({"InfraDevice": {"ids": ["dev-1"], "attributes": "name"}}) # type: ignore[dict-item] + + +def test_bytes_attributes_rejected() -> None: + with pytest.raises(ValidationError, match="'attributes' must be a list"): + compile_get_many_query({"InfraDevice": {"ids": ["dev-1"], "attributes": b"name"}}) # type: ignore[dict-item] + + +def test_multiple_problems_are_collected() -> None: + with pytest.raises(ValidationError) as excinfo: + compile_get_many_query( + { + "InfraDevice": {"ids": []}, + "Bad Kind": {"ids": ["a"]}, + "InfraSite": {"ids": ["s"], "attributes": ["bad attr"]}, + } + ) + assert excinfo.value.messages is not None + assert len(excinfo.value.messages) == 3 + + +# --- client methods (httpx_mock at the transport boundary) ------------------ + + +def _seed_schema(clients: BothClients, schema: NodeSchemaAPI) -> None: + cache_data = {"version": "1.0", "nodes": [schema.model_dump()]} + clients.standard.schema.set_cache(cache_data) + clients.sync.schema.set_cache(cache_data) + + +def _location_response(*, ids: list[str]) -> dict: + return { + "data": { + "k0": { + "edges": [ + { + "node": { + "__typename": "BuiltinLocation", + "id": node_id, + "name": {"value": f"site-{node_id}"}, + "type": {"value": "datacenter"}, + } + } + for node_id in ids + ] + } + } + } + + +@pytest.mark.parametrize("client_type", ["standard", "sync"]) +async def test_get_many_single_kind_round_trip( + clients: BothClients, + client_type: str, + location_schema: NodeSchemaAPI, + httpx_mock: HTTPXMock, +) -> None: + _seed_schema(clients, location_schema) + httpx_mock.add_response( + method="POST", + url=GET_MANY_URL, + match_headers={"X-Infrahub-Tracker": "query-get-many"}, + json=_location_response(ids=["loc-1", "loc-2"]), + ) + spec = {"BuiltinLocation": {"ids": ["loc-2", "loc-1"], "attributes": ["name", "type"]}} + + if client_type == "standard": + result = await clients.standard.get_many(spec) + else: + result = clients.sync.get_many(spec) + + assert list(result.keys()) == ["BuiltinLocation"] + nodes = result["BuiltinLocation"] + assert len(nodes) == 2 + assert {node.id for node in nodes} == {"loc-1", "loc-2"} + assert {node.get_kind() for node in nodes} == {"BuiltinLocation"} + + # The compiled query uses sorted/deduplicated ids + sent = json.loads(httpx_mock.get_requests()[0].content) + assert sent["variables"] == {"ids_0": ["loc-1", "loc-2"]} + assert "k0: BuiltinLocation(ids: $ids_0)" in sent["query"] + + +@pytest.mark.parametrize("client_type", ["standard", "sync"]) +async def test_get_many_populates_store_when_enabled( + clients: BothClients, + client_type: str, + location_schema: NodeSchemaAPI, + httpx_mock: HTTPXMock, +) -> None: + _seed_schema(clients, location_schema) + httpx_mock.add_response( + method="POST", + url=GET_MANY_URL, + match_headers={"X-Infrahub-Tracker": "query-get-many"}, + json=_location_response(ids=["loc-1"]), + ) + spec = {"BuiltinLocation": {"ids": ["loc-1"], "attributes": ["name"]}} + + if client_type == "standard": + await clients.standard.get_many(spec) + stored = clients.standard.store.get(key="loc-1") + else: + clients.sync.get_many(spec) + stored = clients.sync.store.get(key="loc-1") + + assert stored.id == "loc-1" + + +@pytest.mark.parametrize("client_type", ["standard", "sync"]) +async def test_get_many_skips_store_when_disabled( + clients: BothClients, + client_type: str, + location_schema: NodeSchemaAPI, + httpx_mock: HTTPXMock, +) -> None: + _seed_schema(clients, location_schema) + httpx_mock.add_response( + method="POST", + url=GET_MANY_URL, + match_headers={"X-Infrahub-Tracker": "query-get-many"}, + json=_location_response(ids=["loc-1"]), + ) + spec = {"BuiltinLocation": {"ids": ["loc-1"], "attributes": ["name"]}} + + if client_type == "standard": + await clients.standard.get_many(spec, populate_store=False) + store = clients.standard.store + else: + clients.sync.get_many(spec, populate_store=False) + store = clients.sync.store + + with pytest.raises(NodeNotFoundError): + store.get(key="loc-1") + + +@pytest.mark.parametrize("client_type", ["standard", "sync"]) +async def test_get_many_raises_on_invalid_spec_without_calling_server( + clients: BothClients, + client_type: str, + httpx_mock: HTTPXMock, +) -> None: + spec = {"BuiltinLocation": {"ids": []}} + + if client_type == "standard": + with pytest.raises(ValidationError, match="'ids' must be a non-empty list"): + await clients.standard.get_many(spec) + else: + with pytest.raises(ValidationError, match="'ids' must be a non-empty list"): + clients.sync.get_many(spec) + + # The compile failure short-circuits before any HTTP call is made. + assert httpx_mock.get_requests() == []