From 4111d1a118477e1706ec957da98d4a372cdc4631 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:11:39 -0700 Subject: [PATCH 1/2] chore(deps-dev): bump infrahub-testcontainers from 1.9.6 to 1.9.7 (#1068) Bumps [infrahub-testcontainers](https://github.com/opsmill/infrahub) from 1.9.6 to 1.9.7. - [Release notes](https://github.com/opsmill/infrahub/releases) - [Changelog](https://github.com/opsmill/infrahub/blob/stable/CHANGELOG.md) - [Commits](https://github.com/opsmill/infrahub/compare/infrahub-v1.9.6...infrahub-v1.9.7) --- updated-dependencies: - dependency-name: infrahub-testcontainers dependency-version: 1.9.7 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 70142375..ce82abdd 100644 --- a/uv.lock +++ b/uv.lock @@ -894,7 +894,7 @@ types = [ [[package]] name = "infrahub-testcontainers" -version = "1.9.6" +version = "1.9.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -904,9 +904,9 @@ dependencies = [ { name = "pytest" }, { name = "testcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/25/0e56af611410a79ab242dea99a1892af8840524c58870b3050e59a4f99b1/infrahub_testcontainers-1.9.6.tar.gz", hash = "sha256:c75015a166d05aaa00804bf5be42f041e10745a7fa4c6ac13713febc60ae617f", size = 17365, upload-time = "2026-05-20T19:42:41.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/02/467481cbc12e0c841b155426e52567b443ab02928566dc0975c27f69f145/infrahub_testcontainers-1.9.7.tar.gz", hash = "sha256:87fbbaf64682ff07937543b2ef2335f8e250a8ecbd5420b525a629462a583b37", size = 17365, upload-time = "2026-06-03T16:07:03.507Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/25/284135f880d60d1c912a269c88a048a420a691b5b337d05027d269803d48/infrahub_testcontainers-1.9.6-py3-none-any.whl", hash = "sha256:6a2d185513b56071e0235ec71d8ac33be1e29dc2c6815b1ec5ce1d9994cc4e74", size = 23197, upload-time = "2026-05-20T19:42:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8b/3d60e3ad79ca9ec492c865f9464d0416ec7a008550df28fe0ced1108ef0c/infrahub_testcontainers-1.9.7-py3-none-any.whl", hash = "sha256:af29200fc4aedcbd432f53692573e3d8bac381642d89c9a56986bc6d6586e45b", size = 23199, upload-time = "2026-06-03T16:07:04.268Z" }, ] [[package]] From 78d99cdca21bb2f50cd1f3aaad999c651dc34f75 Mon Sep 17 00:00:00 2001 From: Mikhail Yohman Date: Tue, 9 Jun 2026 14:52:50 -0600 Subject: [PATCH 2/2] Type cardinality-one relationships as an assignable descriptor (#1064) (#1067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * IHS-183: Fix typing errors for protocols * fix ty * fix failing tests * address comments * use CoreNodeBase in get_schema_name * remove breakpoint * add test for store get_schema_name * update test * 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) * Type cardinality-one relationships as an assignable descriptor (#1064) 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) * 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) * docs: note protocols must be regenerated in changelog fragment 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) --------- Co-authored-by: Sola Infrahub User Co-authored-by: Aaron McCarty Co-authored-by: Sola Babatunde Co-authored-by: Mikhail Yohman Co-authored-by: Claude Opus 4.8 (1M context) --- changelog/1064.fixed.md | 5 +++ .../infrahub_sdk/node/related_node.mdx | 15 +++++++ 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 +- 7 files changed, 80 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..5b730d23 --- /dev/null +++ b/changelog/1064.fixed.md @@ -0,0 +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. + +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)) 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 f326e155..f46811fb 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 @@ -156,3 +156,18 @@ when this RelatedNode's .id is None — that is the case in which - `ValueError`: when neither a peer, (_id,_typename), nor hfid_str is available. + +### `RelationshipAttribute` + +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. + +### `RelationshipAttributeSync` + +Synchronous counterpart of :class:`RelationshipAttribute`. diff --git a/infrahub_sdk/node/__init__.py b/infrahub_sdk/node/__init__.py index 136100e8..6bbde854 100644 --- a/infrahub_sdk/node/__init__.py +++ b/infrahub_sdk/node/__init__.py @@ -16,7 +16,13 @@ from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync, UploadResult 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__ = [ @@ -38,6 +44,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 372e553f..9350f3c0 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 @@ -350,3 +350,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 d26e9313..e0a4c61c 100644 --- a/infrahub_sdk/protocols_generator/generator.py +++ b/infrahub_sdk/protocols_generator/generator.py @@ -128,7 +128,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 == RelationshipCardinality.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 81d75fb2..531556bc 100644 --- a/tests/unit/sdk/test_protocols_generator.py +++ b/tests/unit/sdk/test_protocols_generator.py @@ -115,7 +115,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]