Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog/1064.fixed.md
Original file line number Diff line number Diff line change
@@ -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 = "<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.

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))
15 changes: 15 additions & 0 deletions docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
10 changes: 9 additions & 1 deletion infrahub_sdk/node/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand All @@ -38,6 +44,8 @@
"RelatedNode",
"RelatedNodeBase",
"RelatedNodeSync",
"RelationshipAttribute",
"RelationshipAttributeSync",
"RelationshipManager",
"RelationshipManagerBase",
"RelationshipManagerSync",
Expand Down
44 changes: 43 additions & 1 deletion infrahub_sdk/node/related_node.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
4 changes: 3 additions & 1 deletion infrahub_sdk/protocols_generator/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
8 changes: 4 additions & 4 deletions infrahub_sdk/protocols_generator/template.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 %}
Expand All @@ -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 %}

Expand All @@ -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 %}

Expand Down
2 changes: 1 addition & 1 deletion tests/unit/sdk/test_protocols_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
6 changes: 3 additions & 3 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading