Skip to content

Commit 0cec7ec

Browse files
Fix code for HFID casting of strings that aren't UUIDs (#732)
* Fix code for HFID casting of strings that aren't UUIDs so user doesn't have to provide HFID directly if single value HFID. * Fix linting issues. * Fix card one from being double wrapped list. * Update to not double wrap a list for a card one rel. * Fixed typing error. * Sessived code comments. Fixturized testing.
1 parent 646e075 commit 0cec7ec

2 files changed

Lines changed: 140 additions & 5 deletions

File tree

infrahub_sdk/spec/object.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from ..exceptions import ObjectValidationError, ValidationError
99
from ..schema import GenericSchemaAPI, RelationshipKind, RelationshipSchema
10+
from ..utils import is_valid_uuid
1011
from ..yaml import InfrahubFile, InfrahubFileKind
1112
from .models import InfrahubObjectParameters
1213
from .processors.factory import DataProcessorFactory
@@ -33,6 +34,32 @@ def validate_list_of_objects(value: list[Any]) -> bool:
3334
return all(isinstance(item, dict) for item in value)
3435

3536

37+
def normalize_hfid_reference(value: str | list[str]) -> str | list[str]:
38+
"""Normalize a reference value to HFID format.
39+
40+
Args:
41+
value: Either a string (ID or single-component HFID) or a list of strings (multi-component HFID).
42+
43+
Returns:
44+
- If value is already a list: returns it unchanged as list[str]
45+
- If value is a valid UUID string: returns it unchanged as str (will be treated as an ID)
46+
- If value is a non-UUID string: wraps it in a list as list[str] (single-component HFID)
47+
"""
48+
if isinstance(value, list):
49+
return value
50+
if is_valid_uuid(value):
51+
return value
52+
return [value]
53+
54+
55+
def normalize_hfid_references(values: list[str | list[str]]) -> list[str | list[str]]:
56+
"""Normalize a list of reference values to HFID format.
57+
58+
Each string that is not a valid UUID will be wrapped in a list to treat it as a single-component HFID.
59+
"""
60+
return [normalize_hfid_reference(v) for v in values]
61+
62+
3663
class RelationshipDataFormat(str, Enum):
3764
UNKNOWN = "unknown"
3865

