Skip to content

Commit 78d99cd

Browse files
FragmentedPacketsolababsajtmccartyMikhail Yohmanclaude
authored
Type cardinality-one relationships as an assignable descriptor (#1064) (#1067)
* 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) <noreply@anthropic.com> * 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 = "<id>" / node.rel = peer failed type checking. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Sola Infrahub User <sola@opsmill.com> Co-authored-by: Aaron McCarty <aaron@opsmill.com> Co-authored-by: Sola Babatunde <solababatunde12@gmail.com> Co-authored-by: Mikhail Yohman <mikhail@opsmill.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6a6467e commit 78d99cd

7 files changed

Lines changed: 80 additions & 8 deletions

File tree

changelog/1064.fixed.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
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`.
2+
3+
Previously relationships were typed read-only as `RelatedNode`, so the documented way of setting a relationship (`node.rel = "<id>"` 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.
4+
5+
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))

docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,18 @@ when this RelatedNode's .id is None — that is the case in which
156156

157157
- `ValueError`: when neither a peer, (_id,_typename), nor hfid_str
158158
is available.
159+
160+
### `RelationshipAttribute`
161+
162+
Typing descriptor for a cardinality-one relationship on a generated protocol.
163+
164+
It reads back as ``RelatedNode[PeerT]`` (so ``.peer`` keeps the peer type) but accepts
165+
assignment of an id string, an HFID, a peer node, or ``None`` — mirroring the runtime
166+
``InfrahubNode.__setattr__`` behaviour, which wraps the assigned value in a ``RelatedNode``.
167+
168+
This type only appears in generated protocols (it is never instantiated at runtime), so it
169+
exists purely to give ``node.rel`` separate read and assignment types under a type checker.
170+
171+
### `RelationshipAttributeSync`
172+
173+
Synchronous counterpart of :class:`RelationshipAttribute`.

infrahub_sdk/node/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@
1616
from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync, UploadResult
1717
from .parsers import parse_human_friendly_id
1818
from .property import NodeProperty
19-
from .related_node import RelatedNode, RelatedNodeBase, RelatedNodeSync
19+
from .related_node import (
20+
RelatedNode,
21+
RelatedNodeBase,
22+
RelatedNodeSync,
23+
RelationshipAttribute,
24+
RelationshipAttributeSync,
25+
)
2026
from .relationship import RelationshipManager, RelationshipManagerBase, RelationshipManagerSync
2127

2228
__all__ = [
@@ -38,6 +44,8 @@
3844
"RelatedNode",
3945
"RelatedNodeBase",
4046
"RelatedNodeSync",
47+
"RelationshipAttribute",
48+
"RelationshipAttributeSync",
4149
"RelationshipManager",
4250
"RelationshipManagerBase",
4351
"RelationshipManagerSync",

infrahub_sdk/node/related_node.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import re
4-
from typing import TYPE_CHECKING, Any, Generic, cast
4+
from typing import TYPE_CHECKING, Any, Generic, cast, overload
55

66
from typing_extensions import TypeVar
77

@@ -350,3 +350,45 @@ def get(self) -> PeerTSync:
350350
return cast("PeerTSync", self._client.store.get(key=self.hfid_str, branch=self._branch))
351351

352352
raise ValueError("Node must have at least one identifier (ID or HFID) to query it.")
353+
354+
355+
class RelationshipAttribute(Generic[PeerT]):
356+
"""Typing descriptor for a cardinality-one relationship on a generated protocol.
357+
358+
It reads back as ``RelatedNode[PeerT]`` (so ``.peer`` keeps the peer type) but accepts
359+
assignment of an id string, an HFID, a peer node, or ``None`` — mirroring the runtime
360+
``InfrahubNode.__setattr__`` behaviour, which wraps the assigned value in a ``RelatedNode``.
361+
362+
This type only appears in generated protocols (it is never instantiated at runtime), so it
363+
exists purely to give ``node.rel`` separate read and assignment types under a type checker.
364+
"""
365+
366+
@overload
367+
def __get__(self, instance: None, owner: Any = None) -> RelationshipAttribute[PeerT]: ...
368+
369+
@overload
370+
def __get__(self, instance: object, owner: Any = None) -> RelatedNode[PeerT]: ...
371+
372+
def __get__(self, instance: object | None, owner: Any = None) -> RelationshipAttribute[PeerT] | RelatedNode[PeerT]:
373+
raise NotImplementedError # typing-only descriptor; never invoked at runtime
374+
375+
def __set__(self, instance: object, value: str | list[str] | PeerT | None) -> None:
376+
raise NotImplementedError # typing-only descriptor; never invoked at runtime
377+
378+
379+
class RelationshipAttributeSync(Generic[PeerTSync]):
380+
"""Synchronous counterpart of :class:`RelationshipAttribute`."""
381+
382+
@overload
383+
def __get__(self, instance: None, owner: Any = None) -> RelationshipAttributeSync[PeerTSync]: ...
384+
385+
@overload
386+
def __get__(self, instance: object, owner: Any = None) -> RelatedNodeSync[PeerTSync]: ...
387+
388+
def __get__(
389+
self, instance: object | None, owner: Any = None
390+
) -> RelationshipAttributeSync[PeerTSync] | RelatedNodeSync[PeerTSync]:
391+
raise NotImplementedError # typing-only descriptor; never invoked at runtime
392+
393+
def __set__(self, instance: object, value: str | list[str] | PeerTSync | None) -> None:
394+
raise NotImplementedError # typing-only descriptor; never invoked at runtime

infrahub_sdk/protocols_generator/generator.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,9 @@ def _jinja2_filter_render_relationship(self, value: RelationshipSchemaAPI, sync:
128128
cardinality = value.cardinality
129129
peer = value.peer
130130

131-
type_ = "RelatedNode"
131+
# Cardinality-one relationships use a descriptor so they can be assigned an id string,
132+
# an HFID, a peer node or ``None`` while still reading back as a typed ``RelatedNode``.
133+
type_ = "RelationshipAttribute"
132134
if cardinality == RelationshipCardinality.MANY:
133135
type_ = "RelationshipManager"
134136

infrahub_sdk/protocols_generator/template.j2

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Optional
99
from infrahub_sdk.protocols import {{ "CoreNode" | syncify(sync) }}, {{ base_protocols | join(', ') }}
1010

1111
if TYPE_CHECKING:
12-
from infrahub_sdk.node import {{ "RelatedNode" | syncify(sync) }}, {{ "RelationshipManager" | syncify(sync) }}
12+
from infrahub_sdk.node import {{ "RelatedNode" | syncify(sync) }}, {{ "RelationshipAttribute" | syncify(sync) }}, {{ "RelationshipManager" | syncify(sync) }}
1313
from infrahub_sdk.protocols_base import (
1414
AnyAttribute,
1515
AnyAttributeOptional,
@@ -52,7 +52,7 @@ class {{ generic.namespace + generic.name }}({{core_node_name}}):
5252
{{ relationship | render_relationship(sync) }}
5353
{% endfor %}
5454
{% if generic.hierarchical | default(false) %}
55-
parent: {{ "RelatedNode" | syncify(sync) }}[{{ generic.namespace + generic.name }}]
55+
parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ generic.namespace + generic.name }}]
5656
children: {{ "RelationshipManager" | syncify(sync) }}[{{ generic.namespace + generic.name }}]
5757
{% endif %}
5858
{% endfor %}
@@ -71,7 +71,7 @@ class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | jo
7171
{{ relationship | render_relationship(sync) }}
7272
{% endfor %}
7373
{% if node.hierarchical | default(false) %}
74-
parent: {{ "RelatedNode" | syncify(sync) }}[{{ node.namespace + node.name }}]
74+
parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ node.namespace + node.name }}]
7575
children: {{ "RelationshipManager" | syncify(sync) }}[{{ node.namespace + node.name }}]
7676
{% endif %}
7777

@@ -91,7 +91,7 @@ class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | jo
9191
{{ relationship | render_relationship(sync) }}
9292
{% endfor %}
9393
{% if node.hierarchical | default(false) %}
94-
parent: {{ "RelatedNode" | syncify(sync) }}[{{ node.namespace + node.name }}]
94+
parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ node.namespace + node.name }}]
9595
children: {{ "RelationshipManager" | syncify(sync) }}[{{ node.namespace + node.name }}]
9696
{% endif %}
9797

tests/unit/sdk/test_protocols_generator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ class LocationSite(LocationGeneric):
115115
shortname: String
116116
children: RelationshipManagerSync[LocationRack]
117117
member_of_groups: RelationshipManagerSync[CoreGroupSync]
118-
parent: RelatedNodeSync[LocationCountry]
118+
parent: RelationshipAttributeSync[LocationCountry]
119119
profiles: RelationshipManagerSync[CoreProfileSync]
120120
servers: RelationshipManagerSync[NetworkManagementServer]
121121
subscriber_of_groups: RelationshipManagerSync[CoreGroupSync]

0 commit comments

Comments
 (0)