From 4b53d82e6bf741cfc7448691a0139e7a3422e99c Mon Sep 17 00:00:00 2001 From: rootsec1 Date: Mon, 31 Aug 2026 05:24:41 +0000 Subject: [PATCH 1/5] fix: make many relationship YAML loadable --- changelog/1274.fixed.md | 1 + infrahub_sdk/ctl/formatters/yaml.py | 2 +- tests/unit/ctl/formatters/test_yaml.py | 47 ++++++++++++++++++++++++-- 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 changelog/1274.fixed.md diff --git a/changelog/1274.fixed.md b/changelog/1274.fixed.md new file mode 100644 index 000000000..669d4ac5d --- /dev/null +++ b/changelog/1274.fixed.md @@ -0,0 +1 @@ +Fixed YAML output for populated cardinality-many relationships so it can be loaded by `infrahubctl object load`. diff --git a/infrahub_sdk/ctl/formatters/yaml.py b/infrahub_sdk/ctl/formatters/yaml.py index e75c0b6dd..728fdc81a 100644 --- a/infrahub_sdk/ctl/formatters/yaml.py +++ b/infrahub_sdk/ctl/formatters/yaml.py @@ -100,7 +100,7 @@ def _node_to_data_entry( peers = getattr(rel, "peers", None) or [] refs = [r for p in peers if (r := _related_node_ref(p)) is not None] if refs: - entry[rel_name] = {"data": refs} + entry[rel_name] = refs return entry diff --git a/tests/unit/ctl/formatters/test_yaml.py b/tests/unit/ctl/formatters/test_yaml.py index e54e8642f..82a246b14 100644 --- a/tests/unit/ctl/formatters/test_yaml.py +++ b/tests/unit/ctl/formatters/test_yaml.py @@ -2,11 +2,13 @@ from __future__ import annotations -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import yaml # pyright: ignore[reportMissingModuleSource] from infrahub_sdk.ctl.formatters.yaml import YamlFormatter +from infrahub_sdk.schema import RelationshipCardinality, RelationshipSchemaAPI +from infrahub_sdk.spec.object import InfrahubObjectFileData, RelationshipDataFormat, get_relationship_info def _make_mock_schema( @@ -327,7 +329,48 @@ def test_rel_cardinality_many_with_peers_uses_hfid(self) -> None: result = formatter.format_detail(node, schema) parsed = yaml.safe_load(result) - assert parsed["spec"]["data"][0]["tags"] == {"data": ["tag1", "tag2"]} + assert parsed["spec"]["data"][0]["tags"] == ["tag1", "tag2"] + + async def test_rel_cardinality_many_output_is_loadable_reference(self) -> None: + """Cardinality-many YAML output is accepted by the object loader as HFID references.""" + schema = MagicMock() + schema.kind = "TestKind" + schema.attribute_names = [] + schema.relationship_names = ["tags"] + rel_schema = RelationshipSchemaAPI( + name="tags", + peer="BuiltinTag", + cardinality=RelationshipCardinality.MANY, + ) + schema.get_relationship.return_value = rel_schema + + peer1 = MagicMock(display_label="tag1", hfid=["tag1"]) + peer2 = MagicMock(display_label="tag2", hfid=["tag2"]) + node = MagicMock() + node.tags.peers = [peer1, peer2] + + parsed = yaml.safe_load(YamlFormatter().format_detail(node, schema)) + relationship_value = parsed["spec"]["data"][0]["tags"] + + peer_schema = MagicMock(human_friendly_id=["name"]) + client = MagicMock() + client.schema.get = AsyncMock(return_value=peer_schema) + rel_info = await get_relationship_info( + client=client, + schema=schema, + name="tags", + value=relationship_value, + ) + errors = await InfrahubObjectFileData.validate_related_nodes( + client=client, + position=[1, "tags"], + rel_info=rel_info, + data=relationship_value, + ) + + assert rel_info.format == RelationshipDataFormat.MANY_REF + assert rel_info.is_reference + assert errors == [] def test_rel_multi_component_hfid(self) -> None: """Multi-component HFID renders as a list.""" From 8fab991166c9fa84ec4e7395025225a90d474ac7 Mon Sep 17 00:00:00 2001 From: rootsec1 Date: Mon, 31 Aug 2026 15:09:20 +0000 Subject: [PATCH 2/5] test: cover YAML many relationship CLI round trip --- tests/integration/test_enduser_cli.py | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/integration/test_enduser_cli.py b/tests/integration/test_enduser_cli.py index 1f9a0164e..42eeacb79 100644 --- a/tests/integration/test_enduser_cli.py +++ b/tests/integration/test_enduser_cli.py @@ -7,9 +7,11 @@ from __future__ import annotations +import asyncio import json import os from typing import TYPE_CHECKING +from uuid import uuid4 import pytest import yaml @@ -23,6 +25,8 @@ if TYPE_CHECKING: from collections.abc import Generator + from pathlib import Path + from typing import Any from infrahub_sdk import InfrahubClient from infrahub_sdk.node import InfrahubNode @@ -208,6 +212,46 @@ def test_create_missing_args(self, base_dataset: None) -> None: result = runner.invoke(app, ["object", "create", "TestingPerson"]) assert result.exit_code != 0 + async def test_get_yaml_round_trips_attribute_many_relationship( + self, + base_dataset: None, + client: InfrahubClient, + schema_extension_01: dict[str, Any], + tmp_path: Path, + ) -> None: + """Round-trip cardinality-many attribute relationships through the CLI.""" + response = await client.schema.load(schemas=[schema_extension_01], wait_until_converged=True) + assert not response.errors + + suffix = uuid4().hex + tag_names = [f"yaml-round-trip-{suffix}-one", f"yaml-round-trip-{suffix}-two"] + tags = [] + for tag_name in tag_names: + tag = await client.create(kind="BuiltinTag", name=tag_name) + await tag.save() + tags.append(tag) + + rack_name = f"yaml-round-trip-rack-{suffix}" + rack = await client.create(kind="InfraRack", name=rack_name, tags=tags) + await rack.save() + + get_result = await asyncio.to_thread( + runner.invoke, + app, + ["object", "get", "InfraRack", "--filter", f"name__value={rack_name}", "--output", "yaml"], + ) + assert get_result.exit_code == 0, f"object get failed: {get_result.output}" + + object_file = tmp_path / "infra-rack.yaml" + object_file.write_text(get_result.stdout, encoding="utf-8") + load_result = await asyncio.to_thread(runner.invoke, app, ["object", "load", str(object_file)]) + assert load_result.exit_code == 0, f"object load failed: {load_result.output}" + + fetched_rack = await client.get(kind="InfraRack", id=rack.id) + fetched_tags = fetched_rack._get_relationship_many(name="tags") + await fetched_tags.fetch() + assert sorted(peer.hfid or [] for peer in fetched_tags.peers) == sorted([[name] for name in tag_names]) + def test_update_inline(self, base_dataset: None) -> None: """Update a person's height using --set.""" result = runner.invoke( From 49feedc1d7dac00b027252085513f464ae4b5619 Mon Sep 17 00:00:00 2001 From: rootsec1 Date: Mon, 31 Aug 2026 15:12:33 +0000 Subject: [PATCH 3/5] test: use real client for YAML round trip --- tests/unit/ctl/formatters/test_yaml.py | 34 +++++++++++--------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/tests/unit/ctl/formatters/test_yaml.py b/tests/unit/ctl/formatters/test_yaml.py index 82a246b14..b074652ec 100644 --- a/tests/unit/ctl/formatters/test_yaml.py +++ b/tests/unit/ctl/formatters/test_yaml.py @@ -2,13 +2,16 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock +import json +from unittest.mock import MagicMock import yaml # pyright: ignore[reportMissingModuleSource] +from infrahub_sdk import InfrahubClient +from infrahub_sdk.config import Config from infrahub_sdk.ctl.formatters.yaml import YamlFormatter -from infrahub_sdk.schema import RelationshipCardinality, RelationshipSchemaAPI from infrahub_sdk.spec.object import InfrahubObjectFileData, RelationshipDataFormat, get_relationship_info +from tests.helpers.fixtures import read_fixture def _make_mock_schema( @@ -333,28 +336,18 @@ def test_rel_cardinality_many_with_peers_uses_hfid(self) -> None: async def test_rel_cardinality_many_output_is_loadable_reference(self) -> None: """Cardinality-many YAML output is accepted by the object loader as HFID references.""" - schema = MagicMock() - schema.kind = "TestKind" - schema.attribute_names = [] - schema.relationship_names = ["tags"] - rel_schema = RelationshipSchemaAPI( - name="tags", - peer="BuiltinTag", - cardinality=RelationshipCardinality.MANY, + client = InfrahubClient(config=Config(address="http://mock")) + client.schema.set_cache(json.loads(read_fixture("schema_01.json")), branch="main") + schema = await client.schema.get(kind="CoreGraphQLQuery", branch="main") + + peer1 = await client.create(kind="BuiltinTag", name="tag1") + peer2 = await client.create(kind="BuiltinTag", name="tag2") + node = await client.create( + kind="CoreGraphQLQuery", name="query1", query="query Test { ok }", tags=[peer1, peer2] ) - schema.get_relationship.return_value = rel_schema - - peer1 = MagicMock(display_label="tag1", hfid=["tag1"]) - peer2 = MagicMock(display_label="tag2", hfid=["tag2"]) - node = MagicMock() - node.tags.peers = [peer1, peer2] parsed = yaml.safe_load(YamlFormatter().format_detail(node, schema)) relationship_value = parsed["spec"]["data"][0]["tags"] - - peer_schema = MagicMock(human_friendly_id=["name"]) - client = MagicMock() - client.schema.get = AsyncMock(return_value=peer_schema) rel_info = await get_relationship_info( client=client, schema=schema, @@ -368,6 +361,7 @@ async def test_rel_cardinality_many_output_is_loadable_reference(self) -> None: data=relationship_value, ) + assert relationship_value == ["tag1", "tag2"] assert rel_info.format == RelationshipDataFormat.MANY_REF assert rel_info.is_reference assert errors == [] From 6f3873350983b619a5d98bad1166373cc97773f1 Mon Sep 17 00:00:00 2001 From: rootsec1 Date: Mon, 31 Aug 2026 16:01:45 +0000 Subject: [PATCH 4/5] test: clean up YAML round trip resources --- tests/integration/test_enduser_cli.py | 70 +++++++++++++++------------ 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/tests/integration/test_enduser_cli.py b/tests/integration/test_enduser_cli.py index 42eeacb79..6243c91f4 100644 --- a/tests/integration/test_enduser_cli.py +++ b/tests/integration/test_enduser_cli.py @@ -220,37 +220,45 @@ async def test_get_yaml_round_trips_attribute_many_relationship( tmp_path: Path, ) -> None: """Round-trip cardinality-many attribute relationships through the CLI.""" - response = await client.schema.load(schemas=[schema_extension_01], wait_until_converged=True) - assert not response.errors - - suffix = uuid4().hex - tag_names = [f"yaml-round-trip-{suffix}-one", f"yaml-round-trip-{suffix}-two"] - tags = [] - for tag_name in tag_names: - tag = await client.create(kind="BuiltinTag", name=tag_name) - await tag.save() - tags.append(tag) - - rack_name = f"yaml-round-trip-rack-{suffix}" - rack = await client.create(kind="InfraRack", name=rack_name, tags=tags) - await rack.save() - - get_result = await asyncio.to_thread( - runner.invoke, - app, - ["object", "get", "InfraRack", "--filter", f"name__value={rack_name}", "--output", "yaml"], - ) - assert get_result.exit_code == 0, f"object get failed: {get_result.output}" - - object_file = tmp_path / "infra-rack.yaml" - object_file.write_text(get_result.stdout, encoding="utf-8") - load_result = await asyncio.to_thread(runner.invoke, app, ["object", "load", str(object_file)]) - assert load_result.exit_code == 0, f"object load failed: {load_result.output}" - - fetched_rack = await client.get(kind="InfraRack", id=rack.id) - fetched_tags = fetched_rack._get_relationship_many(name="tags") - await fetched_tags.fetch() - assert sorted(peer.hfid or [] for peer in fetched_tags.peers) == sorted([[name] for name in tag_names]) + tags: list[InfrahubNode] = [] + rack: InfrahubNode | None = None + try: + response = await client.schema.load(schemas=[schema_extension_01], wait_until_converged=True) + assert not response.errors + + suffix = uuid4().hex + tag_names = [f"yaml-round-trip-{suffix}-one", f"yaml-round-trip-{suffix}-two"] + for tag_name in tag_names: + tag = await client.create(kind="BuiltinTag", name=tag_name) + await tag.save() + tags.append(tag) + + rack_name = f"yaml-round-trip-rack-{suffix}" + created_rack = await client.create(kind="InfraRack", name=rack_name, tags=tags) + await created_rack.save() + rack = created_rack + + get_result = await asyncio.to_thread( + runner.invoke, + app, + ["object", "get", "InfraRack", "--filter", f"name__value={rack_name}", "--output", "yaml"], + ) + assert get_result.exit_code == 0, f"object get failed: {get_result.output}" + + object_file = tmp_path / "infra-rack.yaml" + object_file.write_text(get_result.stdout, encoding="utf-8") + load_result = await asyncio.to_thread(runner.invoke, app, ["object", "load", str(object_file)]) + assert load_result.exit_code == 0, f"object load failed: {load_result.output}" + + fetched_rack = await client.get(kind="InfraRack", id=rack.id) + fetched_tags = fetched_rack._get_relationship_many(name="tags") + await fetched_tags.fetch() + assert sorted(peer.hfid or [] for peer in fetched_tags.peers) == sorted([[name] for name in tag_names]) + finally: + if rack is not None: + await rack.delete() + for tag in reversed(tags): + await tag.delete() def test_update_inline(self, base_dataset: None) -> None: """Update a person's height using --set.""" From 8dc0961b0cc707c640759e6760eb95a744b708ea Mon Sep 17 00:00:00 2001 From: rootsec1 Date: Mon, 31 Aug 2026 16:12:34 +0000 Subject: [PATCH 5/5] test: preserve failures during resource cleanup --- tests/integration/test_enduser_cli.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_enduser_cli.py b/tests/integration/test_enduser_cli.py index 6243c91f4..fc77ba716 100644 --- a/tests/integration/test_enduser_cli.py +++ b/tests/integration/test_enduser_cli.py @@ -222,6 +222,7 @@ async def test_get_yaml_round_trips_attribute_many_relationship( """Round-trip cardinality-many attribute relationships through the CLI.""" tags: list[InfrahubNode] = [] rack: InfrahubNode | None = None + body_succeeded = False try: response = await client.schema.load(schemas=[schema_extension_01], wait_until_converged=True) assert not response.errors @@ -254,11 +255,21 @@ async def test_get_yaml_round_trips_attribute_many_relationship( fetched_tags = fetched_rack._get_relationship_many(name="tags") await fetched_tags.fetch() assert sorted(peer.hfid or [] for peer in fetched_tags.peers) == sorted([[name] for name in tag_names]) + body_succeeded = True finally: + cleanup_errors: list[Exception] = [] if rack is not None: - await rack.delete() + try: + await rack.delete() + except Exception as exc: + cleanup_errors.append(exc) for tag in reversed(tags): - await tag.delete() + try: + await tag.delete() + except Exception as exc: + cleanup_errors.append(exc) + if body_succeeded and cleanup_errors: + raise cleanup_errors[0] def test_update_inline(self, base_dataset: None) -> None: """Update a person's height using --set."""