Skip to content
Draft
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
1 change: 1 addition & 0 deletions changelog/1152.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 46 additions & 0 deletions infrahub_sdk/node/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
83 changes: 83 additions & 0 deletions tests/unit/sdk/test_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,89 @@ 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_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
Expand Down
Loading