Skip to content

Commit 2c16773

Browse files
authored
Object file range expansion (#561)
* 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. * Refactor range expansion tests to clarify expected behavior for edge cases * Refactor range expansion logic to a standalone function for improved reusability and clarity * Rename variable for clarity in data expansion process within InfrahubObjectFileData class * Remove unnecessary range expansion in InfrahubObjectFileData class to streamline data handling
1 parent f9ccf10 commit 2c16773

File tree

6 files changed

+428
-4
lines changed

6 files changed

+428
-4
lines changed

changelog/560.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

docs/docs/python-sdk/topics/object_file.mdx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,86 @@ Metadata support is planned for future releases. Currently, the Object file does
195195
2. Keep object files organized by model type or purpose.
196196
3. Validate object files before loading them into production environments.
197197
4. Use comments in your YAML files to document complex relationships or dependencies.
198+
199+
## Range Expansion in Object Files
200+
201+
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.
202+
203+
### How Range Expansion Works
204+
205+
- Any string field containing a pattern like `[1-5]`, `[10-15]`, or `[1,3,5]` will be expanded into multiple objects.
206+
- If multiple fields in the same object use range expansion, **all expanded lists must have the same length**. If not, validation will fail.
207+
- The expansion is performed before validation and processing, so all downstream logic works on the expanded data.
208+
209+
### Examples
210+
211+
#### Single Field Expansion
212+
213+
```yaml
214+
spec:
215+
kind: BuiltinLocation
216+
data:
217+
- name: AMS[1-3]
218+
type: Country
219+
```
220+
221+
This will expand to:
222+
223+
```yaml
224+
- name: AMS1
225+
type: Country
226+
- name: AMS2
227+
type: Country
228+
- name: AMS3
229+
type: Country
230+
```
231+
232+
#### Multiple Field Expansion (Matching Lengths)
233+
234+
```yaml
235+
spec:
236+
kind: BuiltinLocation
237+
data:
238+
- name: AMS[1-3]
239+
description: Datacenter [A-C]
240+
type: Country
241+
```
242+
243+
This will expand to:
244+
245+
```yaml
246+
- name: AMS1
247+
description: Datacenter A
248+
type: Country
249+
- name: AMS2
250+
description: Datacenter B
251+
type: Country
252+
- name: AMS3
253+
description: Datacenter C
254+
type: Country
255+
```
256+
257+
#### Error: Mismatched Range Lengths
258+
259+
If you use ranges of different lengths in multiple fields:
260+
261+
```yaml
262+
spec:
263+
kind: BuiltinLocation
264+
data:
265+
- name: AMS[1-3]
266+
description: "Datacenter [10-15]"
267+
type: Country
268+
```
269+
270+
This will **fail validation** with an error like:
271+
272+
```bash
273+
Range expansion mismatch: fields expanded to different lengths: [3, 6]
274+
```
275+
276+
### Notes
277+
278+
- Range expansion is supported for any string field in the `data` section.
279+
- If no range pattern is present, the field is left unchanged.
280+
- If expansion fails for any field, validation will fail with an error message.

infrahub_sdk/spec/object.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import copy
4+
import re
35
from enum import Enum
46
from typing import TYPE_CHECKING, Any
57

@@ -8,6 +10,7 @@
810
from ..exceptions import ObjectValidationError, ValidationError
911
from ..schema import GenericSchemaAPI, RelationshipKind, RelationshipSchema
1012
from ..yaml import InfrahubFile, InfrahubFileKind
13+
from .range_expansion import MATCH_PATTERN, range_expansion
1114

1215
if TYPE_CHECKING:
1316
from ..client import InfrahubClient
@@ -164,14 +167,47 @@ async def get_relationship_info(
164167
return info
165168

166169

170+
def expand_data_with_ranges(data: list[dict[str, Any]]) -> list[dict[str, Any]]:
171+
"""Expand any item in self.data with range pattern in any value. Supports multiple fields, requires equal expansion length."""
172+
range_pattern = re.compile(MATCH_PATTERN)
173+
expanded = []
174+
for item in data:
175+
# Find all fields to expand
176+
expand_fields = {}
177+
for key, value in item.items():
178+
if isinstance(value, str) and range_pattern.search(value):
179+
try:
180+
expand_fields[key] = range_expansion(value)
181+
except Exception:
182+
# If expansion fails, treat as no expansion
183+
expand_fields[key] = [value]
184+
if not expand_fields:
185+
expanded.append(item)
186+
continue
187+
# Check all expanded lists have the same length
188+
lengths = [len(v) for v in expand_fields.values()]
189+
if len(set(lengths)) > 1:
190+
raise ValidationError(f"Range expansion mismatch: fields expanded to different lengths: {lengths}")
191+
n = lengths[0]
192+
# Zip expanded values and produce new items
193+
for i in range(n):
194+
new_item = copy.deepcopy(item)
195+
for key, values in expand_fields.items():
196+
new_item[key] = values[i]
197+
expanded.append(new_item)
198+
return expanded
199+
200+
167201
class InfrahubObjectFileData(BaseModel):
168202
kind: str
169203
data: list[dict[str, Any]] = Field(default_factory=list)
170204

171205
async def validate_format(self, client: InfrahubClient, branch: str | None = None) -> list[ObjectValidationError]:
172206
errors: list[ObjectValidationError] = []
173207
schema = await client.schema.get(kind=self.kind, branch=branch)
174-
for idx, item in enumerate(self.data):
208+
expanded_data = expand_data_with_ranges(self.data)
209+
self.data = expanded_data
210+
for idx, item in enumerate(expanded_data):
175211
errors.extend(
176212
await self.validate_object(
177213
client=client,
@@ -186,7 +222,8 @@ async def validate_format(self, client: InfrahubClient, branch: str | None = Non
186222

187223
async def process(self, client: InfrahubClient, branch: str | None = None) -> None:
188224
schema = await client.schema.get(kind=self.kind, branch=branch)
189-
for idx, item in enumerate(self.data):
225+
expanded_data = expand_data_with_ranges(self.data)
226+
for idx, item in enumerate(expanded_data):
190227
await self.create_node(
191228
client=client,
192229
schema=schema,
@@ -311,7 +348,8 @@ async def validate_related_nodes(
311348
rel_info.find_matching_relationship(peer_schema=peer_schema)
312349
context.update(rel_info.get_context(value="placeholder"))
313350

314-
for idx, peer_data in enumerate(data["data"]):
351+
expanded_data = expand_data_with_ranges(data=data["data"])
352+
for idx, peer_data in enumerate(expanded_data):
315353
context["list_index"] = idx
316354
errors.extend(
317355
await cls.validate_object(
@@ -525,7 +563,8 @@ async def create_related_nodes(
525563
rel_info.find_matching_relationship(peer_schema=peer_schema)
526564
context.update(rel_info.get_context(value=parent_node.id))
527565

528-
for idx, peer_data in enumerate(data["data"]):
566+
expanded_data = expand_data_with_ranges(data=data["data"])
567+
for idx, peer_data in enumerate(expanded_data):
529568
context["list_index"] = idx
530569
if isinstance(peer_data, dict):
531570
node = await cls.create_node(
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import itertools
2+
import re
3+
4+
MATCH_PATTERN = r"(\[[\w,-]+\])"
5+
6+
7+
def _escape_brackets(s: str) -> str:
8+
return s.replace("\\[", "__LBRACK__").replace("\\]", "__RBRACK__")
9+
10+
11+
def _unescape_brackets(s: str) -> str:
12+
return s.replace("__LBRACK__", "[").replace("__RBRACK__", "]")
13+
14+
15+
def _char_range_expand(char_range_str: str) -> list[str]:
16+
"""Expands a string of numbers or single-character letters."""
17+
expanded_values: list[str] = []
18+
# Special case: if no dash and no comma, and multiple characters, error if not all alphanumeric
19+
if "," not in char_range_str and "-" not in char_range_str and len(char_range_str) > 1:
20+
if not char_range_str.isalnum():
21+
raise ValueError(f"Invalid non-alphanumeric range: [{char_range_str}]")
22+
return list(char_range_str)
23+
24+
for value in char_range_str.split(","):
25+
if not value:
26+
# Malformed: empty part in comma-separated list
27+
return [f"[{char_range_str}]"]
28+
if "-" in value:
29+
start_char, end_char = value.split("-", 1)
30+
if not start_char or not end_char:
31+
expanded_values.append(f"[{char_range_str}]")
32+
return expanded_values
33+
# Check if it's a numeric range
34+
if start_char.isdigit() and end_char.isdigit():
35+
start_num = int(start_char)
36+
end_num = int(end_char)
37+
step = 1 if start_num <= end_num else -1
38+
expanded_values.extend(str(i) for i in range(start_num, end_num + step, step))
39+
# Check if it's an alphabetical range (single character)
40+
elif len(start_char) == 1 and len(end_char) == 1 and start_char.isalpha() and end_char.isalpha():
41+
start_ord = ord(start_char)
42+
end_ord = ord(end_char)
43+
step = 1 if start_ord <= end_ord else -1
44+
is_upper = start_char.isupper()
45+
for i in range(start_ord, end_ord + step, step):
46+
char = chr(i)
47+
expanded_values.append(char.upper() if is_upper else char)
48+
else:
49+
# Mixed or unsupported range type, append as-is
50+
expanded_values.append(value)
51+
else:
52+
# If the value is a single character or valid alphanumeric string, append
53+
if not value.isalnum():
54+
raise ValueError(f"Invalid non-alphanumeric value: [{value}]")
55+
expanded_values.append(value)
56+
return expanded_values
57+
58+
59+
def _extract_constants(pattern: str, re_compiled: re.Pattern) -> tuple[list[int], list[list[str]]]:
60+
cartesian_list = []
61+
interface_constant = [0]
62+
for match in re_compiled.finditer(pattern):
63+
interface_constant.append(match.start())
64+
interface_constant.append(match.end())
65+
cartesian_list.append(_char_range_expand(match.group()[1:-1]))
66+
return interface_constant, cartesian_list
67+
68+
69+
def _expand_interfaces(pattern: str, interface_constant: list[int], cartesian_list: list[list[str]]) -> list[str]:
70+
def _pairwise(lst: list[int]) -> list[tuple[int, int]]:
71+
it = iter(lst)
72+
return list(zip(it, it))
73+
74+
if interface_constant[-1] < len(pattern):
75+
interface_constant.append(len(pattern))
76+
interface_constant_out = _pairwise(interface_constant)
77+
expanded_interfaces = []
78+
for element in itertools.product(*cartesian_list):
79+
current_interface = ""
80+
for count, item in enumerate(interface_constant_out):
81+
current_interface += pattern[item[0] : item[1]]
82+
if count < len(element):
83+
current_interface += element[count]
84+
expanded_interfaces.append(_unescape_brackets(current_interface))
85+
return expanded_interfaces
86+
87+
88+
def range_expansion(interface_pattern: str) -> list[str]:
89+
"""Expand string pattern into a list of strings, supporting both
90+
number and single-character alphabet ranges. Heavily inspired by
91+
Netutils interface_range_expansion but adapted to support letters.
92+
93+
Args:
94+
interface_pattern: The string pattern that will be parsed to create the list of interfaces.
95+
96+
Returns:
97+
Contains the expanded list of interfaces.
98+
99+
Examples:
100+
>>> from infrahub_sdk.spec.range_expansion import range_expansion
101+
>>> range_expansion("Device [A-C]")
102+
['Device A', 'Device B', 'Device C']
103+
>>> range_expansion("FastEthernet[1-2]/0/[10-15]")
104+
['FastEthernet1/0/10', 'FastEthernet1/0/11', 'FastEthernet1/0/12',
105+
'FastEthernet1/0/13', 'FastEthernet1/0/14', 'FastEthernet1/0/15',
106+
'FastEthernet2/0/10', 'FastEthernet2/0/11', 'FastEthernet2/0/12',
107+
'FastEthernet2/0/13', 'FastEthernet2/0/14', 'FastEthernet2/0/15']
108+
>>> range_expansion("GigabitEthernet[a-c]/0/1")
109+
['GigabitEtherneta/0/1', 'GigabitEthernetb/0/1', 'GigabitEthernetc/0/1']
110+
>>> range_expansion("Eth[a,c,e]/0/1")
111+
['Etha/0/1', 'Ethc/0/1', 'Ethe/0/1']
112+
"""
113+
pattern_escaped = _escape_brackets(interface_pattern)
114+
re_compiled = re.compile(MATCH_PATTERN)
115+
if not re_compiled.search(pattern_escaped):
116+
return [_unescape_brackets(pattern_escaped)]
117+
interface_constant, cartesian_list = _extract_constants(pattern_escaped, re_compiled)
118+
return _expand_interfaces(pattern_escaped, interface_constant, cartesian_list)

tests/unit/sdk/spec/test_object.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,47 @@ def location_bad_syntax02(root_location: dict) -> dict:
4343
return location
4444

4545

46+
@pytest.fixture
47+
def location_expansion(root_location: dict) -> dict:
48+
data = [
49+
{
50+
"name": "AMS[1-5]",
51+
"type": "Country",
52+
}
53+
]
54+
location = root_location.copy()
55+
location["spec"]["data"] = data
56+
return location
57+
58+
59+
@pytest.fixture
60+
def location_expansion_multiple_ranges(root_location: dict) -> dict:
61+
data = [
62+
{
63+
"name": "AMS[1-5]",
64+
"type": "Country",
65+
"description": "Amsterdam datacenter [a,e,i,o,u]",
66+
}
67+
]
68+
location = root_location.copy()
69+
location["spec"]["data"] = data
70+
return location
71+
72+
73+
@pytest.fixture
74+
def location_expansion_multiple_ranges_bad_syntax(root_location: dict) -> dict:
75+
data = [
76+
{
77+
"name": "AMS[1-5]",
78+
"type": "Country",
79+
"description": "Amsterdam datacenter [10-15]",
80+
}
81+
]
82+
location = root_location.copy()
83+
location["spec"]["data"] = data
84+
return location
85+
86+
4687
async def test_validate_object(client: InfrahubClient, mock_schema_query_01: HTTPXMock, location_mexico_01) -> None:
4788
obj = ObjectFile(location="some/path", content=location_mexico_01)
4889
await obj.validate_format(client=client)
@@ -70,6 +111,42 @@ async def test_validate_object_bad_syntax02(
70111
assert "notvalidattribute" in str(exc.value)
71112

72113

114+
async def test_validate_object_expansion(
115+
client: InfrahubClient, mock_schema_query_01: HTTPXMock, location_expansion
116+
) -> None:
117+
obj = ObjectFile(location="some/path", content=location_expansion)
118+
await obj.validate_format(client=client)
119+
120+
assert obj.spec.kind == "BuiltinLocation"
121+
assert len(obj.spec.data) == 5
122+
assert obj.spec.data[0]["name"] == "AMS1"
123+
assert obj.spec.data[4]["name"] == "AMS5"
124+
125+
126+
async def test_validate_object_expansion_multiple_ranges(
127+
client: InfrahubClient, mock_schema_query_01: HTTPXMock, location_expansion_multiple_ranges
128+
) -> None:
129+
obj = ObjectFile(location="some/path", content=location_expansion_multiple_ranges)
130+
await obj.validate_format(client=client)
131+
132+
assert obj.spec.kind == "BuiltinLocation"
133+
assert len(obj.spec.data) == 5
134+
assert obj.spec.data[0]["name"] == "AMS1"
135+
assert obj.spec.data[0]["description"] == "Amsterdam datacenter a"
136+
assert obj.spec.data[4]["name"] == "AMS5"
137+
assert obj.spec.data[4]["description"] == "Amsterdam datacenter u"
138+
139+
140+
async def test_validate_object_expansion_multiple_ranges_bad_syntax(
141+
client: InfrahubClient, mock_schema_query_01: HTTPXMock, location_expansion_multiple_ranges_bad_syntax
142+
) -> None:
143+
obj = ObjectFile(location="some/path", content=location_expansion_multiple_ranges_bad_syntax)
144+
with pytest.raises(ValidationError) as exc:
145+
await obj.validate_format(client=client)
146+
147+
assert "Range expansion mismatch" in str(exc.value)
148+
149+
73150
get_relationship_info_testdata = [
74151
pytest.param(
75152
[

0 commit comments

Comments
 (0)