From c97fbe03b7544681824587ed7df0b7e8c513a0c2 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Tue, 30 Sep 2025 10:58:35 +0100 Subject: [PATCH 1/5] Add range expansion feature for object files and update documentation - Implemented range expansion for string fields in object files, allowing patterns like [1-5] to create multiple objects. - Added validation to ensure expanded lists have the same length. - Updated documentation to include usage examples and details on the new feature. - Added unit tests to verify range expansion functionality and error handling for mismatched lengths. --- changelog/560.added.md | 1 + docs/docs/python-sdk/topics/object_file.mdx | 83 ++++++++++++++ infrahub_sdk/spec/object.py | 40 ++++++- infrahub_sdk/spec/range_expansion.py | 118 ++++++++++++++++++++ tests/unit/sdk/spec/test_object.py | 77 +++++++++++++ tests/unit/sdk/test_range_expansion.py | 110 ++++++++++++++++++ 6 files changed, 427 insertions(+), 2 deletions(-) create mode 100644 changelog/560.added.md create mode 100644 infrahub_sdk/spec/range_expansion.py create mode 100644 tests/unit/sdk/test_range_expansion.py diff --git a/changelog/560.added.md b/changelog/560.added.md new file mode 100644 index 00000000..95fe3b37 --- /dev/null +++ b/changelog/560.added.md @@ -0,0 +1 @@ +Add the ability to perform range expansions in object files. This feature allows users to define patterns in string fields that will be expanded into multiple objects, facilitating bulk object creation and management. The implementation includes validation to ensure that all expanded lists have the same length, preventing inconsistencies. Documentation has been updated to explain how to use this feature, including examples of valid and invalid configurations. \ No newline at end of file diff --git a/docs/docs/python-sdk/topics/object_file.mdx b/docs/docs/python-sdk/topics/object_file.mdx index aebacb83..c5033eb5 100644 --- a/docs/docs/python-sdk/topics/object_file.mdx +++ b/docs/docs/python-sdk/topics/object_file.mdx @@ -195,3 +195,86 @@ Metadata support is planned for future releases. Currently, the Object file does 2. Keep object files organized by model type or purpose. 3. Validate object files before loading them into production environments. 4. Use comments in your YAML files to document complex relationships or dependencies. + +## Range Expansion in Object Files + +The Infrahub Python SDK supports **range expansion** for string fields in object files. This feature allows you to specify a range pattern (e.g., `[1-5]`) in any string value, and the SDK will automatically expand it into multiple objects during validation and processing. + +### How Range Expansion Works + +- Any string field containing a pattern like `[1-5]`, `[10-15]`, or `[1,3,5]` will be expanded into multiple objects. +- If multiple fields in the same object use range expansion, **all expanded lists must have the same length**. If not, validation will fail. +- The expansion is performed before validation and processing, so all downstream logic works on the expanded data. + +### Examples + +#### Single Field Expansion + +```yaml +spec: + kind: BuiltinLocation + data: + - name: AMS[1-3] + type: Country +``` + +This will expand to: + +```yaml +- name: AMS1 + type: Country +- name: AMS2 + type: Country +- name: AMS3 + type: Country +``` + +#### Multiple Field Expansion (Matching Lengths) + +```yaml +spec: + kind: BuiltinLocation + data: + - name: AMS[1-3] + description: Datacenter [A-C] + type: Country +``` + +This will expand to: + +```yaml +- name: AMS1 + description: Datacenter A + type: Country +- name: AMS2 + description: Datacenter B + type: Country +- name: AMS3 + description: Datacenter C + type: Country +``` + +#### Error: Mismatched Range Lengths + +If you use ranges of different lengths in multiple fields: + +```yaml +spec: + kind: BuiltinLocation + data: + - name: AMS[1-3] + description: "Datacenter [10-15]" + type: Country +``` + +This will **fail validation** with an error like: + +```bash +Range expansion mismatch: fields expanded to different lengths: [3, 6] +``` + +### Notes + +- Range expansion is supported for any string field in the `data` section. +- If no range pattern is present, the field is left unchanged. +- If expansion fails for any field, validation will fail with an error message. diff --git a/infrahub_sdk/spec/object.py b/infrahub_sdk/spec/object.py index 23a11c10..cf4d4170 100644 --- a/infrahub_sdk/spec/object.py +++ b/infrahub_sdk/spec/object.py @@ -1,5 +1,7 @@ from __future__ import annotations +import copy +import re from enum import Enum from typing import TYPE_CHECKING, Any @@ -8,6 +10,7 @@ from ..exceptions import ObjectValidationError, ValidationError from ..schema import GenericSchemaAPI, RelationshipKind, RelationshipSchema from ..yaml import InfrahubFile, InfrahubFileKind +from .range_expansion import MATCH_PATTERN, range_expansion if TYPE_CHECKING: from ..client import InfrahubClient @@ -165,13 +168,45 @@ async def get_relationship_info( class InfrahubObjectFileData(BaseModel): + def expand_data_with_ranges(self) -> list[dict[str, Any]]: + """Expand any item in self.data with range pattern in any value. Supports multiple fields, requires equal expansion length.""" + range_pattern = re.compile(MATCH_PATTERN) + expanded = [] + for item in self.data: + # Find all fields to expand + expand_fields = {} + for key, value in item.items(): + if isinstance(value, str) and range_pattern.search(value): + try: + expand_fields[key] = range_expansion(value) + except Exception: + # If expansion fails, treat as no expansion + expand_fields[key] = [value] + if not expand_fields: + expanded.append(item) + continue + # Check all expanded lists have the same length + lengths = [len(v) for v in expand_fields.values()] + if len(set(lengths)) > 1: + raise ValidationError(f"Range expansion mismatch: fields expanded to different lengths: {lengths}") + n = lengths[0] + # Zip expanded values and produce new items + for i in range(n): + new_item = copy.deepcopy(item) + for key, values in expand_fields.items(): + new_item[key] = values[i] + expanded.append(new_item) + return expanded + kind: str data: list[dict[str, Any]] = Field(default_factory=list) async def validate_format(self, client: InfrahubClient, branch: str | None = None) -> list[ObjectValidationError]: errors: list[ObjectValidationError] = [] schema = await client.schema.get(kind=self.kind, branch=branch) - for idx, item in enumerate(self.data): + expanded_data = self.expand_data_with_ranges() + self.data = expanded_data + for idx, item in enumerate(expanded_data): errors.extend( await self.validate_object( client=client, @@ -186,7 +221,8 @@ async def validate_format(self, client: InfrahubClient, branch: str | None = Non async def process(self, client: InfrahubClient, branch: str | None = None) -> None: schema = await client.schema.get(kind=self.kind, branch=branch) - for idx, item in enumerate(self.data): + expanded_data = self.expand_data_with_ranges() + for idx, item in enumerate(expanded_data): await self.create_node( client=client, schema=schema, diff --git a/infrahub_sdk/spec/range_expansion.py b/infrahub_sdk/spec/range_expansion.py new file mode 100644 index 00000000..441c589c --- /dev/null +++ b/infrahub_sdk/spec/range_expansion.py @@ -0,0 +1,118 @@ +import itertools +import re + +MATCH_PATTERN = r"(\[[\w,-]+\])" + + +def _escape_brackets(s: str) -> str: + return s.replace("\\[", "__LBRACK__").replace("\\]", "__RBRACK__") + + +def _unescape_brackets(s: str) -> str: + return s.replace("__LBRACK__", "[").replace("__RBRACK__", "]") + + +def _char_range_expand(char_range_str: str) -> list[str]: + """Expands a string of numbers or single-character letters.""" + expanded_values: list[str] = [] + # Special case: if no dash and no comma, and multiple characters, error if not all alphanumeric + if "," not in char_range_str and "-" not in char_range_str and len(char_range_str) > 1: + if not char_range_str.isalnum(): + raise ValueError(f"Invalid non-alphanumeric range: [{char_range_str}]") + return list(char_range_str) + + for value in char_range_str.split(","): + if not value: + # Malformed: empty part in comma-separated list + return [f"[{char_range_str}]"] + if "-" in value: + start_char, end_char = value.split("-", 1) + if not start_char or not end_char: + expanded_values.append(f"[{char_range_str}]") + return expanded_values + # Check if it's a numeric range + if start_char.isdigit() and end_char.isdigit(): + start_num = int(start_char) + end_num = int(end_char) + step = 1 if start_num <= end_num else -1 + expanded_values.extend(str(i) for i in range(start_num, end_num + step, step)) + # Check if it's an alphabetical range (single character) + elif len(start_char) == 1 and len(end_char) == 1 and start_char.isalpha() and end_char.isalpha(): + start_ord = ord(start_char) + end_ord = ord(end_char) + step = 1 if start_ord <= end_ord else -1 + is_upper = start_char.isupper() + for i in range(start_ord, end_ord + step, step): + char = chr(i) + expanded_values.append(char.upper() if is_upper else char) + else: + # Mixed or unsupported range type, append as-is + expanded_values.append(value) + else: + # If the value is a single character or valid alphanumeric string, append + if not value.isalnum(): + raise ValueError(f"Invalid non-alphanumeric value: [{value}]") + expanded_values.append(value) + return expanded_values + + +def _extract_constants(pattern: str, re_compiled: re.Pattern) -> tuple[list[int], list[list[str]]]: + cartesian_list = [] + interface_constant = [0] + for match in re_compiled.finditer(pattern): + interface_constant.append(match.start()) + interface_constant.append(match.end()) + cartesian_list.append(_char_range_expand(match.group()[1:-1])) + return interface_constant, cartesian_list + + +def _expand_interfaces(pattern: str, interface_constant: list[int], cartesian_list: list[list[str]]) -> list[str]: + def _pairwise(lst: list[int]) -> list[tuple[int, int]]: + it = iter(lst) + return list(zip(it, it)) + + if interface_constant[-1] < len(pattern): + interface_constant.append(len(pattern)) + interface_constant_out = _pairwise(interface_constant) + expanded_interfaces = [] + for element in itertools.product(*cartesian_list): + current_interface = "" + for count, item in enumerate(interface_constant_out): + current_interface += pattern[item[0] : item[1]] + if count < len(element): + current_interface += element[count] + expanded_interfaces.append(_unescape_brackets(current_interface)) + return expanded_interfaces + + +def range_expansion(interface_pattern: str) -> list[str]: + """Expand string pattern into a list of strings, supporting both + number and single-character alphabet ranges. Heavily inspired by + Netutils interface_range_expansion but adapted to support letters. + + Args: + interface_pattern: The string pattern that will be parsed to create the list of interfaces. + + Returns: + Contains the expanded list of interfaces. + + Examples: + >>> from infrahub_sdk.spec.range_expansion import range_expansion + >>> range_expansion("Device [A-C]") + ['Device A', 'Device B', 'Device C'] + >>> range_expansion("FastEthernet[1-2]/0/[10-15]") + ['FastEthernet1/0/10', 'FastEthernet1/0/11', 'FastEthernet1/0/12', + 'FastEthernet1/0/13', 'FastEthernet1/0/14', 'FastEthernet1/0/15', + 'FastEthernet2/0/10', 'FastEthernet2/0/11', 'FastEthernet2/0/12', + 'FastEthernet2/0/13', 'FastEthernet2/0/14', 'FastEthernet2/0/15'] + >>> range_expansion("GigabitEthernet[a-c]/0/1") + ['GigabitEtherneta/0/1', 'GigabitEthernetb/0/1', 'GigabitEthernetc/0/1'] + >>> range_expansion("Eth[a,c,e]/0/1") + ['Etha/0/1', 'Ethc/0/1', 'Ethe/0/1'] + """ + pattern_escaped = _escape_brackets(interface_pattern) + re_compiled = re.compile(MATCH_PATTERN) + if not re_compiled.search(pattern_escaped): + return [_unescape_brackets(pattern_escaped)] + interface_constant, cartesian_list = _extract_constants(pattern_escaped, re_compiled) + return _expand_interfaces(pattern_escaped, interface_constant, cartesian_list) diff --git a/tests/unit/sdk/spec/test_object.py b/tests/unit/sdk/spec/test_object.py index 29f06391..dbe517ab 100644 --- a/tests/unit/sdk/spec/test_object.py +++ b/tests/unit/sdk/spec/test_object.py @@ -43,6 +43,47 @@ def location_bad_syntax02(root_location: dict) -> dict: return location +@pytest.fixture +def location_expansion(root_location: dict) -> dict: + data = [ + { + "name": "AMS[1-5]", + "type": "Country", + } + ] + location = root_location.copy() + location["spec"]["data"] = data + return location + + +@pytest.fixture +def location_expansion_multiple_ranges(root_location: dict) -> dict: + data = [ + { + "name": "AMS[1-5]", + "type": "Country", + "description": "Amsterdam datacenter [a,e,i,o,u]", + } + ] + location = root_location.copy() + location["spec"]["data"] = data + return location + + +@pytest.fixture +def location_expansion_multiple_ranges_bad_syntax(root_location: dict) -> dict: + data = [ + { + "name": "AMS[1-5]", + "type": "Country", + "description": "Amsterdam datacenter [10-15]", + } + ] + location = root_location.copy() + location["spec"]["data"] = data + return location + + async def test_validate_object(client: InfrahubClient, mock_schema_query_01: HTTPXMock, location_mexico_01) -> None: obj = ObjectFile(location="some/path", content=location_mexico_01) await obj.validate_format(client=client) @@ -70,6 +111,42 @@ async def test_validate_object_bad_syntax02( assert "notvalidattribute" in str(exc.value) +async def test_validate_object_expansion( + client: InfrahubClient, mock_schema_query_01: HTTPXMock, location_expansion +) -> None: + obj = ObjectFile(location="some/path", content=location_expansion) + await obj.validate_format(client=client) + + assert obj.spec.kind == "BuiltinLocation" + assert len(obj.spec.data) == 5 + assert obj.spec.data[0]["name"] == "AMS1" + assert obj.spec.data[4]["name"] == "AMS5" + + +async def test_validate_object_expansion_multiple_ranges( + client: InfrahubClient, mock_schema_query_01: HTTPXMock, location_expansion_multiple_ranges +) -> None: + obj = ObjectFile(location="some/path", content=location_expansion_multiple_ranges) + await obj.validate_format(client=client) + + assert obj.spec.kind == "BuiltinLocation" + assert len(obj.spec.data) == 5 + assert obj.spec.data[0]["name"] == "AMS1" + assert obj.spec.data[0]["description"] == "Amsterdam datacenter a" + assert obj.spec.data[4]["name"] == "AMS5" + assert obj.spec.data[4]["description"] == "Amsterdam datacenter u" + + +async def test_validate_object_expansion_multiple_ranges_bad_syntax( + client: InfrahubClient, mock_schema_query_01: HTTPXMock, location_expansion_multiple_ranges_bad_syntax +) -> None: + obj = ObjectFile(location="some/path", content=location_expansion_multiple_ranges_bad_syntax) + with pytest.raises(ValidationError) as exc: + await obj.validate_format(client=client) + + assert "Range expansion mismatch" in str(exc.value) + + get_relationship_info_testdata = [ pytest.param( [ diff --git a/tests/unit/sdk/test_range_expansion.py b/tests/unit/sdk/test_range_expansion.py new file mode 100644 index 00000000..f8caf3c7 --- /dev/null +++ b/tests/unit/sdk/test_range_expansion.py @@ -0,0 +1,110 @@ +from infrahub_sdk.spec.range_expansion import range_expansion + + +def test_number_range_expansion() -> None: + assert range_expansion("Device[1-3]") == ["Device1", "Device2", "Device3"] + assert range_expansion("FastEthernet[1-2]/0/[10-12]") == [ + "FastEthernet1/0/10", + "FastEthernet1/0/11", + "FastEthernet1/0/12", + "FastEthernet2/0/10", + "FastEthernet2/0/11", + "FastEthernet2/0/12", + ] + + +def test_letter_range_expansion() -> None: + assert range_expansion("Device [A-C]") == ["Device A", "Device B", "Device C"] + assert range_expansion("GigabitEthernet[a-c]/0/1") == [ + "GigabitEtherneta/0/1", + "GigabitEthernetb/0/1", + "GigabitEthernetc/0/1", + ] + assert range_expansion("Eth[a,c,e]/0/1") == [ + "Etha/0/1", + "Ethc/0/1", + "Ethe/0/1", + ] + + +def test_mixed_range_expansion() -> None: + assert range_expansion("Device[1-2,A-C]") == [ + "Device1", + "Device2", + "DeviceA", + "DeviceB", + "DeviceC", + ] + assert range_expansion("Interface[1-2,a-c]/0/[10-11,x,z]") == [ + "Interface1/0/10", + "Interface1/0/11", + "Interface1/0/x", + "Interface1/0/z", + "Interface2/0/10", + "Interface2/0/11", + "Interface2/0/x", + "Interface2/0/z", + "Interfacea/0/10", + "Interfacea/0/11", + "Interfacea/0/x", + "Interfacea/0/z", + "Interfaceb/0/10", + "Interfaceb/0/11", + "Interfaceb/0/x", + "Interfaceb/0/z", + "Interfacec/0/10", + "Interfacec/0/11", + "Interfacec/0/x", + "Interfacec/0/z", + ] + + +def test_single_value_in_brackets() -> None: + assert range_expansion("Device[5]") == ["Device5"] + + +def test_empty_brackets() -> None: + assert range_expansion("Device[]") == ["Device[]"] # or raise, depending on implementation + + +def test_no_brackets() -> None: + assert range_expansion("Device1") == ["Device1"] + + +def test_malformed_ranges() -> None: + # These should either return the original or raise, depending on implementation + assert range_expansion("Device[1-]") == ["Device[1-]"] + assert range_expansion("Device[-3]") == ["Device[-3]"] + assert range_expansion("Device[a-]") == ["Device[a-]"] + assert range_expansion("Device[1-3,]") == ["Device[1-3,]"] + + +def test_duplicate_and_overlapping_values() -> None: + assert range_expansion("Device[1,1,2]") == ["Device1", "Device1", "Device2"] + + +def test_whitespace_handling() -> None: + assert range_expansion("Device[ 1 - 3 ]") == [ + "Device[ 1 - 3 ]" + ] # or ["Device1", "Device2", "Device3"] if whitespace is handled + + +def test_descending_ranges() -> None: + assert range_expansion("Device[3-1]") == ["Device3", "Device2", "Device1"] # or error, depending on implementation + + +def test_multiple_bracketed_ranges_in_a_row() -> None: + assert range_expansion("Dev[A-B][1-2]") == ["DevA1", "DevA2", "DevB1", "DevB2"] + + +def test_non_alphanumeric_ranges() -> None: + assert range_expansion("Port[!@#]") == ["Port[!@#]"] + + +def test_unicode_ranges() -> None: + # Only if supported by implementation + assert range_expansion("Dev[α-γ]") == ["Devα", "Devβ", "Devγ"] # noqa: RUF001 + + +def test_brackets_in_strings() -> None: + assert range_expansion(r"Service Object [Circuit Provider, X]") == ["Service Object [Circuit Provider, X]"] From f08610c7d190e00ad3a1686078ebbc09a0d7d672 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Tue, 30 Sep 2025 11:00:55 +0100 Subject: [PATCH 2/5] Refactor range expansion tests to clarify expected behavior for edge cases --- tests/unit/sdk/test_range_expansion.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/unit/sdk/test_range_expansion.py b/tests/unit/sdk/test_range_expansion.py index f8caf3c7..26d817c0 100644 --- a/tests/unit/sdk/test_range_expansion.py +++ b/tests/unit/sdk/test_range_expansion.py @@ -64,7 +64,7 @@ def test_single_value_in_brackets() -> None: def test_empty_brackets() -> None: - assert range_expansion("Device[]") == ["Device[]"] # or raise, depending on implementation + assert range_expansion("Device[]") == ["Device[]"] def test_no_brackets() -> None: @@ -72,7 +72,6 @@ def test_no_brackets() -> None: def test_malformed_ranges() -> None: - # These should either return the original or raise, depending on implementation assert range_expansion("Device[1-]") == ["Device[1-]"] assert range_expansion("Device[-3]") == ["Device[-3]"] assert range_expansion("Device[a-]") == ["Device[a-]"] @@ -84,13 +83,11 @@ def test_duplicate_and_overlapping_values() -> None: def test_whitespace_handling() -> None: - assert range_expansion("Device[ 1 - 3 ]") == [ - "Device[ 1 - 3 ]" - ] # or ["Device1", "Device2", "Device3"] if whitespace is handled + assert range_expansion("Device[ 1 - 3 ]") == ["Device[ 1 - 3 ]"] def test_descending_ranges() -> None: - assert range_expansion("Device[3-1]") == ["Device3", "Device2", "Device1"] # or error, depending on implementation + assert range_expansion("Device[3-1]") == ["Device3", "Device2", "Device1"] def test_multiple_bracketed_ranges_in_a_row() -> None: @@ -102,7 +99,6 @@ def test_non_alphanumeric_ranges() -> None: def test_unicode_ranges() -> None: - # Only if supported by implementation assert range_expansion("Dev[α-γ]") == ["Devα", "Devβ", "Devγ"] # noqa: RUF001 From 0f821cf7d18811b8554693396738b68fc5c727b7 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Tue, 30 Sep 2025 16:23:08 +0100 Subject: [PATCH 3/5] Refactor range expansion logic to a standalone function for improved reusability and clarity --- infrahub_sdk/spec/object.py | 78 ++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/infrahub_sdk/spec/object.py b/infrahub_sdk/spec/object.py index cf4d4170..4f36d9fc 100644 --- a/infrahub_sdk/spec/object.py +++ b/infrahub_sdk/spec/object.py @@ -167,44 +167,45 @@ async def get_relationship_info( return info -class InfrahubObjectFileData(BaseModel): - def expand_data_with_ranges(self) -> list[dict[str, Any]]: - """Expand any item in self.data with range pattern in any value. Supports multiple fields, requires equal expansion length.""" - range_pattern = re.compile(MATCH_PATTERN) - expanded = [] - for item in self.data: - # Find all fields to expand - expand_fields = {} - for key, value in item.items(): - if isinstance(value, str) and range_pattern.search(value): - try: - expand_fields[key] = range_expansion(value) - except Exception: - # If expansion fails, treat as no expansion - expand_fields[key] = [value] - if not expand_fields: - expanded.append(item) - continue - # Check all expanded lists have the same length - lengths = [len(v) for v in expand_fields.values()] - if len(set(lengths)) > 1: - raise ValidationError(f"Range expansion mismatch: fields expanded to different lengths: {lengths}") - n = lengths[0] - # Zip expanded values and produce new items - for i in range(n): - new_item = copy.deepcopy(item) - for key, values in expand_fields.items(): - new_item[key] = values[i] - expanded.append(new_item) - return expanded +def expand_data_with_ranges(data: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Expand any item in self.data with range pattern in any value. Supports multiple fields, requires equal expansion length.""" + range_pattern = re.compile(MATCH_PATTERN) + expanded = [] + for item in data: + # Find all fields to expand + expand_fields = {} + for key, value in item.items(): + if isinstance(value, str) and range_pattern.search(value): + try: + expand_fields[key] = range_expansion(value) + except Exception: + # If expansion fails, treat as no expansion + expand_fields[key] = [value] + if not expand_fields: + expanded.append(item) + continue + # Check all expanded lists have the same length + lengths = [len(v) for v in expand_fields.values()] + if len(set(lengths)) > 1: + raise ValidationError(f"Range expansion mismatch: fields expanded to different lengths: {lengths}") + n = lengths[0] + # Zip expanded values and produce new items + for i in range(n): + new_item = copy.deepcopy(item) + for key, values in expand_fields.items(): + new_item[key] = values[i] + expanded.append(new_item) + return expanded + +class InfrahubObjectFileData(BaseModel): kind: str data: list[dict[str, Any]] = Field(default_factory=list) async def validate_format(self, client: InfrahubClient, branch: str | None = None) -> list[ObjectValidationError]: errors: list[ObjectValidationError] = [] schema = await client.schema.get(kind=self.kind, branch=branch) - expanded_data = self.expand_data_with_ranges() + expanded_data = expand_data_with_ranges(self.data) self.data = expanded_data for idx, item in enumerate(expanded_data): errors.extend( @@ -221,7 +222,7 @@ async def validate_format(self, client: InfrahubClient, branch: str | None = Non async def process(self, client: InfrahubClient, branch: str | None = None) -> None: schema = await client.schema.get(kind=self.kind, branch=branch) - expanded_data = self.expand_data_with_ranges() + expanded_data = expand_data_with_ranges(self.data) for idx, item in enumerate(expanded_data): await self.create_node( client=client, @@ -347,7 +348,8 @@ async def validate_related_nodes( rel_info.find_matching_relationship(peer_schema=peer_schema) context.update(rel_info.get_context(value="placeholder")) - for idx, peer_data in enumerate(data["data"]): + extended_data = expand_data_with_ranges(data=data["data"]) + for idx, peer_data in enumerate(extended_data): context["list_index"] = idx errors.extend( await cls.validate_object( @@ -457,22 +459,24 @@ async def create_node( remaining_rels.append(key) elif not rel_info.is_reference and not rel_info.is_mandatory: if rel_info.format == RelationshipDataFormat.ONE_OBJ: + expanded_data = expand_data_with_ranges(data=[value]) nodes = await cls.create_related_nodes( client=client, position=position, rel_info=rel_info, - data=value, + data=expanded_data, branch=branch, default_schema_kind=default_schema_kind, ) clean_data[key] = nodes[0] else: + expanded_data = expand_data_with_ranges(data=value) nodes = await cls.create_related_nodes( client=client, position=position, rel_info=rel_info, - data=value, + data=expanded_data, branch=branch, default_schema_kind=default_schema_kind, ) @@ -561,7 +565,9 @@ async def create_related_nodes( rel_info.find_matching_relationship(peer_schema=peer_schema) context.update(rel_info.get_context(value=parent_node.id)) - for idx, peer_data in enumerate(data["data"]): + expanded_data = expand_data_with_ranges(data=data["data"]) + + for idx, peer_data in enumerate(expanded_data): context["list_index"] = idx if isinstance(peer_data, dict): node = await cls.create_node( From faef2aaef3f0ad62097ee74775d03e8f1be2b815 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Tue, 30 Sep 2025 16:32:35 +0100 Subject: [PATCH 4/5] Rename variable for clarity in data expansion process within InfrahubObjectFileData class --- infrahub_sdk/spec/object.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/infrahub_sdk/spec/object.py b/infrahub_sdk/spec/object.py index 4f36d9fc..9f5931a8 100644 --- a/infrahub_sdk/spec/object.py +++ b/infrahub_sdk/spec/object.py @@ -348,8 +348,8 @@ async def validate_related_nodes( rel_info.find_matching_relationship(peer_schema=peer_schema) context.update(rel_info.get_context(value="placeholder")) - extended_data = expand_data_with_ranges(data=data["data"]) - for idx, peer_data in enumerate(extended_data): + expanded_data = expand_data_with_ranges(data=data["data"]) + for idx, peer_data in enumerate(expanded_data): context["list_index"] = idx errors.extend( await cls.validate_object( @@ -566,7 +566,6 @@ async def create_related_nodes( context.update(rel_info.get_context(value=parent_node.id)) expanded_data = expand_data_with_ranges(data=data["data"]) - for idx, peer_data in enumerate(expanded_data): context["list_index"] = idx if isinstance(peer_data, dict): From cf01aa5a5048921fe9c2c1effce728525185ec04 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Tue, 30 Sep 2025 20:04:45 +0100 Subject: [PATCH 5/5] Remove unnecessary range expansion in InfrahubObjectFileData class to streamline data handling --- infrahub_sdk/spec/object.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/infrahub_sdk/spec/object.py b/infrahub_sdk/spec/object.py index 9f5931a8..5bd54892 100644 --- a/infrahub_sdk/spec/object.py +++ b/infrahub_sdk/spec/object.py @@ -459,24 +459,22 @@ async def create_node( remaining_rels.append(key) elif not rel_info.is_reference and not rel_info.is_mandatory: if rel_info.format == RelationshipDataFormat.ONE_OBJ: - expanded_data = expand_data_with_ranges(data=[value]) nodes = await cls.create_related_nodes( client=client, position=position, rel_info=rel_info, - data=expanded_data, + data=value, branch=branch, default_schema_kind=default_schema_kind, ) clean_data[key] = nodes[0] else: - expanded_data = expand_data_with_ranges(data=value) nodes = await cls.create_related_nodes( client=client, position=position, rel_info=rel_info, - data=expanded_data, + data=value, branch=branch, default_schema_kind=default_schema_kind, )