@@ -444,10 +471,13 @@ async def create_node(
444471
# - if the relationship is bidirectional and is mandatory on the other side, then we need to create this object First
445472
# - if the relationship is bidirectional and is not mandatory on the other side, then we need should create the related object First
446473
# - if the relationship is not bidirectional, then we need to create the related object First
447-
if rel_info.is_reference and isinstance(value, list):
448-
clean_data[key] = value
449-
elif rel_info.format == RelationshipDataFormat.ONE_REF and isinstance(value, str):
450-
clean_data[key] = [value]
474+
if rel_info.format == RelationshipDataFormat.MANY_REF and isinstance(value, list):
475+
# Cardinality-many: normalize each string HFID to list format: "name" -> ["name"]
476+
# UUIDs are left as-is since they are treated as IDs
477+
clean_data[key] = normalize_hfid_references(value)
478+
elif rel_info.format == RelationshipDataFormat.ONE_REF:
479+
# Cardinality-one: normalize string to HFID list format: "name" -> ["name"] or keep as string (UUID)
480+
clean_data[key] = normalize_hfid_reference(value)
451481
elif not rel_info.is_reference and rel_info.is_bidirectional and rel_info.is_mandatory:
452482
remaining_rels.append(key)
453483
elif not rel_info.is_reference and not rel_info.is_mandatory:

tests/unit/sdk/spec/test_object.py

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

3-
from typing import TYPE_CHECKING
3+
from dataclasses import dataclass
4+
from typing import TYPE_CHECKING, Any
5+
from unittest.mock import AsyncMock, patch
46

57
import pytest
68

@@ -9,6 +11,7 @@
911

1012
if TYPE_CHECKING:
1113
from infrahub_sdk.client import InfrahubClient
14+
from infrahub_sdk.node import InfrahubNode
1215

1316

1417
@pytest.fixture
@@ -263,3 +266,105 @@ async def test_parameters_non_dict(client_with_schema_01: InfrahubClient, locati
263266
obj = ObjectFile(location="some/path", content=location_with_non_dict_parameters)
264267
with pytest.raises(ValidationError):
265268
await obj.validate_format(client=client_with_schema_01)
269+
270+
271+
@dataclass
272+
class HfidLoadTestCase:
273+
"""Test case for HFID normalization in object loading."""
274+
275+
name: str
276+
data: list[dict[str, Any]]
277+
expected_primary_tag: str | list[str] | None
278+
expected_tags: list[str] | list[list[str]] | None
279+
280+
281+
HFID_NORMALIZATION_TEST_CASES = [
282+
HfidLoadTestCase(
283+
name="cardinality_one_string_hfid_normalized",
284+
data=[{"name": "Mexico", "type": "Country", "primary_tag": "Important"}],
285+
expected_primary_tag=["Important"],
286+
expected_tags=None,
287+
),
288+
HfidLoadTestCase(
289+
name="cardinality_one_list_hfid_unchanged",
290+
data=[{"name": "Mexico", "type": "Country", "primary_tag": ["Important"]}],
291+
expected_primary_tag=["Important"],
292+
expected_tags=None,
293+
),
294+
HfidLoadTestCase(
295+
name="cardinality_one_uuid_unchanged",
296+
data=[{"name": "Mexico", "type": "Country", "primary_tag": "550e8400-e29b-41d4-a716-446655440000"}],
297+
expected_primary_tag="550e8400-e29b-41d4-a716-446655440000",
298+
expected_tags=None,
299+
),
300+
HfidLoadTestCase(
301+
name="cardinality_many_string_hfids_normalized",
302+
data=[{"name": "Mexico", "type": "Country", "tags": ["Important", "Active"]}],
303+
expected_primary_tag=None,
304+
expected_tags=[["Important"], ["Active"]],
305+
),
306+
HfidLoadTestCase(
307+
name="cardinality_many_list_hfids_unchanged",
308+
data=[{"name": "Mexico", "type": "Country", "tags": [["Important"], ["Active"]]}],
309+
expected_primary_tag=None,
310+
expected_tags=[["Important"], ["Active"]],
311+
),
312+
HfidLoadTestCase(
313+
name="cardinality_many_mixed_hfids_normalized",
314+
data=[{"name": "Mexico", "type": "Country", "tags": ["Important", ["namespace", "name"]]}],
315+
expected_primary_tag=None,
316+
expected_tags=[["Important"], ["namespace", "name"]],
317+
),
318+
HfidLoadTestCase(
319+
name="cardinality_many_uuids_unchanged",
320+
data=[
321+
{
322+
"name": "Mexico",
323+
"type": "Country",
324+
"tags": ["550e8400-e29b-41d4-a716-446655440000", "6ba7b810-9dad-11d1-80b4-00c04fd430c8"],
325+
}
326+
],
327+
expected_primary_tag=None,
328+
expected_tags=["550e8400-e29b-41d4-a716-446655440000", "6ba7b810-9dad-11d1-80b4-00c04fd430c8"],
329+
),
330+
]
331+
332+
333+
@pytest.mark.parametrize("test_case", HFID_NORMALIZATION_TEST_CASES, ids=lambda tc: tc.name)
334+
async def test_hfid_normalization_in_object_loading(
335+
client_with_schema_01: InfrahubClient, test_case: HfidLoadTestCase
336+
) -> None:
337+
"""Test that HFIDs are normalized correctly based on cardinality and format."""
338+
339+
root_location = {"apiVersion": "infrahub.app/v1", "kind": "Object", "spec": {"kind": "BuiltinLocation", "data": []}}
340+
location = {
341+
"apiVersion": root_location["apiVersion"],
342+
"kind": root_location["kind"],
343+
"spec": {"kind": root_location["spec"]["kind"], "data": test_case.data},
344+
}
345+
346+
obj = ObjectFile(location="some/path", content=location)
347+
await obj.validate_format(client=client_with_schema_01)
348+
349+
create_calls: list[dict[str, Any]] = []
350+
351+
async def mock_create(
352+
kind: str,
353+
branch: str | None = None,
354+
data: dict | None = None,
355+
**kwargs: Any, # noqa: ANN401
356+
) -> InfrahubNode:
357+
create_calls.append({"kind": kind, "data": data})
358+
original_create = client_with_schema_01.__class__.create
359+
return await original_create(client_with_schema_01, kind=kind, branch=branch, data=data, **kwargs)
360+
361+
client_with_schema_01.create = mock_create
362+
363+
with patch("infrahub_sdk.node.InfrahubNode.save", new_callable=AsyncMock):
364+
await obj.process(client=client_with_schema_01)
365+
366+
assert len(create_calls) == 1
367+
if test_case.expected_primary_tag is not None:
368+
assert create_calls[0]["data"]["primary_tag"] == test_case.expected_primary_tag
369+
if test_case.expected_tags is not None:
370+
assert create_calls[0]["data"]["tags"] == test_case.expected_tags

0 commit comments

Comments
 (0)