infrahub-server 1.5.0b0__py3-none-any.whl → 1.5.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. infrahub/actions/tasks.py +8 -0
  2. infrahub/api/diff/diff.py +1 -1
  3. infrahub/cli/db.py +24 -0
  4. infrahub/cli/db_commands/clean_duplicate_schema_fields.py +212 -0
  5. infrahub/core/attribute.py +3 -3
  6. infrahub/core/branch/tasks.py +2 -1
  7. infrahub/core/changelog/models.py +4 -12
  8. infrahub/core/constants/infrahubkind.py +1 -0
  9. infrahub/core/diff/model/path.py +4 -0
  10. infrahub/core/diff/payload_builder.py +1 -1
  11. infrahub/core/graph/__init__.py +1 -1
  12. infrahub/core/ipam/utilization.py +1 -1
  13. infrahub/core/manager.py +6 -3
  14. infrahub/core/migrations/graph/__init__.py +4 -0
  15. infrahub/core/migrations/graph/m041_create_hfid_display_label_in_db.py +97 -0
  16. infrahub/core/migrations/graph/m042_backfill_hfid_display_label_in_db.py +86 -0
  17. infrahub/core/migrations/schema/node_attribute_add.py +5 -2
  18. infrahub/core/migrations/shared.py +5 -6
  19. infrahub/core/node/__init__.py +142 -40
  20. infrahub/core/node/constraints/attribute_uniqueness.py +3 -1
  21. infrahub/core/node/node_property_attribute.py +230 -0
  22. infrahub/core/node/standard.py +1 -1
  23. infrahub/core/protocols.py +7 -1
  24. infrahub/core/query/node.py +14 -1
  25. infrahub/core/registry.py +2 -2
  26. infrahub/core/relationship/constraints/count.py +1 -1
  27. infrahub/core/relationship/model.py +1 -1
  28. infrahub/core/schema/basenode_schema.py +42 -2
  29. infrahub/core/schema/definitions/core/__init__.py +2 -0
  30. infrahub/core/schema/definitions/core/generator.py +2 -0
  31. infrahub/core/schema/definitions/core/group.py +16 -2
  32. infrahub/core/schema/definitions/internal.py +14 -1
  33. infrahub/core/schema/generated/base_node_schema.py +6 -1
  34. infrahub/core/schema/node_schema.py +5 -2
  35. infrahub/core/schema/schema_branch.py +134 -0
  36. infrahub/core/schema/schema_branch_display.py +123 -0
  37. infrahub/core/schema/schema_branch_hfid.py +114 -0
  38. infrahub/core/validators/aggregated_checker.py +1 -1
  39. infrahub/core/validators/determiner.py +12 -1
  40. infrahub/core/validators/relationship/peer.py +1 -1
  41. infrahub/core/validators/tasks.py +1 -1
  42. infrahub/display_labels/__init__.py +0 -0
  43. infrahub/display_labels/gather.py +48 -0
  44. infrahub/display_labels/models.py +240 -0
  45. infrahub/display_labels/tasks.py +186 -0
  46. infrahub/display_labels/triggers.py +22 -0
  47. infrahub/events/group_action.py +1 -1
  48. infrahub/events/node_action.py +1 -1
  49. infrahub/generators/constants.py +7 -0
  50. infrahub/generators/models.py +7 -0
  51. infrahub/generators/tasks.py +31 -15
  52. infrahub/git/integrator.py +22 -14
  53. infrahub/graphql/analyzer.py +1 -1
  54. infrahub/graphql/mutations/display_label.py +111 -0
  55. infrahub/graphql/mutations/generator.py +25 -7
  56. infrahub/graphql/mutations/hfid.py +118 -0
  57. infrahub/graphql/mutations/relationship.py +2 -2
  58. infrahub/graphql/mutations/resource_manager.py +2 -2
  59. infrahub/graphql/mutations/schema.py +5 -5
  60. infrahub/graphql/queries/resource_manager.py +1 -1
  61. infrahub/graphql/resolvers/resolver.py +2 -0
  62. infrahub/graphql/schema.py +4 -0
  63. infrahub/groups/tasks.py +1 -1
  64. infrahub/hfid/__init__.py +0 -0
  65. infrahub/hfid/gather.py +48 -0
  66. infrahub/hfid/models.py +240 -0
  67. infrahub/hfid/tasks.py +185 -0
  68. infrahub/hfid/triggers.py +22 -0
  69. infrahub/lock.py +15 -4
  70. infrahub/middleware.py +26 -1
  71. infrahub/proposed_change/tasks.py +10 -1
  72. infrahub/server.py +16 -3
  73. infrahub/services/__init__.py +8 -5
  74. infrahub/trigger/catalogue.py +4 -0
  75. infrahub/trigger/models.py +2 -0
  76. infrahub/trigger/tasks.py +3 -0
  77. infrahub/workflows/catalogue.py +72 -0
  78. infrahub/workflows/initialization.py +16 -0
  79. infrahub_sdk/checks.py +1 -1
  80. infrahub_sdk/ctl/cli_commands.py +2 -0
  81. infrahub_sdk/ctl/generator.py +4 -0
  82. infrahub_sdk/ctl/graphql.py +184 -0
  83. infrahub_sdk/ctl/schema.py +6 -2
  84. infrahub_sdk/generator.py +7 -1
  85. infrahub_sdk/graphql/__init__.py +12 -0
  86. infrahub_sdk/graphql/constants.py +1 -0
  87. infrahub_sdk/graphql/plugin.py +85 -0
  88. infrahub_sdk/graphql/query.py +77 -0
  89. infrahub_sdk/{graphql.py → graphql/renderers.py} +81 -73
  90. infrahub_sdk/graphql/utils.py +40 -0
  91. infrahub_sdk/protocols.py +14 -0
  92. infrahub_sdk/schema/__init__.py +38 -0
  93. infrahub_sdk/schema/repository.py +8 -0
  94. infrahub_sdk/spec/object.py +84 -10
  95. infrahub_sdk/spec/range_expansion.py +1 -1
  96. infrahub_sdk/transforms.py +1 -1
  97. {infrahub_server-1.5.0b0.dist-info → infrahub_server-1.5.0b1.dist-info}/METADATA +5 -4
  98. {infrahub_server-1.5.0b0.dist-info → infrahub_server-1.5.0b1.dist-info}/RECORD +104 -79
  99. infrahub_testcontainers/container.py +1 -1
  100. infrahub_testcontainers/docker-compose-cluster.test.yml +1 -1
  101. infrahub_testcontainers/docker-compose.test.yml +1 -1
  102. {infrahub_server-1.5.0b0.dist-info → infrahub_server-1.5.0b1.dist-info}/LICENSE.txt +0 -0
  103. {infrahub_server-1.5.0b0.dist-info → infrahub_server-1.5.0b1.dist-info}/WHEEL +0 -0
  104. {infrahub_server-1.5.0b0.dist-info → infrahub_server-1.5.0b1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .renderers import render_input_block, render_query_block, render_variables_to_string
6
+
7
+
8
+ class BaseGraphQLQuery:
9
+ query_type: str = "not-defined"
10
+ indentation: int = 4
11
+
12
+ def __init__(self, query: dict, variables: dict | None = None, name: str | None = None):
13
+ self.query = query
14
+ self.variables = variables
15
+ self.name = name or ""
16
+
17
+ def render_first_line(self) -> str:
18
+ first_line = self.query_type
19
+
20
+ if self.name:
21
+ first_line += " " + self.name
22
+
23
+ if self.variables:
24
+ first_line += f" ({render_variables_to_string(self.variables)})"
25
+
26
+ first_line += " {"
27
+
28
+ return first_line
29
+
30
+
31
+ class Query(BaseGraphQLQuery):
32
+ query_type = "query"
33
+
34
+ def render(self, convert_enum: bool = False) -> str:
35
+ lines = [self.render_first_line()]
36
+ lines.extend(
37
+ render_query_block(
38
+ data=self.query, indentation=self.indentation, offset=self.indentation, convert_enum=convert_enum
39
+ )
40
+ )
41
+ lines.append("}")
42
+
43
+ return "\n" + "\n".join(lines) + "\n"
44
+
45
+
46
+ class Mutation(BaseGraphQLQuery):
47
+ query_type = "mutation"
48
+
49
+ def __init__(self, *args: Any, mutation: str, input_data: dict, **kwargs: Any):
50
+ self.input_data = input_data
51
+ self.mutation = mutation
52
+ super().__init__(*args, **kwargs)
53
+
54
+ def render(self, convert_enum: bool = False) -> str:
55
+ lines = [self.render_first_line()]
56
+ lines.append(" " * self.indentation + f"{self.mutation}(")
57
+ lines.extend(
58
+ render_input_block(
59
+ data=self.input_data,
60
+ indentation=self.indentation,
61
+ offset=self.indentation * 2,
62
+ convert_enum=convert_enum,
63
+ )
64
+ )
65
+ lines.append(" " * self.indentation + "){")
66
+ lines.extend(
67
+ render_query_block(
68
+ data=self.query,
69
+ indentation=self.indentation,
70
+ offset=self.indentation * 2,
71
+ convert_enum=convert_enum,
72
+ )
73
+ )
74
+ lines.append(" " * self.indentation + "}")
75
+ lines.append("}")
76
+
77
+ return "\n" + "\n".join(lines) + "\n"
@@ -6,10 +6,35 @@ from typing import Any
6
6
 
7
7
  from pydantic import BaseModel
8
8
 
9
- VARIABLE_TYPE_MAPPING = ((str, "String!"), (int, "Int!"), (float, "Float!"), (bool, "Boolean!"))
9
+ from .constants import VARIABLE_TYPE_MAPPING
10
10
 
11
11
 
12
12
  def convert_to_graphql_as_string(value: Any, convert_enum: bool = False) -> str: # noqa: PLR0911
13
+ """Convert a Python value to its GraphQL string representation.
14
+
15
+ This function handles various Python types and converts them to their appropriate
16
+ GraphQL string format, including proper quoting, formatting, and special handling
17
+ for different data types.
18
+
19
+ Args:
20
+ value: The value to convert to GraphQL string format. Can be None, str, bool,
21
+ int, float, Enum, list, BaseModel, or any other type.
22
+ convert_enum: If True, converts Enum values to their underlying value instead
23
+ of their name. Defaults to False.
24
+
25
+ Returns:
26
+ str: The GraphQL string representation of the value.
27
+
28
+ Examples:
29
+ >>> convert_to_graphql_as_string("hello")
30
+ '"hello"'
31
+ >>> convert_to_graphql_as_string(True)
32
+ 'true'
33
+ >>> convert_to_graphql_as_string([1, 2, 3])
34
+ '[1, 2, 3]'
35
+ >>> convert_to_graphql_as_string(None)
36
+ 'null'
37
+ """
13
38
  if value is None:
14
39
  return "null"
15
40
  if isinstance(value, str) and value.startswith("$"):
@@ -56,6 +81,34 @@ def render_variables_to_string(data: dict[str, type[str | int | float | bool]])
56
81
 
57
82
 
58
83
  def render_query_block(data: dict, offset: int = 4, indentation: int = 4, convert_enum: bool = False) -> list[str]:
84
+ """Render a dictionary structure as a GraphQL query block with proper formatting.
85
+
86
+ This function recursively processes a dictionary to generate GraphQL query syntax
87
+ with proper indentation, handling of aliases, filters, and nested structures.
88
+ Special keys like "@filters" and "@alias" are processed for GraphQL-specific
89
+ formatting.
90
+
91
+ Args:
92
+ data: Dictionary representing the GraphQL query structure. Can contain
93
+ nested dictionaries, special keys like "@filters" and "@alias", and
94
+ various value types.
95
+ offset: Number of spaces to use for initial indentation. Defaults to 4.
96
+ indentation: Number of spaces to add for each nesting level. Defaults to 4.
97
+ convert_enum: If True, converts Enum values to their underlying value.
98
+ Defaults to False.
99
+
100
+ Returns:
101
+ list[str]: List of formatted lines representing the GraphQL query block.
102
+
103
+ Examples:
104
+ >>> data = {"user": {"name": None, "email": None}}
105
+ >>> render_query_block(data)
106
+ [' user {', ' name', ' email', ' }']
107
+
108
+ >>> data = {"user": {"@alias": "u", "@filters": {"id": 123}, "name": None}}
109
+ >>> render_query_block(data)
110
+ [' u: user(id: 123) {', ' name', ' }']
111
+ """
59
112
  FILTERS_KEY = "@filters"
60
113
  ALIAS_KEY = "@alias"
61
114
  KEYWORDS_TO_SKIP = [FILTERS_KEY, ALIAS_KEY]
@@ -97,6 +150,33 @@ def render_query_block(data: dict, offset: int = 4, indentation: int = 4, conver
97
150
 
98
151
 
99
152
  def render_input_block(data: dict, offset: int = 4, indentation: int = 4, convert_enum: bool = False) -> list[str]:
153
+ """Render a dictionary structure as a GraphQL input block with proper formatting.
154
+
155
+ This function recursively processes a dictionary to generate GraphQL input syntax
156
+ with proper indentation, handling nested objects, arrays, and various data types.
157
+ Unlike query blocks, input blocks don't handle special keys like "@filters" or
158
+ "@alias" and focus on data structure representation.
159
+
160
+ Args:
161
+ data: Dictionary representing the GraphQL input structure. Can contain
162
+ nested dictionaries, lists, and various value types.
163
+ offset: Number of spaces to use for initial indentation. Defaults to 4.
164
+ indentation: Number of spaces to add for each nesting level. Defaults to 4.
165
+ convert_enum: If True, converts Enum values to their underlying value.
166
+ Defaults to False.
167
+
168
+ Returns:
169
+ list[str]: List of formatted lines representing the GraphQL input block.
170
+
171
+ Examples:
172
+ >>> data = {"name": "John", "age": 30}
173
+ >>> render_input_block(data)
174
+ [' name: "John"', ' age: 30']
175
+
176
+ >>> data = {"user": {"name": "John", "hobbies": ["reading", "coding"]}}
177
+ >>> render_input_block(data)
178
+ [' user: {', ' name: "John"', ' hobbies: [', ' "reading",', ' "coding",', ' ]', ' }']
179
+ """
100
180
  offset_str = " " * offset
101
181
  lines = []
102
182
  for key, value in data.items():
@@ -130,75 +210,3 @@ def render_input_block(data: dict, offset: int = 4, indentation: int = 4, conver
130
210
  else:
131
211
  lines.append(f"{offset_str}{key}: {convert_to_graphql_as_string(value=value, convert_enum=convert_enum)}")
132
212
  return lines
133
-
134
-
135
- class BaseGraphQLQuery:
136
- query_type: str = "not-defined"
137
- indentation: int = 4
138
-
139
- def __init__(self, query: dict, variables: dict | None = None, name: str | None = None):
140
- self.query = query
141
- self.variables = variables
142
- self.name = name or ""
143
-
144
- def render_first_line(self) -> str:
145
- first_line = self.query_type
146
-
147
- if self.name:
148
- first_line += " " + self.name
149
-
150
- if self.variables:
151
- first_line += f" ({render_variables_to_string(self.variables)})"
152
-
153
- first_line += " {"
154
-
155
- return first_line
156
-
157
-
158
- class Query(BaseGraphQLQuery):
159
- query_type = "query"
160
-
161
- def render(self, convert_enum: bool = False) -> str:
162
- lines = [self.render_first_line()]
163
- lines.extend(
164
- render_query_block(
165
- data=self.query, indentation=self.indentation, offset=self.indentation, convert_enum=convert_enum
166
- )
167
- )
168
- lines.append("}")
169
-
170
- return "\n" + "\n".join(lines) + "\n"
171
-
172
-
173
- class Mutation(BaseGraphQLQuery):
174
- query_type = "mutation"
175
-
176
- def __init__(self, *args: Any, mutation: str, input_data: dict, **kwargs: Any):
177
- self.input_data = input_data
178
- self.mutation = mutation
179
- super().__init__(*args, **kwargs)
180
-
181
- def render(self, convert_enum: bool = False) -> str:
182
- lines = [self.render_first_line()]
183
- lines.append(" " * self.indentation + f"{self.mutation}(")
184
- lines.extend(
185
- render_input_block(
186
- data=self.input_data,
187
- indentation=self.indentation,
188
- offset=self.indentation * 2,
189
- convert_enum=convert_enum,
190
- )
191
- )
192
- lines.append(" " * self.indentation + "){")
193
- lines.extend(
194
- render_query_block(
195
- data=self.query,
196
- indentation=self.indentation,
197
- offset=self.indentation * 2,
198
- convert_enum=convert_enum,
199
- )
200
- )
201
- lines.append(" " * self.indentation + "}")
202
- lines.append("}")
203
-
204
- return "\n" + "\n".join(lines) + "\n"
@@ -0,0 +1,40 @@
1
+ import ast
2
+
3
+
4
+ def get_class_def_index(module: ast.Module) -> int:
5
+ """Get the index of the first class definition in the module.
6
+ It's useful to insert other classes before the first class definition."""
7
+ for idx, item in enumerate(module.body):
8
+ if isinstance(item, ast.ClassDef):
9
+ return idx
10
+ return -1
11
+
12
+
13
+ def insert_fragments_inline(module: ast.Module, fragment: ast.Module) -> ast.Module:
14
+ """Insert the Pydantic classes for the fragments inline into the module.
15
+
16
+ If no class definitions exist in module, fragments are appended to the end.
17
+ """
18
+ module_class_def_index = get_class_def_index(module)
19
+
20
+ fragment_classes: list[ast.ClassDef] = [item for item in fragment.body if isinstance(item, ast.ClassDef)]
21
+
22
+ # Handle edge case when no class definitions exist
23
+ if module_class_def_index == -1:
24
+ # Append fragments to the end of the module
25
+ module.body.extend(fragment_classes)
26
+ else:
27
+ # Insert fragments before the first class definition
28
+ for idx, item in enumerate(fragment_classes):
29
+ module.body.insert(module_class_def_index + idx, item)
30
+
31
+ return module
32
+
33
+
34
+ def remove_fragment_import(module: ast.Module) -> ast.Module:
35
+ """Remove the fragment import from the module."""
36
+ for item in module.body:
37
+ if isinstance(item, ast.ImportFrom) and item.module == "fragments":
38
+ module.body.remove(item)
39
+ return module
40
+ return module
infrahub_sdk/protocols.py CHANGED
@@ -131,6 +131,7 @@ class CoreGenericRepository(CoreNode):
131
131
  queries: RelationshipManager
132
132
  checks: RelationshipManager
133
133
  generators: RelationshipManager
134
+ groups_objects: RelationshipManager
134
135
 
135
136
 
136
137
  class CoreGroup(CoreNode):
@@ -355,6 +356,10 @@ class CoreGeneratorAction(CoreAction):
355
356
  generator: RelatedNode
356
357
 
357
358
 
359
+ class CoreGeneratorAwareGroup(CoreGroup):
360
+ pass
361
+
362
+
358
363
  class CoreGeneratorCheck(CoreCheck):
359
364
  instance: String
360
365
 
@@ -366,6 +371,8 @@ class CoreGeneratorDefinition(CoreTaskTarget):
366
371
  file_path: String
367
372
  class_name: String
368
373
  convert_query_response: BooleanOptional
374
+ execute_in_proposed_change: BooleanOptional
375
+ execute_after_merge: BooleanOptional
369
376
  query: RelatedNode
370
377
  repository: RelatedNode
371
378
  targets: RelatedNode
@@ -681,6 +688,7 @@ class CoreGenericRepositorySync(CoreNodeSync):
681
688
  queries: RelationshipManagerSync
682
689
  checks: RelationshipManagerSync
683
690
  generators: RelationshipManagerSync
691
+ groups_objects: RelationshipManagerSync
684
692
 
685
693
 
686
694
  class CoreGroupSync(CoreNodeSync):
@@ -905,6 +913,10 @@ class CoreGeneratorActionSync(CoreActionSync):
905
913
  generator: RelatedNodeSync
906
914
 
907
915
 
916
+ class CoreGeneratorAwareGroupSync(CoreGroupSync):
917
+ pass
918
+
919
+
908
920
  class CoreGeneratorCheckSync(CoreCheckSync):
909
921
  instance: String
910
922
 
@@ -916,6 +928,8 @@ class CoreGeneratorDefinitionSync(CoreTaskTargetSync):
916
928
  file_path: String
917
929
  class_name: String
918
930
  convert_query_response: BooleanOptional
931
+ execute_in_proposed_change: BooleanOptional
932
+ execute_after_merge: BooleanOptional
919
933
  query: RelatedNodeSync
920
934
  repository: RelatedNodeSync
921
935
  targets: RelatedNodeSync
@@ -474,6 +474,25 @@ class InfrahubSchema(InfrahubSchemaBase):
474
474
 
475
475
  return branch_schema.nodes
476
476
 
477
+ async def get_graphql_schema(self, branch: str | None = None) -> str:
478
+ """Get the GraphQL schema as a string.
479
+
480
+ Args:
481
+ branch: The branch to get the schema for. Defaults to default_branch.
482
+
483
+ Returns:
484
+ The GraphQL schema as a string.
485
+ """
486
+ branch = branch or self.client.default_branch
487
+ url = f"{self.client.address}/schema.graphql?branch={branch}"
488
+
489
+ response = await self.client._get(url=url)
490
+
491
+ if response.status_code != 200:
492
+ raise ValueError(f"Failed to fetch GraphQL schema: HTTP {response.status_code} - {response.text}")
493
+
494
+ return response.text
495
+
477
496
  async def _fetch(self, branch: str, namespaces: list[str] | None = None) -> BranchSchema:
478
497
  url_parts = [("branch", branch)]
479
498
  if namespaces:
@@ -697,6 +716,25 @@ class InfrahubSchemaSync(InfrahubSchemaBase):
697
716
 
698
717
  return branch_schema.nodes
699
718
 
719
+ def get_graphql_schema(self, branch: str | None = None) -> str:
720
+ """Get the GraphQL schema as a string.
721
+
722
+ Args:
723
+ branch: The branch to get the schema for. Defaults to default_branch.
724
+
725
+ Returns:
726
+ The GraphQL schema as a string.
727
+ """
728
+ branch = branch or self.client.default_branch
729
+ url = f"{self.client.address}/schema.graphql?branch={branch}"
730
+
731
+ response = self.client._get(url=url)
732
+
733
+ if response.status_code != 200:
734
+ raise ValueError(f"Failed to fetch GraphQL schema: HTTP {response.status_code} - {response.text}")
735
+
736
+ return response.text
737
+
700
738
  def _fetch(self, branch: str, namespaces: list[str] | None = None) -> BranchSchema:
701
739
  url_parts = [("branch", branch)]
702
740
  if namespaces:
@@ -96,6 +96,14 @@ class InfrahubGeneratorDefinitionConfig(InfrahubRepositoryConfigElement):
96
96
  default=False,
97
97
  description="Decide if the generator should convert the result of the GraphQL query to SDK InfrahubNode objects.",
98
98
  )
99
+ execute_in_proposed_change: bool = Field(
100
+ default=True,
101
+ description="Decide if the generator should execute in a proposed change.",
102
+ )
103
+ execute_after_merge: bool = Field(
104
+ default=True,
105
+ description="Decide if the generator should execute after a merge.",
106
+ )
99
107
 
100
108
  def load_class(self, import_root: str | None = None, relative_path: str | None = None) -> type[InfrahubGenerator]:
101
109
  module = import_module(module_path=self.file_path, import_root=import_root, relative_path=relative_path)
@@ -2,8 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import copy
4
4
  import re
5
+ from abc import ABC, abstractmethod
5
6
  from enum import Enum
6
- from typing import TYPE_CHECKING, Any
7
+ from typing import TYPE_CHECKING, Any, ClassVar
7
8
 
8
9
  from pydantic import BaseModel, Field
9
10
 
@@ -45,6 +46,11 @@ class RelationshipDataFormat(str, Enum):
45
46
  MANY_REF = "many_ref_list"
46
47
 
47
48
 
49
+ class ObjectStrategy(str, Enum):
50
+ NORMAL = "normal"
51
+ RANGE_EXPAND = "range_expand"
52
+
53
+
48
54
  class RelationshipInfo(BaseModel):
49
55
  name: str
50
56
  rel_schema: RelationshipSchema
@@ -168,7 +174,7 @@ async def get_relationship_info(
168
174
 
169
175
 
170
176
  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."""
172
178
  range_pattern = re.compile(MATCH_PATTERN)
173
179
  expanded = []
174
180
  for item in data:
@@ -198,16 +204,69 @@ def expand_data_with_ranges(data: list[dict[str, Any]]) -> list[dict[str, Any]]:
198
204
  return expanded
199
205
 
200
206
 
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
+
201
252
  class InfrahubObjectFileData(BaseModel):
202
253
  kind: str
254
+ strategy: ObjectStrategy = ObjectStrategy.NORMAL
203
255
  data: list[dict[str, Any]] = Field(default_factory=list)
204
256
 
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
+
205
262
  async def validate_format(self, client: InfrahubClient, branch: str | None = None) -> list[ObjectValidationError]:
206
263
  errors: list[ObjectValidationError] = []
207
264
  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):
211
270
  errors.extend(
212
271
  await self.validate_object(
213
272
  client=client,
@@ -216,14 +275,16 @@ class InfrahubObjectFileData(BaseModel):
216
275
  data=item,
217
276
  branch=branch,
218
277
  default_schema_kind=self.kind,
278
+ strategy=self.strategy, # Pass strategy down
219
279
  )
220
280
  )
221
281
  return errors
222
282
 
223
283
  async def process(self, client: InfrahubClient, branch: str | None = None) -> None:
224
284
  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):
227
288
  await self.create_node(
228
289
  client=client,
229
290
  schema=schema,
@@ -243,6 +304,7 @@ class InfrahubObjectFileData(BaseModel):
243
304
  context: dict | None = None,
244
305
  branch: str | None = None,
245
306
  default_schema_kind: str | None = None,
307
+ strategy: ObjectStrategy = ObjectStrategy.NORMAL,
246
308
  ) -> list[ObjectValidationError]:
247
309
  errors: list[ObjectValidationError] = []
248
310
  context = context.copy() if context else {}
@@ -292,6 +354,7 @@ class InfrahubObjectFileData(BaseModel):
292
354
  context=context,
293
355
  branch=branch,
294
356
  default_schema_kind=default_schema_kind,
357
+ strategy=strategy,
295
358
  )
296
359
  )
297
360
 
@@ -307,6 +370,7 @@ class InfrahubObjectFileData(BaseModel):
307
370
  context: dict | None = None,
308
371
  branch: str | None = None,
309
372
  default_schema_kind: str | None = None,
373
+ strategy: ObjectStrategy = ObjectStrategy.NORMAL,
310
374
  ) -> list[ObjectValidationError]:
311
375
  context = context.copy() if context else {}
312
376
  errors: list[ObjectValidationError] = []
@@ -348,7 +412,10 @@ class InfrahubObjectFileData(BaseModel):
348
412
  rel_info.find_matching_relationship(peer_schema=peer_schema)
349
413
  context.update(rel_info.get_context(value="placeholder"))
350
414
 
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
+
352
419
  for idx, peer_data in enumerate(expanded_data):
353
420
  context["list_index"] = idx
354
421
  errors.extend(
@@ -360,6 +427,7 @@ class InfrahubObjectFileData(BaseModel):
360
427
  context=context,
361
428
  branch=branch,
362
429
  default_schema_kind=default_schema_kind,
430
+ strategy=strategy,
363
431
  )
364
432
  )
365
433
  return errors
@@ -633,14 +701,20 @@ class ObjectFile(InfrahubFile):
633
701
  @property
634
702
  def spec(self) -> InfrahubObjectFileData:
635
703
  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))
637
708
  return self._spec
638
709
 
639
710
  def validate_content(self) -> None:
640
711
  super().validate_content()
641
712
  if self.kind != InfrahubFileKind.OBJECT:
642
713
  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))
644
718
 
645
719
  async def validate_format(self, client: InfrahubClient, branch: str | None = None) -> None:
646
720
  self.validate_content()
@@ -1,7 +1,7 @@
1
1
  import itertools
2
2
  import re
3
3
 
4
- MATCH_PATTERN = r"(\[[\w,-]+\])"
4
+ MATCH_PATTERN = r"(\[[\w,-]*[-,][\w,-]*\])"
5
5
 
6
6
 
7
7
  def _escape_brackets(s: str) -> str:
@@ -17,7 +17,7 @@ INFRAHUB_TRANSFORM_VARIABLE_TO_IMPORT = "INFRAHUB_TRANSFORMS"
17
17
  class InfrahubTransform(InfrahubOperation):
18
18
  name: str | None = None
19
19
  query: str
20
- timeout: int = 10
20
+ timeout: int = 60
21
21
 
22
22
  def __init__(
23
23
  self,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: infrahub-server
3
- Version: 1.5.0b0
3
+ Version: 1.5.0b1
4
4
  Summary: Infrahub is taking a new approach to Infrastructure Management by providing a new generation of datastore to organize and control all the data that defines how an infrastructure should run.
5
5
  License: Apache-2.0
6
6
  Author: OpsMill
@@ -13,8 +13,9 @@ Classifier: Programming Language :: Python :: 3.12
13
13
  Requires-Dist: Jinja2 (>=3,<4)
14
14
  Requires-Dist: aio-pika (>=9.4,<9.5)
15
15
  Requires-Dist: aiodataloader (==0.4.0)
16
+ Requires-Dist: ariadne-codegen (==0.15.3)
16
17
  Requires-Dist: asgi-correlation-id (==4.2.0)
17
- Requires-Dist: authlib (==1.3.2)
18
+ Requires-Dist: authlib (==1.6.5)
18
19
  Requires-Dist: bcrypt (>=4.1,<4.2)
19
20
  Requires-Dist: boto3 (==1.34.129)
20
21
  Requires-Dist: copier (>=9.8.0,<10.0.0)
@@ -37,8 +38,8 @@ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc (==1.28.1)
37
38
  Requires-Dist: opentelemetry-exporter-otlp-proto-http (==1.28.1)
38
39
  Requires-Dist: opentelemetry-instrumentation-aio-pika (==0.49b1)
39
40
  Requires-Dist: opentelemetry-instrumentation-fastapi (==0.49b1)
40
- Requires-Dist: prefect (==3.4.22)
41
- Requires-Dist: prefect-redis (==0.2.4)
41
+ Requires-Dist: prefect (==3.4.23)
42
+ Requires-Dist: prefect-redis (==0.2.5)
42
43
  Requires-Dist: pyarrow (>=14)
43
44
  Requires-Dist: pydantic (>=2.10,<2.11)
44
45
  Requires-Dist: pydantic-settings (>=2.8,<2.9)