From a400e3b7284672fb5d12fc6a7efa273cff9f21bb Mon Sep 17 00:00:00 2001 From: Sola Infrahub User Date: Mon, 19 Jan 2026 09:27:56 +0100 Subject: [PATCH 01/12] IHS-183: Fix typing errors for protocols --- .pre-commit-config.yaml | 2 +- infrahub_sdk/ctl/branch.py | 2 +- infrahub_sdk/node/related_node.py | 2 +- infrahub_sdk/protocols_base.py | 9 +++------ infrahub_sdk/testing/repository.py | 2 +- pyproject.toml | 1 + 6 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 537c7969..1c393efd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.11.9 + rev: v0.14.10 hooks: # Run the linter. - id: ruff diff --git a/infrahub_sdk/ctl/branch.py b/infrahub_sdk/ctl/branch.py index 60d67e86..d169cb91 100644 --- a/infrahub_sdk/ctl/branch.py +++ b/infrahub_sdk/ctl/branch.py @@ -293,7 +293,7 @@ async def report( git_files_changed = await check_git_files_changed(client, branch=branch_name) proposed_changes = await client.filters( - kind=CoreProposedChange, # type: ignore[type-abstract] + kind=CoreProposedChange, source_branch__value=branch_name, include=["created_by"], prefetch_relationships=True, diff --git a/infrahub_sdk/node/related_node.py b/infrahub_sdk/node/related_node.py index 5b46a8f7..029c6c28 100644 --- a/infrahub_sdk/node/related_node.py +++ b/infrahub_sdk/node/related_node.py @@ -34,7 +34,7 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, self._properties_object = PROPERTIES_OBJECT self._properties = self._properties_flag + self._properties_object - self._peer = None + self._peer: InfrahubNode | InfrahubNodeSync | CoreNodeBase | None = None self._id: str | None = None self._hfid: list[str] | None = None self._display_label: str | None = None diff --git a/infrahub_sdk/protocols_base.py b/infrahub_sdk/protocols_base.py index 8a841b5b..6af77c5e 100644 --- a/infrahub_sdk/protocols_base.py +++ b/infrahub_sdk/protocols_base.py @@ -171,8 +171,7 @@ class AnyAttributeOptional(Attribute): value: float | None -@runtime_checkable -class CoreNodeBase(Protocol): +class CoreNodeBase: _schema: MainSchemaTypes _internal_id: str id: str # NOTE this is incorrect, should be str | None @@ -204,8 +203,7 @@ def is_resource_pool(self) -> bool: ... def get_raw_graphql_data(self) -> dict | None: ... -@runtime_checkable -class CoreNode(CoreNodeBase, Protocol): +class CoreNode(CoreNodeBase): async def save( self, allow_upsert: bool = False, @@ -229,8 +227,7 @@ async def add_relationships(self, relation_to_update: str, related_nodes: list[s async def remove_relationships(self, relation_to_update: str, related_nodes: list[str]) -> None: ... -@runtime_checkable -class CoreNodeSync(CoreNodeBase, Protocol): +class CoreNodeSync(CoreNodeBase): def save( self, allow_upsert: bool = False, diff --git a/infrahub_sdk/testing/repository.py b/infrahub_sdk/testing/repository.py index 9e974164..d07d2b1a 100644 --- a/infrahub_sdk/testing/repository.py +++ b/infrahub_sdk/testing/repository.py @@ -98,7 +98,7 @@ async def wait_for_sync_to_complete( ) -> bool: for _ in range(retries): repo = await client.get( - kind=CoreGenericRepository, # type: ignore[type-abstract] + kind=CoreGenericRepository, name__value=self.name, branch=branch or self.initial_branch, ) diff --git a/pyproject.toml b/pyproject.toml index 854fc39d..ce9348de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -177,6 +177,7 @@ invalid-assignment = "ignore" pretty = true ignore_missing_imports = true disallow_untyped_defs = true +disable_error_code = ["empty-body"] [[tool.mypy.overrides]] module = "infrahub_sdk.ctl.check" From cc49501bc01e032111d8f971836d85fa89a88836 Mon Sep 17 00:00:00 2001 From: Sola Infrahub User Date: Mon, 19 Jan 2026 09:46:57 +0100 Subject: [PATCH 02/12] fix ty --- infrahub_sdk/protocols_base.py | 18 ++++++++++++------ pyproject.toml | 1 - 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/infrahub_sdk/protocols_base.py b/infrahub_sdk/protocols_base.py index 6af77c5e..4282fa53 100644 --- a/infrahub_sdk/protocols_base.py +++ b/infrahub_sdk/protocols_base.py @@ -188,17 +188,23 @@ def get_human_friendly_id(self) -> list[str] | None: ... def get_human_friendly_id_as_string(self, include_kind: bool = False) -> str | None: ... - def get_kind(self) -> str: ... + def get_kind(self) -> str: + return "" - def get_all_kinds(self) -> list[str]: ... + def get_all_kinds(self) -> list[str]: + return [] - def get_branch(self) -> str: ... + def get_branch(self) -> str: + return "" - def is_ip_prefix(self) -> bool: ... + def is_ip_prefix(self) -> bool: + return False - def is_ip_address(self) -> bool: ... + def is_ip_address(self) -> bool: + return False - def is_resource_pool(self) -> bool: ... + def is_resource_pool(self) -> bool: + return False def get_raw_graphql_data(self) -> dict | None: ... diff --git a/pyproject.toml b/pyproject.toml index ce9348de..854fc39d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -177,7 +177,6 @@ invalid-assignment = "ignore" pretty = true ignore_missing_imports = true disallow_untyped_defs = true -disable_error_code = ["empty-body"] [[tool.mypy.overrides]] module = "infrahub_sdk.ctl.check" From 6760a25faac20cbe4144d0890268403888a4f8da Mon Sep 17 00:00:00 2001 From: Sola Infrahub User Date: Mon, 19 Jan 2026 10:41:43 +0100 Subject: [PATCH 03/12] fix failing tests --- infrahub_sdk/node/related_node.py | 12 +++++++----- infrahub_sdk/schema/__init__.py | 5 +++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/infrahub_sdk/node/related_node.py b/infrahub_sdk/node/related_node.py index 029c6c28..4553c2cb 100644 --- a/infrahub_sdk/node/related_node.py +++ b/infrahub_sdk/node/related_node.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from ..exceptions import Error from ..protocols_base import CoreNodeBase @@ -11,7 +11,7 @@ if TYPE_CHECKING: from ..client import InfrahubClient, InfrahubClientSync from ..schema import RelationshipSchemaAPI - from .node import InfrahubNode, InfrahubNodeSync + from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync class RelatedNodeBase: @@ -34,7 +34,7 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, self._properties_object = PROPERTIES_OBJECT self._properties = self._properties_flag + self._properties_object - self._peer: InfrahubNode | InfrahubNodeSync | CoreNodeBase | None = None + self._peer: InfrahubNode | InfrahubNodeSync | InfrahubNodeBase | CoreNodeBase | None = None self._id: str | None = None self._hfid: list[str] | None = None self._display_label: str | None = None @@ -43,8 +43,10 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, self._source_typename: str | None = None self._relationship_metadata: RelationshipMetadata | None = None - if isinstance(data, (CoreNodeBase)): - self._peer = data + # Check for InfrahubNodeBase instances using duck-typing (_schema attribute) + # to avoid circular imports, or CoreNodeBase instances + if isinstance(data, CoreNodeBase) or hasattr(data, "_schema"): + self._peer = cast("InfrahubNodeBase | CoreNodeBase", data) for prop in self._properties: setattr(self, prop, None) self._relationship_metadata = None diff --git a/infrahub_sdk/schema/__init__.py b/infrahub_sdk/schema/__init__.py index 3e61ad2a..83271772 100644 --- a/infrahub_sdk/schema/__init__.py +++ b/infrahub_sdk/schema/__init__.py @@ -21,6 +21,7 @@ ValidationError, ) from ..graphql import Mutation +from ..protocols_base import CoreNode, CoreNodeSync from ..queries import SCHEMA_HASH_SYNC_STATUS from .main import ( AttributeSchema, @@ -207,14 +208,14 @@ def _get_schema_name(schema: type[SchemaType | SchemaTypeSync] | str) -> str: if isinstance(schema, str): return schema - if hasattr(schema, "_is_runtime_protocol") and getattr(schema, "_is_runtime_protocol", None): + if isinstance(schema, type) and issubclass(schema, (CoreNode, CoreNodeSync)): if inspect.iscoroutinefunction(schema.save): return schema.__name__ if schema.__name__[-4:] == "Sync": return schema.__name__[:-4] return schema.__name__ - raise ValueError("schema must be a protocol or a string") + raise ValueError("schema must be a CoreNode subclass or a string") @staticmethod def _parse_schema_response(response: httpx.Response, branch: str) -> MutableMapping[str, Any]: From 9ac036b0ace3996553871420f6608e2cdbb6a5e0 Mon Sep 17 00:00:00 2001 From: Sola Infrahub User Date: Tue, 20 Jan 2026 06:01:46 +0100 Subject: [PATCH 04/12] address comments --- infrahub_sdk/node/related_node.py | 2 +- infrahub_sdk/protocols_base.py | 12 ++++++------ infrahub_sdk/schema/__init__.py | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/infrahub_sdk/node/related_node.py b/infrahub_sdk/node/related_node.py index 4553c2cb..67171f99 100644 --- a/infrahub_sdk/node/related_node.py +++ b/infrahub_sdk/node/related_node.py @@ -34,7 +34,7 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, self._properties_object = PROPERTIES_OBJECT self._properties = self._properties_flag + self._properties_object - self._peer: InfrahubNode | InfrahubNodeSync | InfrahubNodeBase | CoreNodeBase | None = None + self._peer: InfrahubNodeBase | CoreNodeBase | None = None self._id: str | None = None self._hfid: list[str] | None = None self._display_label: str | None = None diff --git a/infrahub_sdk/protocols_base.py b/infrahub_sdk/protocols_base.py index 4282fa53..ba920552 100644 --- a/infrahub_sdk/protocols_base.py +++ b/infrahub_sdk/protocols_base.py @@ -189,22 +189,22 @@ def get_human_friendly_id(self) -> list[str] | None: ... def get_human_friendly_id_as_string(self, include_kind: bool = False) -> str | None: ... def get_kind(self) -> str: - return "" + raise NotImplementedError() def get_all_kinds(self) -> list[str]: - return [] + raise NotImplementedError() def get_branch(self) -> str: - return "" + raise NotImplementedError() def is_ip_prefix(self) -> bool: - return False + raise NotImplementedError() def is_ip_address(self) -> bool: - return False + raise NotImplementedError() def is_resource_pool(self) -> bool: - return False + raise NotImplementedError() def get_raw_graphql_data(self) -> dict | None: ... diff --git a/infrahub_sdk/schema/__init__.py b/infrahub_sdk/schema/__init__.py index 83271772..febb204b 100644 --- a/infrahub_sdk/schema/__init__.py +++ b/infrahub_sdk/schema/__init__.py @@ -21,7 +21,7 @@ ValidationError, ) from ..graphql import Mutation -from ..protocols_base import CoreNode, CoreNodeSync +from ..protocols_base import CoreNodeBase from ..queries import SCHEMA_HASH_SYNC_STATUS from .main import ( AttributeSchema, @@ -208,7 +208,7 @@ def _get_schema_name(schema: type[SchemaType | SchemaTypeSync] | str) -> str: if isinstance(schema, str): return schema - if isinstance(schema, type) and issubclass(schema, (CoreNode, CoreNodeSync)): + if issubclass(schema, CoreNodeBase): if inspect.iscoroutinefunction(schema.save): return schema.__name__ if schema.__name__[-4:] == "Sync": From 323fbe6ab6689cc44a448071e60348a8643dd10f Mon Sep 17 00:00:00 2001 From: Aaron McCarty Date: Wed, 21 Jan 2026 16:10:51 -0800 Subject: [PATCH 05/12] use CoreNodeBase in get_schema_name --- infrahub_sdk/store.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/infrahub_sdk/store.py b/infrahub_sdk/store.py index 6420495b..aaf72b45 100644 --- a/infrahub_sdk/store.py +++ b/infrahub_sdk/store.py @@ -1,8 +1,11 @@ from __future__ import annotations +import inspect import warnings from typing import TYPE_CHECKING, Literal, overload +from infrahub_sdk.protocols_base import CoreNodeBase + from .exceptions import NodeInvalidError, NodeNotFoundError from .node.parsers import parse_human_friendly_id @@ -16,8 +19,15 @@ def get_schema_name(schema: type[SchemaType | SchemaTypeSync] | str | None = Non if isinstance(schema, str): return schema - if hasattr(schema, "_is_runtime_protocol") and schema._is_runtime_protocol: # type: ignore[union-attr] - return schema.__name__ # type: ignore[union-attr] + if schema is None: + return None + + if issubclass(schema, CoreNodeBase): + if inspect.iscoroutinefunction(schema.save): + return schema.__name__ + if schema.__name__[-4:] == "Sync": + return schema.__name__[:-4] + return schema.__name__ return None @@ -101,6 +111,8 @@ def get( message=f"Found a node of a different kind instead of {kind} for key {key!r} in the store ({self.branch_name})", ) + breakpoint() + raise NodeNotFoundError( identifier={"key": [key] if isinstance(key, str) else key}, message=f"Unable to find the node {key!r} in the store ({self.branch_name})", From 23c119c894af3e33894d43646b2bd528ec9b5a92 Mon Sep 17 00:00:00 2001 From: Aaron McCarty Date: Wed, 21 Jan 2026 16:14:22 -0800 Subject: [PATCH 06/12] remove breakpoint --- infrahub_sdk/store.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/infrahub_sdk/store.py b/infrahub_sdk/store.py index aaf72b45..e8406b10 100644 --- a/infrahub_sdk/store.py +++ b/infrahub_sdk/store.py @@ -111,8 +111,6 @@ def get( message=f"Found a node of a different kind instead of {kind} for key {key!r} in the store ({self.branch_name})", ) - breakpoint() - raise NodeNotFoundError( identifier={"key": [key] if isinstance(key, str) else key}, message=f"Unable to find the node {key!r} in the store ({self.branch_name})", From 92b543635df6af8f08d75d54d76d5a3601c53f8f Mon Sep 17 00:00:00 2001 From: Sola Babatunde Date: Thu, 22 Jan 2026 08:58:05 +0100 Subject: [PATCH 07/12] add test for store get_schema_name --- tests/unit/sdk/test_store.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/unit/sdk/test_store.py b/tests/unit/sdk/test_store.py index 83644aae..b049616a 100644 --- a/tests/unit/sdk/test_store.py +++ b/tests/unit/sdk/test_store.py @@ -6,7 +6,8 @@ from infrahub_sdk.exceptions import NodeInvalidError, NodeNotFoundError from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync -from infrahub_sdk.store import NodeStore, NodeStoreSync +from infrahub_sdk.protocols import BuiltinIPPrefix +from infrahub_sdk.store import NodeStore, NodeStoreSync, get_schema_name if TYPE_CHECKING: from infrahub_sdk.schema import NodeSchemaAPI @@ -157,3 +158,7 @@ def test_node_store_get_with_hfid( store.get(kind="BuiltinLocation", key="anotherkey") with pytest.raises(NodeNotFoundError): store.get(key="anotherkey") + + +def test_store_get_schema_name() -> None: + assert get_schema_name(schema=BuiltinIPPrefix) == BuiltinIPPrefix.__name__ From 6c5bd98314e3649776b60547fac6d64806f1bc0e Mon Sep 17 00:00:00 2001 From: Sola Babatunde Date: Thu, 22 Jan 2026 09:25:25 +0100 Subject: [PATCH 08/12] update test --- tests/unit/sdk/test_store.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/sdk/test_store.py b/tests/unit/sdk/test_store.py index b049616a..eaeab57c 100644 --- a/tests/unit/sdk/test_store.py +++ b/tests/unit/sdk/test_store.py @@ -6,7 +6,7 @@ from infrahub_sdk.exceptions import NodeInvalidError, NodeNotFoundError from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync -from infrahub_sdk.protocols import BuiltinIPPrefix +from infrahub_sdk.protocols import BuiltinIPAddressSync, BuiltinIPPrefix from infrahub_sdk.store import NodeStore, NodeStoreSync, get_schema_name if TYPE_CHECKING: @@ -162,3 +162,4 @@ def test_node_store_get_with_hfid( def test_store_get_schema_name() -> None: assert get_schema_name(schema=BuiltinIPPrefix) == BuiltinIPPrefix.__name__ + assert get_schema_name(schema=BuiltinIPAddressSync) == BuiltinIPAddressSync.__name__[:-4] From 49bd3ed91abc7e8421c3415b6ef67416edbc3dc8 Mon Sep 17 00:00:00 2001 From: Mikhail Yohman Date: Fri, 5 Jun 2026 10:04:46 -0600 Subject: [PATCH 09/12] Make RelatedNode/RelationshipManager generic over peer type (#1063) Parameterise RelatedNode, RelatedNodeSync, RelationshipManager and RelationshipManagerSync over the peer type (defaulting to InfrahubNode/ InfrahubNodeSync for backward compatibility) and have infrahubctl protocols emit the peer type for each relationship. Relationship traversal via .peer/ .peers/indexing now preserves the peer type instead of collapsing to the dynamic InfrahubNode. Co-Authored-By: Claude Opus 4.8 (1M context) --- changelog/1063.fixed.md | 3 ++ infrahub_sdk/node/related_node.py | 35 ++++++++----- infrahub_sdk/node/relationship.py | 49 +++++++++++++------ infrahub_sdk/protocols_generator/generator.py | 11 +++-- infrahub_sdk/protocols_generator/template.j2 | 12 ++--- tests/unit/sdk/test_protocols_generator.py | 14 +++--- 6 files changed, 79 insertions(+), 45 deletions(-) create mode 100644 changelog/1063.fixed.md diff --git a/changelog/1063.fixed.md b/changelog/1063.fixed.md new file mode 100644 index 00000000..5fde0057 --- /dev/null +++ b/changelog/1063.fixed.md @@ -0,0 +1,3 @@ +`RelatedNode`, `RelatedNodeSync`, `RelationshipManager` and `RelationshipManagerSync` are now generic over their peer type, and `infrahubctl protocols` parameterises generated relationships accordingly (e.g. `device: RelatedNode[NetworkDevice]`, `interfaces: RelationshipManager[NetworkInterface]`). + +Traversing a relationship via `.peer`, `.peers` or indexing now preserves the peer's type instead of collapsing to the dynamic `InfrahubNode`, so chains such as `device.rack.peer.name.value` type-check under mypy/ty without casts. Existing un-parameterised `RelatedNode` / `RelationshipManager` usage is unaffected — the peer type defaults to `InfrahubNode` / `InfrahubNodeSync`, preserving current behaviour. ([#1063](https://github.com/opsmill/infrahub-sdk-python/issues/1063)) diff --git a/infrahub_sdk/node/related_node.py b/infrahub_sdk/node/related_node.py index 67171f99..1e2c8bfa 100644 --- a/infrahub_sdk/node/related_node.py +++ b/infrahub_sdk/node/related_node.py @@ -1,7 +1,9 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Generic, cast + +from typing_extensions import TypeVar from ..exceptions import Error from ..protocols_base import CoreNodeBase @@ -13,6 +15,13 @@ from ..schema import RelationshipSchemaAPI from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync +# Type of the related peer node. Defaults to ``InfrahubNode``/``InfrahubNodeSync`` so that +# existing un-parameterised ``RelatedNode`` / ``RelatedNodeSync`` usage keeps returning the +# dynamic node, while generated protocols can parameterise it (e.g. ``RelatedNode[CoreDevice]``) +# to preserve the peer type through ``.peer`` / ``.get()``. +PeerT = TypeVar("PeerT", default="InfrahubNode") +PeerTSync = TypeVar("PeerTSync", default="InfrahubNodeSync") + class RelatedNodeBase: """Base class for representing a related node in a relationship.""" @@ -212,7 +221,7 @@ def _generate_query_data( return data -class RelatedNode(RelatedNodeBase): +class RelatedNode(RelatedNodeBase, Generic[PeerT]): """Represents a RelatedNodeBase in an asynchronous context.""" def __init__( @@ -243,23 +252,23 @@ async def fetch(self, timeout: int | None = None) -> None: ) @property - def peer(self) -> InfrahubNode: + def peer(self) -> PeerT: return self.get() - def get(self) -> InfrahubNode: + def get(self) -> PeerT: if self._peer: - return self._peer # type: ignore[return-value] + return cast("PeerT", self._peer) if self.id and self.typename: - return self._client.store.get(key=self.id, kind=self.typename, branch=self._branch) # type: ignore[return-value] + return cast("PeerT", self._client.store.get(key=self.id, kind=self.typename, branch=self._branch)) if self.hfid_str: - return self._client.store.get(key=self.hfid_str, branch=self._branch) # type: ignore[return-value] + return cast("PeerT", self._client.store.get(key=self.hfid_str, branch=self._branch)) raise ValueError("Node must have at least one identifier (ID or HFID) to query it.") -class RelatedNodeSync(RelatedNodeBase): +class RelatedNodeSync(RelatedNodeBase, Generic[PeerTSync]): """Represents a related node in a synchronous context.""" def __init__( @@ -290,17 +299,17 @@ def fetch(self, timeout: int | None = None) -> None: ) @property - def peer(self) -> InfrahubNodeSync: + def peer(self) -> PeerTSync: return self.get() - def get(self) -> InfrahubNodeSync: + def get(self) -> PeerTSync: if self._peer: - return self._peer # type: ignore[return-value] + return cast("PeerTSync", self._peer) if self.id and self.typename: - return self._client.store.get(key=self.id, kind=self.typename, branch=self._branch) # type: ignore[return-value] + return cast("PeerTSync", self._client.store.get(key=self.id, kind=self.typename, branch=self._branch)) if self.hfid_str: - return self._client.store.get(key=self.hfid_str, branch=self._branch) # type: ignore[return-value] + return cast("PeerTSync", self._client.store.get(key=self.hfid_str, branch=self._branch)) raise ValueError("Node must have at least one identifier (ID or HFID) to query it.") diff --git a/infrahub_sdk/node/relationship.py b/infrahub_sdk/node/relationship.py index dcd33c9c..9456ba50 100644 --- a/infrahub_sdk/node/relationship.py +++ b/infrahub_sdk/node/relationship.py @@ -2,7 +2,7 @@ from collections import defaultdict from collections.abc import Iterable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Generic, cast from ..exceptions import ( Error, @@ -11,7 +11,7 @@ from ..types import Order from .constants import PROPERTIES_FLAG, PROPERTIES_OBJECT from .metadata import NodeMetadata, RelationshipMetadata -from .related_node import RelatedNode, RelatedNodeSync +from .related_node import PeerT, PeerTSync, RelatedNode, RelatedNodeSync if TYPE_CHECKING: from ..client import InfrahubClient, InfrahubClientSync @@ -19,7 +19,7 @@ from .node import InfrahubNode, InfrahubNodeSync -class RelationshipManagerBase: +class RelationshipManagerBase(Generic[PeerT]): """Base class for RelationshipManager and RelationshipManagerSync""" def __init__(self, name: str, branch: str, schema: RelationshipSchemaAPI) -> None: @@ -39,7 +39,7 @@ def __init__(self, name: str, branch: str, schema: RelationshipSchemaAPI) -> Non self._properties_object = PROPERTIES_OBJECT self._properties = self._properties_flag + self._properties_object - self.peers: list[RelatedNode | RelatedNodeSync] = [] + self.peers: list[RelatedNode[PeerT] | RelatedNodeSync[PeerT]] = [] @property def peer_ids(self) -> list[str]: @@ -112,7 +112,7 @@ def _generate_query_data( return data -class RelationshipManager(RelationshipManagerBase): +class RelationshipManager(RelationshipManagerBase[PeerT]): """Manages relationships of a node in an asynchronous context.""" def __init__( @@ -147,18 +147,24 @@ def __init__( if isinstance(data, list): for item in data: self.peers.append( - RelatedNode(name=name, client=self.client, branch=self.branch, schema=schema, data=item) + cast( + "RelatedNode[PeerT]", + RelatedNode(name=name, client=self.client, branch=self.branch, schema=schema, data=item), + ) ) elif isinstance(data, dict) and "edges" in data: for item in data["edges"]: self.peers.append( - RelatedNode(name=name, client=self.client, branch=self.branch, schema=schema, data=item) + cast( + "RelatedNode[PeerT]", + RelatedNode(name=name, client=self.client, branch=self.branch, schema=schema, data=item), + ) ) else: raise ValueError(f"Unexpected format for {name} found a {type(data)}, {data}") - def __getitem__(self, item: int) -> RelatedNode: - return self.peers[item] # type: ignore[return-value] + def __getitem__(self, item: int) -> RelatedNode[PeerT]: + return cast("RelatedNode[PeerT]", self.peers[item]) async def fetch(self) -> None: if not self.initialized: @@ -200,7 +206,9 @@ def add(self, data: str | RelatedNode | dict) -> None: """Add a new peer to this relationship.""" if not self.initialized: raise UninitializedError("Must call fetch() on RelationshipManager before editing members") - new_node = RelatedNode(schema=self.schema, client=self.client, branch=self.branch, data=data) + new_node = cast( + "RelatedNode[PeerT]", RelatedNode(schema=self.schema, client=self.client, branch=self.branch, data=data) + ) if (new_node.id and new_node.id not in self.peer_ids) or ( new_node.hfid and new_node.hfid not in self.peer_hfids @@ -235,7 +243,7 @@ def remove(self, data: str | RelatedNode | dict) -> None: self._has_update = True -class RelationshipManagerSync(RelationshipManagerBase): +class RelationshipManagerSync(RelationshipManagerBase[PeerTSync]): """Manages relationships of a node in a synchronous context.""" def __init__( @@ -270,18 +278,24 @@ def __init__( if isinstance(data, list): for item in data: self.peers.append( - RelatedNodeSync(name=name, client=self.client, branch=self.branch, schema=schema, data=item) + cast( + "RelatedNodeSync[PeerTSync]", + RelatedNodeSync(name=name, client=self.client, branch=self.branch, schema=schema, data=item), + ) ) elif isinstance(data, dict) and "edges" in data: for item in data["edges"]: self.peers.append( - RelatedNodeSync(name=name, client=self.client, branch=self.branch, schema=schema, data=item) + cast( + "RelatedNodeSync[PeerTSync]", + RelatedNodeSync(name=name, client=self.client, branch=self.branch, schema=schema, data=item), + ) ) else: raise ValueError(f"Unexpected format for {name} found a {type(data)}, {data}") - def __getitem__(self, item: int) -> RelatedNodeSync: - return self.peers[item] # type: ignore[return-value] + def __getitem__(self, item: int) -> RelatedNodeSync[PeerTSync]: + return cast("RelatedNodeSync[PeerTSync]", self.peers[item]) def fetch(self) -> None: if not self.initialized: @@ -323,7 +337,10 @@ def add(self, data: str | RelatedNodeSync | dict) -> None: """Add a new peer to this relationship.""" if not self.initialized: raise UninitializedError("Must call fetch() on RelationshipManager before editing members") - new_node = RelatedNodeSync(schema=self.schema, client=self.client, branch=self.branch, data=data) + new_node = cast( + "RelatedNodeSync[PeerTSync]", + RelatedNodeSync(schema=self.schema, client=self.client, branch=self.branch, data=data), + ) if (new_node.id and new_node.id not in self.peer_ids) or ( new_node.hfid and new_node.hfid not in self.peer_hfids diff --git a/infrahub_sdk/protocols_generator/generator.py b/infrahub_sdk/protocols_generator/generator.py index ee80732f..e08afb17 100644 --- a/infrahub_sdk/protocols_generator/generator.py +++ b/infrahub_sdk/protocols_generator/generator.py @@ -117,10 +117,10 @@ def _jinja2_filter_render_attribute(value: AttributeSchemaAPI) -> str: return f"{value.name}: {attribute_kind}" - @staticmethod - def _jinja2_filter_render_relationship(value: RelationshipSchemaAPI, sync: bool = False) -> str: + def _jinja2_filter_render_relationship(self, value: RelationshipSchemaAPI, sync: bool = False) -> str: name = value.name cardinality = value.cardinality + peer = value.peer type_ = "RelatedNode" if cardinality == "many": @@ -128,8 +128,13 @@ def _jinja2_filter_render_relationship(value: RelationshipSchemaAPI, sync: bool if sync: type_ += "Sync" + # Core peer protocols expose a dedicated ``*Sync`` variant; reference it in sync + # output. Locally generated node/generic classes keep their name (they already + # inherit the sync base class), so only swap when a ``*Sync`` peer actually exists. + if f"{peer}Sync" in self.base_protocols: + peer = f"{peer}Sync" - return f"{name}: {type_}" + return f"{name}: {type_}[{peer}]" @staticmethod def _sort_and_filter_models( diff --git a/infrahub_sdk/protocols_generator/template.j2 b/infrahub_sdk/protocols_generator/template.j2 index 2f4f9e89..eeb2ef6c 100644 --- a/infrahub_sdk/protocols_generator/template.j2 +++ b/infrahub_sdk/protocols_generator/template.j2 @@ -52,8 +52,8 @@ class {{ generic.namespace + generic.name }}({{core_node_name}}): {{ relationship | render_relationship(sync) }} {% endfor %} {% if generic.hierarchical | default(false) %} - parent: {{ "RelatedNode" | syncify(sync) }} - children: {{ "RelationshipManager" | syncify(sync) }} + parent: {{ "RelatedNode" | syncify(sync) }}[{{ generic.namespace + generic.name }}] + children: {{ "RelationshipManager" | syncify(sync) }}[{{ generic.namespace + generic.name }}] {% endif %} {% endfor %} @@ -71,8 +71,8 @@ class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | jo {{ relationship | render_relationship(sync) }} {% endfor %} {% if node.hierarchical | default(false) %} - parent: {{ "RelatedNode" | syncify(sync) }} - children: {{ "RelationshipManager" | syncify(sync) }} + parent: {{ "RelatedNode" | syncify(sync) }}[{{ node.namespace + node.name }}] + children: {{ "RelationshipManager" | syncify(sync) }}[{{ node.namespace + node.name }}] {% endif %} {% endfor %} @@ -91,8 +91,8 @@ class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | jo {{ relationship | render_relationship(sync) }} {% endfor %} {% if node.hierarchical | default(false) %} - parent: {{ "RelatedNode" | syncify(sync) }} - children: {{ "RelationshipManager" | syncify(sync) }} + parent: {{ "RelatedNode" | syncify(sync) }}[{{ node.namespace + node.name }}] + children: {{ "RelationshipManager" | syncify(sync) }}[{{ node.namespace + node.name }}] {% endif %} {% endfor %} diff --git a/tests/unit/sdk/test_protocols_generator.py b/tests/unit/sdk/test_protocols_generator.py index 55796db6..5e6fbe51 100644 --- a/tests/unit/sdk/test_protocols_generator.py +++ b/tests/unit/sdk/test_protocols_generator.py @@ -62,13 +62,13 @@ class LocationSite(LocationGeneric): name: String physical_address: StringOptional shortname: String - children: RelationshipManagerSync - member_of_groups: RelationshipManagerSync - parent: RelatedNodeSync - profiles: RelationshipManagerSync - servers: RelationshipManagerSync - subscriber_of_groups: RelationshipManagerSync - tags: RelationshipManagerSync + children: RelationshipManagerSync[LocationRack] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + parent: RelatedNodeSync[LocationCountry] + profiles: RelationshipManagerSync[CoreProfileSync] + servers: RelationshipManagerSync[NetworkManagementServer] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + tags: RelationshipManagerSync[BuiltinTagSync] """ assert location_site_sync in sync_protocols From a910be66ccfd88d544b7dbdb823e265d3e863c0e Mon Sep 17 00:00:00 2001 From: Mikhail Yohman Date: Sat, 6 Jun 2026 21:07:13 -0600 Subject: [PATCH 10/12] Type cardinality-one relationships as an assignable descriptor (#1064) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated cardinality-one relationships now use a RelationshipAttribute descriptor that reads back as RelatedNode[Peer] but accepts assignment of an id string, an HFID, a peer node, or None — matching the runtime __setattr__ that wraps the value in a RelatedNode. Previously relationships were typed read-only as RelatedNode, so node.rel = "" / node.rel = peer failed type checking. Co-Authored-By: Claude Opus 4.8 (1M context) --- changelog/1064.fixed.md | 3 ++ infrahub_sdk/node/__init__.py | 10 ++++- infrahub_sdk/node/related_node.py | 44 ++++++++++++++++++- infrahub_sdk/protocols_generator/generator.py | 4 +- infrahub_sdk/protocols_generator/template.j2 | 8 ++-- tests/unit/sdk/test_protocols_generator.py | 2 +- 6 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 changelog/1064.fixed.md diff --git a/changelog/1064.fixed.md b/changelog/1064.fixed.md new file mode 100644 index 00000000..d02b786b --- /dev/null +++ b/changelog/1064.fixed.md @@ -0,0 +1,3 @@ +Cardinality-one relationships in generated protocols are now typed with a `RelationshipAttribute[...]` descriptor. It still reads back as a typed `RelatedNode[Peer]` (so `.peer` keeps the peer type), but it accepts assignment of an id string, an HFID, a peer node, or `None` — mirroring the runtime `InfrahubNode.__setattr__`, which wraps the assigned value in a `RelatedNode`. + +Previously relationships were typed read-only as `RelatedNode`, so the documented way of setting a relationship (`node.rel = ""` or `node.rel = peer_node`) failed under mypy/ty with an `assignment` error. The descriptor only appears in generated protocols and is never instantiated at runtime. ([#1064](https://github.com/opsmill/infrahub-sdk-python/issues/1064)) diff --git a/infrahub_sdk/node/__init__.py b/infrahub_sdk/node/__init__.py index 2a1c39e5..2bc248ae 100644 --- a/infrahub_sdk/node/__init__.py +++ b/infrahub_sdk/node/__init__.py @@ -14,7 +14,13 @@ from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync from .parsers import parse_human_friendly_id from .property import NodeProperty -from .related_node import RelatedNode, RelatedNodeBase, RelatedNodeSync +from .related_node import ( + RelatedNode, + RelatedNodeBase, + RelatedNodeSync, + RelationshipAttribute, + RelationshipAttributeSync, +) from .relationship import RelationshipManager, RelationshipManagerBase, RelationshipManagerSync __all__ = [ @@ -34,6 +40,8 @@ "RelatedNode", "RelatedNodeBase", "RelatedNodeSync", + "RelationshipAttribute", + "RelationshipAttributeSync", "RelationshipManager", "RelationshipManagerBase", "RelationshipManagerSync", diff --git a/infrahub_sdk/node/related_node.py b/infrahub_sdk/node/related_node.py index 1e2c8bfa..c623b84f 100644 --- a/infrahub_sdk/node/related_node.py +++ b/infrahub_sdk/node/related_node.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Any, Generic, cast +from typing import TYPE_CHECKING, Any, Generic, cast, overload from typing_extensions import TypeVar @@ -313,3 +313,45 @@ def get(self) -> PeerTSync: return cast("PeerTSync", self._client.store.get(key=self.hfid_str, branch=self._branch)) raise ValueError("Node must have at least one identifier (ID or HFID) to query it.") + + +class RelationshipAttribute(Generic[PeerT]): + """Typing descriptor for a cardinality-one relationship on a generated protocol. + + It reads back as ``RelatedNode[PeerT]`` (so ``.peer`` keeps the peer type) but accepts + assignment of an id string, an HFID, a peer node, or ``None`` — mirroring the runtime + ``InfrahubNode.__setattr__`` behaviour, which wraps the assigned value in a ``RelatedNode``. + + This type only appears in generated protocols (it is never instantiated at runtime), so it + exists purely to give ``node.rel`` separate read and assignment types under a type checker. + """ + + @overload + def __get__(self, instance: None, owner: Any = None) -> RelationshipAttribute[PeerT]: ... + + @overload + def __get__(self, instance: object, owner: Any = None) -> RelatedNode[PeerT]: ... + + def __get__(self, instance: object | None, owner: Any = None) -> RelationshipAttribute[PeerT] | RelatedNode[PeerT]: + raise NotImplementedError # typing-only descriptor; never invoked at runtime + + def __set__(self, instance: object, value: str | list[str] | PeerT | None) -> None: + raise NotImplementedError # typing-only descriptor; never invoked at runtime + + +class RelationshipAttributeSync(Generic[PeerTSync]): + """Synchronous counterpart of :class:`RelationshipAttribute`.""" + + @overload + def __get__(self, instance: None, owner: Any = None) -> RelationshipAttributeSync[PeerTSync]: ... + + @overload + def __get__(self, instance: object, owner: Any = None) -> RelatedNodeSync[PeerTSync]: ... + + def __get__( + self, instance: object | None, owner: Any = None + ) -> RelationshipAttributeSync[PeerTSync] | RelatedNodeSync[PeerTSync]: + raise NotImplementedError # typing-only descriptor; never invoked at runtime + + def __set__(self, instance: object, value: str | list[str] | PeerTSync | None) -> None: + raise NotImplementedError # typing-only descriptor; never invoked at runtime diff --git a/infrahub_sdk/protocols_generator/generator.py b/infrahub_sdk/protocols_generator/generator.py index e08afb17..ed75a833 100644 --- a/infrahub_sdk/protocols_generator/generator.py +++ b/infrahub_sdk/protocols_generator/generator.py @@ -122,7 +122,9 @@ def _jinja2_filter_render_relationship(self, value: RelationshipSchemaAPI, sync: cardinality = value.cardinality peer = value.peer - type_ = "RelatedNode" + # Cardinality-one relationships use a descriptor so they can be assigned an id string, + # an HFID, a peer node or ``None`` while still reading back as a typed ``RelatedNode``. + type_ = "RelationshipAttribute" if cardinality == "many": type_ = "RelationshipManager" diff --git a/infrahub_sdk/protocols_generator/template.j2 b/infrahub_sdk/protocols_generator/template.j2 index eeb2ef6c..43ef2407 100644 --- a/infrahub_sdk/protocols_generator/template.j2 +++ b/infrahub_sdk/protocols_generator/template.j2 @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Optional from infrahub_sdk.protocols import {{ "CoreNode" | syncify(sync) }}, {{ base_protocols | join(', ') }} if TYPE_CHECKING: - from infrahub_sdk.node import {{ "RelatedNode" | syncify(sync) }}, {{ "RelationshipManager" | syncify(sync) }} + from infrahub_sdk.node import {{ "RelatedNode" | syncify(sync) }}, {{ "RelationshipAttribute" | syncify(sync) }}, {{ "RelationshipManager" | syncify(sync) }} from infrahub_sdk.protocols_base import ( AnyAttribute, AnyAttributeOptional, @@ -52,7 +52,7 @@ class {{ generic.namespace + generic.name }}({{core_node_name}}): {{ relationship | render_relationship(sync) }} {% endfor %} {% if generic.hierarchical | default(false) %} - parent: {{ "RelatedNode" | syncify(sync) }}[{{ generic.namespace + generic.name }}] + parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ generic.namespace + generic.name }}] children: {{ "RelationshipManager" | syncify(sync) }}[{{ generic.namespace + generic.name }}] {% endif %} {% endfor %} @@ -71,7 +71,7 @@ class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | jo {{ relationship | render_relationship(sync) }} {% endfor %} {% if node.hierarchical | default(false) %} - parent: {{ "RelatedNode" | syncify(sync) }}[{{ node.namespace + node.name }}] + parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ node.namespace + node.name }}] children: {{ "RelationshipManager" | syncify(sync) }}[{{ node.namespace + node.name }}] {% endif %} @@ -91,7 +91,7 @@ class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | jo {{ relationship | render_relationship(sync) }} {% endfor %} {% if node.hierarchical | default(false) %} - parent: {{ "RelatedNode" | syncify(sync) }}[{{ node.namespace + node.name }}] + parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ node.namespace + node.name }}] children: {{ "RelationshipManager" | syncify(sync) }}[{{ node.namespace + node.name }}] {% endif %} diff --git a/tests/unit/sdk/test_protocols_generator.py b/tests/unit/sdk/test_protocols_generator.py index 5e6fbe51..f927c8a8 100644 --- a/tests/unit/sdk/test_protocols_generator.py +++ b/tests/unit/sdk/test_protocols_generator.py @@ -64,7 +64,7 @@ class LocationSite(LocationGeneric): shortname: String children: RelationshipManagerSync[LocationRack] member_of_groups: RelationshipManagerSync[CoreGroupSync] - parent: RelatedNodeSync[LocationCountry] + parent: RelationshipAttributeSync[LocationCountry] profiles: RelationshipManagerSync[CoreProfileSync] servers: RelationshipManagerSync[NetworkManagementServer] subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] From da2e3f39682b25d06f88c2b57066cc95286fe772 Mon Sep 17 00:00:00 2001 From: Mikhail Yohman Date: Mon, 8 Jun 2026 15:33:35 -0600 Subject: [PATCH 11/12] chore: regenerate SDK docs for generic RelatedNode peer types The develop merge combined upstream peer/get docstrings with our generic PeerT/PeerTSync return types, leaving the committed related_node.mdx stale. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx index 8f0ec245..f326e155 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx @@ -98,7 +98,7 @@ fetch(self, timeout: int | None = None) -> None #### `peer` ```python -peer(self) -> InfrahubNode +peer(self) -> PeerT ``` Return the peer node, or raise ValueError if no identifier is available. @@ -106,7 +106,7 @@ Return the peer node, or raise ValueError if no identifier is available. #### `get` ```python -get(self) -> InfrahubNode +get(self) -> PeerT ``` Return the peer node, performing a store lookup if not materialized. @@ -135,7 +135,7 @@ fetch(self, timeout: int | None = None) -> None #### `peer` ```python -peer(self) -> InfrahubNodeSync +peer(self) -> PeerTSync ``` Return the peer node, or raise ValueError if no identifier is available. @@ -143,7 +143,7 @@ Return the peer node, or raise ValueError if no identifier is available. #### `get` ```python -get(self) -> InfrahubNodeSync +get(self) -> PeerTSync ``` Return the peer node, performing a store lookup if not materialized. From c5887f0a9a4bc1b6df9723248eab0473d3ed8d2e Mon Sep 17 00:00:00 2001 From: Mikhail Yohman Date: Tue, 9 Jun 2026 11:27:18 -0600 Subject: [PATCH 12/12] docs: note protocols must be regenerated in changelog fragment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Highlight in the 1064 newsfragment that the new relationship-assignment typing lives in generated protocols, so existing projects must rerun `infrahubctl protocols` to pick it up — it won't work out of the box. Co-Authored-By: Claude Opus 4.8 (1M context) --- changelog/1064.fixed.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog/1064.fixed.md b/changelog/1064.fixed.md index d02b786b..5b730d23 100644 --- a/changelog/1064.fixed.md +++ b/changelog/1064.fixed.md @@ -1,3 +1,5 @@ Cardinality-one relationships in generated protocols are now typed with a `RelationshipAttribute[...]` descriptor. It still reads back as a typed `RelatedNode[Peer]` (so `.peer` keeps the peer type), but it accepts assignment of an id string, an HFID, a peer node, or `None` — mirroring the runtime `InfrahubNode.__setattr__`, which wraps the assigned value in a `RelatedNode`. -Previously relationships were typed read-only as `RelatedNode`, so the documented way of setting a relationship (`node.rel = ""` or `node.rel = peer_node`) failed under mypy/ty with an `assignment` error. The descriptor only appears in generated protocols and is never instantiated at runtime. ([#1064](https://github.com/opsmill/infrahub-sdk-python/issues/1064)) +Previously relationships were typed read-only as `RelatedNode`, so the documented way of setting a relationship (`node.rel = ""` or `node.rel = peer_node`) failed under mypy/ty with an `assignment` error. The descriptor only appears in generated protocols and is never instantiated at runtime. + +Because the new typing lives in the generated protocols, existing projects must regenerate them with `infrahubctl protocols` to pick up the change — it does not take effect for already-generated protocol files. ([#1064](https://github.com/opsmill/infrahub-sdk-python/issues/1064))