Skip to content

Commit a346243

Browse files
committed
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.
1 parent 2c16773 commit a346243

File tree

5 files changed

+139
-16
lines changed

5 files changed

+139
-16
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: 75 additions & 8 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,67 @@ 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(f"Unknown strategy: {strategy}")
242+
return processor_class()
243+
244+
@classmethod
245+
def register_processor(cls, strategy: ObjectStrategy, processor_class: type[DataProcessor]) -> None:
246+
"""Register a new processor for a strategy - useful for future extensions"""
247+
cls._processors[strategy] = processor_class
248+
249+
201250
class InfrahubObjectFileData(BaseModel):
202251
kind: str
252+
strategy: ObjectStrategy = ObjectStrategy.NORMAL
203253
data: list[dict[str, Any]] = Field(default_factory=list)
204254

255+
def _get_processed_data(self, data: list[dict[str, Any]]) -> list[dict[str, Any]]:
256+
"""Get data processed according to the strategy"""
257+
processor = DataProcessorFactory.get_processor(self.strategy)
258+
return processor.process_data(data)
259+
205260
async def validate_format(self, client: InfrahubClient, branch: str | None = None) -> list[ObjectValidationError]:
206261
errors: list[ObjectValidationError] = []
207262
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):
263+
264+
processed_data = self._get_processed_data(data=self.data)
265+
self.data = processed_data
266+
267+
for idx, item in enumerate(processed_data):
211268
errors.extend(
212269
await self.validate_object(
213270
client=client,
@@ -216,21 +273,24 @@ async def validate_format(self, client: InfrahubClient, branch: str | None = Non
216273
data=item,
217274
branch=branch,
218275
default_schema_kind=self.kind,
276+
strategy=self.strategy, # Pass strategy down
219277
)
220278
)
221279
return errors
222280

223281
async def process(self, client: InfrahubClient, branch: str | None = None) -> None:
224282
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):
283+
processed_data = self._get_processed_data(data=self.data)
284+
285+
for idx, item in enumerate(processed_data):
227286
await self.create_node(
228287
client=client,
229288
schema=schema,
230289
data=item,
231290
position=[idx + 1],
232291
branch=branch,
233292
default_schema_kind=self.kind,
293+
strategy=self.strategy,
234294
)
235295

236296
@classmethod
@@ -243,6 +303,7 @@ async def validate_object(
243303
context: dict | None = None,
244304
branch: str | None = None,
245305
default_schema_kind: str | None = None,
306+
strategy: ObjectStrategy = ObjectStrategy.NORMAL,
246307
) -> list[ObjectValidationError]:
247308
errors: list[ObjectValidationError] = []
248309
context = context.copy() if context else {}
@@ -292,6 +353,7 @@ async def validate_object(
292353
context=context,
293354
branch=branch,
294355
default_schema_kind=default_schema_kind,
356+
strategy=strategy,
295357
)
296358
)
297359

@@ -307,6 +369,7 @@ async def validate_related_nodes(
307369
context: dict | None = None,
308370
branch: str | None = None,
309371
default_schema_kind: str | None = None,
372+
strategy: ObjectStrategy = ObjectStrategy.NORMAL,
310373
) -> list[ObjectValidationError]:
311374
context = context.copy() if context else {}
312375
errors: list[ObjectValidationError] = []
@@ -348,7 +411,10 @@ async def validate_related_nodes(
348411
rel_info.find_matching_relationship(peer_schema=peer_schema)
349412
context.update(rel_info.get_context(value="placeholder"))
350413

351-
expanded_data = expand_data_with_ranges(data=data["data"])
414+
# Use strategy-aware data processing
415+
processor = DataProcessorFactory.get_processor(strategy)
416+
expanded_data = processor.process_data(data["data"])
417+
352418
for idx, peer_data in enumerate(expanded_data):
353419
context["list_index"] = idx
354420
errors.extend(
@@ -360,6 +426,7 @@ async def validate_related_nodes(
360426
context=context,
361427
branch=branch,
362428
default_schema_kind=default_schema_kind,
429+
strategy=strategy,
363430
)
364431
)
365432
return errors

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: 30 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:

tests/unit/sdk/test_range_expansion.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def test_mixed_range_expansion() -> None:
6060

6161

6262
def test_single_value_in_brackets() -> None:
63-
assert range_expansion("Device[5]") == ["Device5"]
63+
assert range_expansion("Device[5]") == ["Device[5]"]
6464

6565

6666
def test_empty_brackets() -> None:
@@ -82,10 +82,6 @@ def test_duplicate_and_overlapping_values() -> None:
8282
assert range_expansion("Device[1,1,2]") == ["Device1", "Device1", "Device2"]
8383

8484

85-
def test_whitespace_handling() -> None:
86-
assert range_expansion("Device[ 1 - 3 ]") == ["Device[ 1 - 3 ]"]
87-
88-
8985
def test_descending_ranges() -> None:
9086
assert range_expansion("Device[3-1]") == ["Device3", "Device2", "Device1"]
9187

@@ -104,3 +100,7 @@ def test_unicode_ranges() -> None:
104100

105101
def test_brackets_in_strings() -> None:
106102
assert range_expansion(r"Service Object [Circuit Provider, X]") == ["Service Object [Circuit Provider, X]"]
103+
104+
105+
def test_words_in_brackets() -> None:
106+
assert range_expansion("Device[expansion]") == ["Device[expansion]"]

0 commit comments

Comments
 (0)