Skip to content

Commit 7fbc329

Browse files
authored
Range Expansion Imrovements (#571)
* Add data processing strategy for object files with range expansion support - Introduced `strategy` field in object file specification to control data processing. - Implemented `DataProcessor` classes for normal and range expansion strategies. - Updated validation and processing methods to utilize the specified strategy. - Enhanced tests to cover new strategy functionality and ensure correct behavior. * Remove unused strategy parameter from InfrahubObjectFileData class * Improve error handling for unknown data processing strategies and add test for invalid object expansion strategy * Rename test_invalid_object_expansion_strategy to test_invalid_object_expansion_processor and add additional test for invalid strategy handling
1 parent 34fda2d commit 7fbc329

File tree

5 files changed

+175
-18
lines changed

5 files changed

+175
-18
lines changed

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,24 @@ apiVersion: infrahub.app/v1
6060
kind: Object
6161
spec:
6262
kind: <NamespaceName>
63+
strategy: <normal|range_expand> # Optional, defaults to normal
6364
data:
6465
- [...]
6566
```
6667

6768
> Multiple documents in a single YAML file are also supported, each document will be loaded separately. Documents are separated by `---`
6869

70+
### Data Processing Strategies
71+
72+
The `strategy` field controls how the data in the object file is processed before loading into Infrahub:
73+
74+
| Strategy | Description | Default |
75+
|----------|-------------|---------|
76+
| `normal` | No data manipulation is performed. Objects are loaded as-is. | Yes |
77+
| `range_expand` | Range patterns (e.g., `[1-5]`) in string fields are expanded into multiple objects. | No |
78+
79+
When `strategy` is not specified, it defaults to `normal`.
80+
6981
### Relationship of cardinality one
7082

7183
A relationship of cardinality one can either reference an existing node via its HFID or create a new node if it doesn't exist.
@@ -198,7 +210,19 @@ Metadata support is planned for future releases. Currently, the Object file does
198210

199211
## Range Expansion in Object Files
200212

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.
213+
The Infrahub Python SDK supports **range expansion** for string fields in object files when the `strategy` is set to `range_expand`. 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.
214+
215+
```yaml
216+
---
217+
apiVersion: infrahub.app/v1
218+
kind: Object
219+
spec:
220+
kind: BuiltinLocation
221+
strategy: range_expand # Enable range expansion
222+
data:
223+
- name: AMS[1-3]
224+
type: Country
225+
```
202226

203227
### How Range Expansion Works
204228

@@ -213,6 +237,7 @@ The Infrahub Python SDK supports **range expansion** for string fields in object
213237
```yaml
214238
spec:
215239
kind: BuiltinLocation
240+
strategy: range_expand
216241
data:
217242
- name: AMS[1-3]
218243
type: Country
@@ -234,6 +259,7 @@ This will expand to:
234259
```yaml
235260
spec:
236261
kind: BuiltinLocation
262+
strategy: range_expand
237263
data:
238264
- name: AMS[1-3]
239265
description: Datacenter [A-C]
@@ -261,6 +287,7 @@ If you use ranges of different lengths in multiple fields:
261287
```yaml
262288
spec:
263289
kind: BuiltinLocation
290+
strategy: range_expand
264291
data:
265292
- name: AMS[1-3]
266293
description: "Datacenter [10-15]"

infrahub_sdk/spec/object.py

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
import copy
44
import re
5+
from abc import ABC, abstractmethod
56
from enum import Enum
6-
from typing import TYPE_CHECKING, Any
7+
from typing import TYPE_CHECKING, Any, ClassVar
78

89
from pydantic import BaseModel, Field
910

@@ -45,6 +46,11 @@ class RelationshipDataFormat(str, Enum):
4546
MANY_REF = "many_ref_list"
4647

4748

49+
class ObjectStrategy(str, Enum):
50+
NORMAL = "normal"
51+
RANGE_EXPAND = "range_expand"
52+
53+
4854
class RelationshipInfo(BaseModel):
4955
name: str
5056
rel_schema: RelationshipSchema
@@ -168,7 +174,7 @@ async def get_relationship_info(
168174

169175

170176
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."""
177+
"""Expand any item in data with range pattern in any value. Supports multiple fields, requires equal expansion length."""
172178
range_pattern = re.compile(MATCH_PATTERN)
173179
expanded = []
174180
for item in data:
@@ -198,16 +204,69 @@ def expand_data_with_ranges(data: list[dict[str, Any]]) -> list[dict[str, Any]]:
198204
return expanded
199205

200206

207+
class DataProcessor(ABC):
208+
"""Abstract base class for data processing strategies"""
209+
210+
@abstractmethod
211+
def process_data(self, data: list[dict[str, Any]]) -> list[dict[str, Any]]:
212+
"""Process the data according to the strategy"""
213+
214+
215+
class SingleDataProcessor(DataProcessor):
216+
"""Process data without any expansion"""
217+
218+
def process_data(self, data: list[dict[str, Any]]) -> list[dict[str, Any]]:
219+
return data
220+
221+
222+
class RangeExpandDataProcessor(DataProcessor):
223+
"""Process data with range expansion"""
224+
225+
def process_data(self, data: list[dict[str, Any]]) -> list[dict[str, Any]]:
226+
return expand_data_with_ranges(data)
227+
228+
229+
class DataProcessorFactory:
230+
"""Factory to create appropriate data processor based on strategy"""
231+
232+
_processors: ClassVar[dict[ObjectStrategy, type[DataProcessor]]] = {
233+
ObjectStrategy.NORMAL: SingleDataProcessor,
234+
ObjectStrategy.RANGE_EXPAND: RangeExpandDataProcessor,
235+
}
236+
237+
@classmethod
238+
def get_processor(cls, strategy: ObjectStrategy) -> DataProcessor:
239+
processor_class = cls._processors.get(strategy)
240+
if not processor_class:
241+
raise ValueError(
242+
f"Unknown strategy: {strategy} - no processor found. Valid strategies are: {list(cls._processors.keys())}"
243+
)
244+
return processor_class()
245+
246+
@classmethod
247+
def register_processor(cls, strategy: ObjectStrategy, processor_class: type[DataProcessor]) -> None:
248+
"""Register a new processor for a strategy - useful for future extensions"""
249+
cls._processors[strategy] = processor_class
250+
251+
201252
class InfrahubObjectFileData(BaseModel):
202253
kind: str
254+
strategy: ObjectStrategy = ObjectStrategy.NORMAL
203255
data: list[dict[str, Any]] = Field(default_factory=list)
204256

257+
def _get_processed_data(self, data: list[dict[str, Any]]) -> list[dict[str, Any]]:
258+
"""Get data processed according to the strategy"""
259+
processor = DataProcessorFactory.get_processor(self.strategy)
260+
return processor.process_data(data)
261+
205262
async def validate_format(self, client: InfrahubClient, branch: str | None = None) -> list[ObjectValidationError]:
206263
errors: list[ObjectValidationError] = []
207264
schema = await client.schema.get(kind=self.kind, branch=branch)
208-
expanded_data = expand_data_with_ranges(self.data)
209-
self.data = expanded_data
210-
for idx, item in enumerate(expanded_data):
265+
266+
processed_data = self._get_processed_data(data=self.data)
267+
self.data = processed_data
268+
269+
for idx, item in enumerate(processed_data):
211270
errors.extend(
212271
await self.validate_object(
213272
client=client,
@@ -216,14 +275,16 @@ async def validate_format(self, client: InfrahubClient, branch: str | None = Non
216275
data=item,
217276
branch=branch,
218277
default_schema_kind=self.kind,
278+
strategy=self.strategy, # Pass strategy down
219279
)
220280
)
221281
return errors
222282

223283
async def process(self, client: InfrahubClient, branch: str | None = None) -> None:
224284
schema = await client.schema.get(kind=self.kind, branch=branch)
225-
expanded_data = expand_data_with_ranges(self.data)
226-
for idx, item in enumerate(expanded_data):
285+
processed_data = self._get_processed_data(data=self.data)
286+
287+
for idx, item in enumerate(processed_data):
227288
await self.create_node(
228289
client=client,
229290
schema=schema,
@@ -243,6 +304,7 @@ async def validate_object(
243304
context: dict | None = None,
244305
branch: str | None = None,
245306
default_schema_kind: str | None = None,
307+
strategy: ObjectStrategy = ObjectStrategy.NORMAL,
246308
) -> list[ObjectValidationError]:
247309
errors: list[ObjectValidationError] = []
248310
context = context.copy() if context else {}
@@ -292,6 +354,7 @@ async def validate_object(
292354
context=context,
293355
branch=branch,
294356
default_schema_kind=default_schema_kind,
357+
strategy=strategy,
295358
)
296359
)
297360

@@ -307,6 +370,7 @@ async def validate_related_nodes(
307370
context: dict | None = None,
308371
branch: str | None = None,
309372
default_schema_kind: str | None = None,
373+
strategy: ObjectStrategy = ObjectStrategy.NORMAL,
310374
) -> list[ObjectValidationError]:
311375
context = context.copy() if context else {}
312376
errors: list[ObjectValidationError] = []
@@ -348,7 +412,10 @@ async def validate_related_nodes(
348412
rel_info.find_matching_relationship(peer_schema=peer_schema)
349413
context.update(rel_info.get_context(value="placeholder"))
350414

351-
expanded_data = expand_data_with_ranges(data=data["data"])
415+
# Use strategy-aware data processing
416+
processor = DataProcessorFactory.get_processor(strategy)
417+
expanded_data = processor.process_data(data["data"])
418+
352419
for idx, peer_data in enumerate(expanded_data):
353420
context["list_index"] = idx
354421
errors.extend(
@@ -360,6 +427,7 @@ async def validate_related_nodes(
360427
context=context,
361428
branch=branch,
362429
default_schema_kind=default_schema_kind,
430+
strategy=strategy,
363431
)
364432
)
365433
return errors
@@ -633,14 +701,20 @@ class ObjectFile(InfrahubFile):
633701
@property
634702
def spec(self) -> InfrahubObjectFileData:
635703
if not self._spec:
636-
self._spec = InfrahubObjectFileData(**self.data.spec)
704+
try:
705+
self._spec = InfrahubObjectFileData(**self.data.spec)
706+
except Exception as exc:
707+
raise ValidationError(identifier=str(self.location), message=str(exc))
637708
return self._spec
638709

639710
def validate_content(self) -> None:
640711
super().validate_content()
641712
if self.kind != InfrahubFileKind.OBJECT:
642713
raise ValueError("File is not an Infrahub Object file")
643-
self._spec = InfrahubObjectFileData(**self.data.spec)
714+
try:
715+
self._spec = InfrahubObjectFileData(**self.data.spec)
716+
except Exception as exc:
717+
raise ValidationError(identifier=str(self.location), message=str(exc))
644718

645719
async def validate_format(self, client: InfrahubClient, branch: str | None = None) -> None:
646720
self.validate_content()

infrahub_sdk/spec/range_expansion.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import itertools
22
import re
33

4-
MATCH_PATTERN = r"(\[[\w,-]+\])"
4+
MATCH_PATTERN = r"(\[[\w,-]*[-,][\w,-]*\])"
55

66

77
def _escape_brackets(s: str) -> str:

tests/unit/sdk/spec/test_object.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66

77
from infrahub_sdk.exceptions import ValidationError
8-
from infrahub_sdk.spec.object import ObjectFile, RelationshipDataFormat, get_relationship_info
8+
from infrahub_sdk.spec.object import ObjectFile, ObjectStrategy, RelationshipDataFormat, get_relationship_info
99

1010
if TYPE_CHECKING:
1111
from pytest_httpx import HTTPXMock
@@ -40,6 +40,7 @@ def location_bad_syntax02(root_location: dict) -> dict:
4040
data = [{"name": "Mexico", "notvalidattribute": "notvalidattribute", "type": "Country"}]
4141
location = root_location.copy()
4242
location["spec"]["data"] = data
43+
location["spec"]["strategy"] = ObjectStrategy.RANGE_EXPAND
4344
return location
4445

4546

@@ -53,6 +54,21 @@ def location_expansion(root_location: dict) -> dict:
5354
]
5455
location = root_location.copy()
5556
location["spec"]["data"] = data
57+
location["spec"]["strategy"] = ObjectStrategy.RANGE_EXPAND
58+
return location
59+
60+
61+
@pytest.fixture
62+
def no_location_expansion(root_location: dict) -> dict:
63+
data = [
64+
{
65+
"name": "AMS[1-5]",
66+
"type": "Country",
67+
}
68+
]
69+
location = root_location.copy()
70+
location["spec"]["data"] = data
71+
location["spec"]["strategy"] = ObjectStrategy.NORMAL
5672
return location
5773

5874

@@ -67,6 +83,7 @@ def location_expansion_multiple_ranges(root_location: dict) -> dict:
6783
]
6884
location = root_location.copy()
6985
location["spec"]["data"] = data
86+
location["spec"]["strategy"] = ObjectStrategy.RANGE_EXPAND
7087
return location
7188

7289

@@ -81,6 +98,7 @@ def location_expansion_multiple_ranges_bad_syntax(root_location: dict) -> dict:
8198
]
8299
location = root_location.copy()
83100
location["spec"]["data"] = data
101+
location["spec"]["strategy"] = ObjectStrategy.RANGE_EXPAND
84102
return location
85103

86104

@@ -123,6 +141,17 @@ async def test_validate_object_expansion(
123141
assert obj.spec.data[4]["name"] == "AMS5"
124142

125143

144+
async def test_validate_no_object_expansion(
145+
client: InfrahubClient, mock_schema_query_01: HTTPXMock, no_location_expansion
146+
) -> None:
147+
obj = ObjectFile(location="some/path", content=no_location_expansion)
148+
await obj.validate_format(client=client)
149+
assert obj.spec.kind == "BuiltinLocation"
150+
assert obj.spec.strategy == ObjectStrategy.NORMAL
151+
assert len(obj.spec.data) == 1
152+
assert obj.spec.data[0]["name"] == "AMS[1-5]"
153+
154+
126155
async def test_validate_object_expansion_multiple_ranges(
127156
client: InfrahubClient, mock_schema_query_01: HTTPXMock, location_expansion_multiple_ranges
128157
) -> None:
@@ -199,3 +228,30 @@ async def test_get_relationship_info_tags(
199228
rel_info = await get_relationship_info(client, location_schema, "tags", data)
200229
assert rel_info.is_valid == is_valid
201230
assert rel_info.format == format
231+
232+
233+
async def test_invalid_object_expansion_processor(
234+
client: InfrahubClient, mock_schema_query_01: HTTPXMock, location_expansion
235+
) -> None:
236+
obj = ObjectFile(location="some/path", content=location_expansion)
237+
238+
from infrahub_sdk.spec.object import DataProcessorFactory, ObjectStrategy # noqa: PLC0415
239+
240+
# Patch _processors to remove the invalid strategy
241+
original_processors = DataProcessorFactory._processors.copy()
242+
try:
243+
DataProcessorFactory._processors[ObjectStrategy.RANGE_EXPAND] = None
244+
with pytest.raises(ValueError) as exc:
245+
await obj.validate_format(client=client)
246+
assert "Unknown strategy" in str(exc.value)
247+
finally:
248+
DataProcessorFactory._processors = original_processors
249+
250+
251+
async def test_invalid_object_expansion_strategy(client: InfrahubClient, location_expansion) -> None:
252+
location_expansion["spec"]["strategy"] = "InvalidStrategy"
253+
obj = ObjectFile(location="some/path", content=location_expansion)
254+
255+
with pytest.raises(ValidationError) as exc:
256+
await obj.validate_format(client=client)
257+
assert "Input should be" in str(exc.value)

0 commit comments

Comments
 (0)