From 5e80d3975cfd42f8407738b6e6bc4bc69e7b864e Mon Sep 17 00:00:00 2001 From: Phillip Simonds Date: Thu, 9 Jul 2026 11:51:09 -0600 Subject: [PATCH 1/2] fix: support assigning to cardinality-many relationships Assigning to a cardinality-many relationship attribute (e.g. `node.members = [peer]`) previously fell through `__setattr__` to `super().__setattr__`, shadowing the RelationshipManager with a raw value. The node then failed in `save()` with a confusing `'InfrahubNode' object has no attribute 'initialized'` error. `__setattr__` now handles cardinality-many relationships: a list rebuilds the RelationshipManager (marked as updated so save() persists it), and a non-list value raises the existing "expects a list of nodes" error at assignment time. Applied to both InfrahubNode and InfrahubNodeSync. Fixes #1152 Co-Authored-By: Claude Opus 4.8 (1M context) --- changelog/1152.fixed.md | 1 + infrahub_sdk/node/node.py | 46 +++++++++++++++++++++++++++++++++++++ tests/unit/sdk/test_node.py | 36 +++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 changelog/1152.fixed.md diff --git a/changelog/1152.fixed.md b/changelog/1152.fixed.md new file mode 100644 index 000000000..39ec8da21 --- /dev/null +++ b/changelog/1152.fixed.md @@ -0,0 +1 @@ +Assigning to a cardinality-many relationship (e.g. `node.members = [peer]`) now populates the relationship manager, and assigning a non-list value raises a clear error at assignment time instead of being silently accepted and failing later in `save()` with a confusing `'InfrahubNode' object has no attribute 'initialized'` message. diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index 9bdaa20cc..713066b84 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -978,6 +978,29 @@ def __setattr__(self, name: str, value: Any) -> None: self._relationship_cardinality_one_data[name] = new_rel return + if "_relationship_cardinality_many_data" in self.__dict__ and name in self._relationship_cardinality_many_data: + rel_schemas = [rel_schema for rel_schema in self._schema.relationships if rel_schema.name == name] + if not rel_schemas: + raise SchemaNotFoundError( + identifier=self._schema.kind, + message=f"Unable to find relationship schema for '{name}' on {self._schema.kind}", + ) + rel_schema = rel_schemas[0] + # RelationshipManager validates that a cardinality-many value is a list and raises a + # helpful error otherwise, so assigning a single node fails here instead of silently + # corrupting the node and blowing up later in save(). + new_many_rel = RelationshipManager( + name=rel_schema.name, + client=self._client, + node=self, + branch=self._branch, + schema=rel_schema, + data=value, + ) + new_many_rel._has_update = True + self._relationship_cardinality_many_data[name] = new_many_rel + return + super().__setattr__(name, value) async def generate(self, nodes: list[str] | None = None) -> None: @@ -2169,6 +2192,29 @@ def __setattr__(self, name: str, value: Any) -> None: self._relationship_cardinality_one_data[name] = new_rel return + if "_relationship_cardinality_many_data" in self.__dict__ and name in self._relationship_cardinality_many_data: + rel_schemas = [rel_schema for rel_schema in self._schema.relationships if rel_schema.name == name] + if not rel_schemas: + raise SchemaNotFoundError( + identifier=self._schema.kind, + message=f"Unable to find relationship schema for '{name}' on {self._schema.kind}", + ) + rel_schema = rel_schemas[0] + # RelationshipManagerSync validates that a cardinality-many value is a list and raises a + # helpful error otherwise, so assigning a single node fails here instead of silently + # corrupting the node and blowing up later in save(). + new_many_rel = RelationshipManagerSync( + name=rel_schema.name, + client=self._client, + node=self, + branch=self._branch, + schema=rel_schema, + data=value, + ) + new_many_rel._has_update = True + self._relationship_cardinality_many_data[name] = new_many_rel + return + super().__setattr__(name, value) def generate(self, nodes: list[str] | None = None) -> None: diff --git a/tests/unit/sdk/test_node.py b/tests/unit/sdk/test_node.py index ab0d72ec0..4704a3b01 100644 --- a/tests/unit/sdk/test_node.py +++ b/tests/unit/sdk/test_node.py @@ -342,6 +342,42 @@ async def test_cardinality_many_accepts_list( assert len(node.tags.peers) == 2 +@pytest.mark.parametrize("client_type", client_types) +async def test_cardinality_many_assignment_requires_list( + client: InfrahubClient, location_schema: NodeSchemaAPI, client_type: str +) -> None: + """Assigning a single node to a cardinality-many relationship must fail at assignment time. + + Previously this was silently accepted and only blew up later in save() with a confusing + "'InfrahubNode' object has no attribute 'initialized'" error. + """ + if client_type == "standard": + node = InfrahubNode(client=client, schema=location_schema, data={"name": {"value": "JFK1"}}) + else: + node = InfrahubNodeSync(client=client, schema=location_schema, data={"name": {"value": "JFK1"}}) + + with pytest.raises(ValueError, match=r"expects a list of nodes"): + node.tags = {"id": "pppppppp"} + + +@pytest.mark.parametrize("client_type", client_types) +async def test_cardinality_many_assignment_accepts_list( + client: InfrahubClient, location_schema: NodeSchemaAPI, client_type: str +) -> None: + """Assigning a list to a cardinality-many relationship populates the manager and marks it updated.""" + if client_type == "standard": + node = InfrahubNode(client=client, schema=location_schema, data={"name": {"value": "JFK1"}}) + else: + node = InfrahubNodeSync(client=client, schema=location_schema, data={"name": {"value": "JFK1"}}) + + node.tags = [{"id": "aaaaaa"}, {"id": "bbbb"}] + + assert isinstance(node.tags, RelationshipManagerBase) + assert node.tags.peer_ids == ["aaaaaa", "bbbb"] + # has_update must be set so save() actually persists the assignment instead of stripping it. + assert node.tags.has_update is True + + @pytest.mark.parametrize("client_type", client_types) async def test_query_data_no_filters_property( clients: BothClients, location_schema: NodeSchemaAPI, client_type: str From 9beed45ca43d7adeff03f4c5e363c3c5bf3be83a Mon Sep 17 00:00:00 2001 From: Phillip Simonds Date: Thu, 9 Jul 2026 22:14:34 -0600 Subject: [PATCH 2/2] test: cover save() path for cardinality-many relationship assignment Add coverage that a list assigned to a many-relationship after construction is carried through the save() payload: - test_cardinality_many_assignment_included_in_input_data asserts the peers are present in _generate_input_data() (the build path that previously crashed, and a guard that _strip_unmodified keeps the assignment via has_update). - test_cardinality_many_assignment_saved calls save() with a mocked mutation and asserts the node is persisted and the peers are sent in the request. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/sdk/test_node.py | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/unit/sdk/test_node.py b/tests/unit/sdk/test_node.py index 4704a3b01..8a4954ae5 100644 --- a/tests/unit/sdk/test_node.py +++ b/tests/unit/sdk/test_node.py @@ -378,6 +378,53 @@ async def test_cardinality_many_assignment_accepts_list( assert node.tags.has_update is True +@pytest.mark.parametrize("client_type", client_types) +async def test_cardinality_many_assignment_included_in_input_data( + client: InfrahubClient, location_schema: NodeSchemaAPI, client_type: str +) -> None: + """A list assigned after construction is carried into the mutation payload built by save(). + + This is the exact path that used to raise 'InfrahubNode' object has no attribute 'initialized', + and it also guards that _strip_unmodified keeps the assignment (thanks to has_update). + """ + data = {"name": {"value": "JFK1"}, "type": {"value": "SITE"}} + if client_type == "standard": + node = InfrahubNode(client=client, schema=location_schema, data=data) + else: + node = InfrahubNodeSync(client=client, schema=location_schema, data=data) + + node.tags = [{"id": "aaaaaa"}, {"id": "bbbb"}] + + input_data = node._generate_input_data()["data"]["data"] + assert input_data["tags"] == [{"id": "aaaaaa"}, {"id": "bbbb"}] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_cardinality_many_assignment_saved( + httpx_mock: HTTPXMock, clients: BothClients, location_schema: NodeSchemaAPI, client_type: str +) -> None: + """Assigning a list then calling save() succeeds and sends the peers in the create mutation.""" + httpx_mock.add_response( + method="POST", + json={"data": {"BuiltinLocationCreate": {"ok": True, "object": {"id": "location-123"}}}}, + ) + data = {"name": {"value": "JFK1"}, "type": {"value": "SITE"}} + client = getattr(clients, client_type) + if client_type == "standard": + node = InfrahubNode(client=client, schema=location_schema, data=data) + node.tags = [{"id": "aaaaaa"}, {"id": "bbbb"}] + await node.save() + else: + node = InfrahubNodeSync(client=client, schema=location_schema, data=data) + node.tags = [{"id": "aaaaaa"}, {"id": "bbbb"}] + node.save() + + assert node.id == "location-123" + body = httpx_mock.get_requests()[-1].content.decode() + assert "aaaaaa" in body + assert "bbbb" in body + + @pytest.mark.parametrize("client_type", client_types) async def test_query_data_no_filters_property( clients: BothClients, location_schema: NodeSchemaAPI, client_type: str