infrahub-server 1.2.11__py3-none-any.whl → 1.3.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 (147) hide show
  1. infrahub/actions/constants.py +86 -0
  2. infrahub/actions/gather.py +114 -0
  3. infrahub/actions/models.py +241 -0
  4. infrahub/actions/parsers.py +104 -0
  5. infrahub/actions/schema.py +382 -0
  6. infrahub/actions/tasks.py +126 -0
  7. infrahub/actions/triggers.py +21 -0
  8. infrahub/cli/db.py +1 -2
  9. infrahub/core/account.py +24 -47
  10. infrahub/core/attribute.py +13 -15
  11. infrahub/core/constants/__init__.py +5 -0
  12. infrahub/core/constants/infrahubkind.py +9 -0
  13. infrahub/core/convert_object_type/__init__.py +0 -0
  14. infrahub/core/convert_object_type/conversion.py +122 -0
  15. infrahub/core/convert_object_type/schema_mapping.py +56 -0
  16. infrahub/core/diff/query/all_conflicts.py +1 -5
  17. infrahub/core/diff/query/artifact.py +10 -20
  18. infrahub/core/diff/query/diff_get.py +3 -6
  19. infrahub/core/diff/query/field_summary.py +2 -4
  20. infrahub/core/diff/query/merge.py +70 -123
  21. infrahub/core/diff/query/save.py +20 -32
  22. infrahub/core/diff/query/summary_counts_enricher.py +34 -54
  23. infrahub/core/manager.py +14 -11
  24. infrahub/core/migrations/graph/m003_relationship_parent_optional.py +1 -2
  25. infrahub/core/migrations/graph/m013_convert_git_password_credential.py +2 -4
  26. infrahub/core/migrations/graph/m019_restore_rels_to_time.py +11 -22
  27. infrahub/core/migrations/graph/m020_duplicate_edges.py +3 -6
  28. infrahub/core/migrations/graph/m021_missing_hierarchy_merge.py +1 -2
  29. infrahub/core/migrations/graph/m024_missing_hierarchy_backfill.py +1 -2
  30. infrahub/core/migrations/query/attribute_add.py +1 -2
  31. infrahub/core/migrations/query/attribute_rename.py +5 -10
  32. infrahub/core/migrations/query/delete_element_in_schema.py +19 -17
  33. infrahub/core/migrations/query/node_duplicate.py +19 -21
  34. infrahub/core/migrations/query/relationship_duplicate.py +19 -17
  35. infrahub/core/migrations/schema/node_attribute_remove.py +4 -8
  36. infrahub/core/migrations/schema/node_remove.py +19 -19
  37. infrahub/core/models.py +29 -2
  38. infrahub/core/node/__init__.py +90 -18
  39. infrahub/core/node/create.py +211 -0
  40. infrahub/core/node/resource_manager/number_pool.py +31 -5
  41. infrahub/core/node/standard.py +6 -1
  42. infrahub/core/protocols.py +56 -0
  43. infrahub/core/protocols_base.py +3 -0
  44. infrahub/core/query/__init__.py +2 -2
  45. infrahub/core/query/diff.py +19 -32
  46. infrahub/core/query/ipam.py +10 -20
  47. infrahub/core/query/node.py +28 -46
  48. infrahub/core/query/relationship.py +53 -32
  49. infrahub/core/query/resource_manager.py +1 -2
  50. infrahub/core/query/subquery.py +2 -4
  51. infrahub/core/relationship/model.py +3 -0
  52. infrahub/core/schema/__init__.py +2 -1
  53. infrahub/core/schema/attribute_parameters.py +160 -0
  54. infrahub/core/schema/attribute_schema.py +111 -8
  55. infrahub/core/schema/basenode_schema.py +25 -1
  56. infrahub/core/schema/definitions/core/__init__.py +29 -1
  57. infrahub/core/schema/definitions/core/group.py +45 -0
  58. infrahub/core/schema/definitions/internal.py +27 -4
  59. infrahub/core/schema/generated/attribute_schema.py +16 -3
  60. infrahub/core/schema/manager.py +3 -0
  61. infrahub/core/schema/schema_branch.py +67 -7
  62. infrahub/core/validators/__init__.py +13 -1
  63. infrahub/core/validators/attribute/choices.py +1 -3
  64. infrahub/core/validators/attribute/enum.py +1 -3
  65. infrahub/core/validators/attribute/kind.py +1 -3
  66. infrahub/core/validators/attribute/length.py +13 -7
  67. infrahub/core/validators/attribute/min_max.py +118 -0
  68. infrahub/core/validators/attribute/number_pool.py +106 -0
  69. infrahub/core/validators/attribute/optional.py +1 -4
  70. infrahub/core/validators/attribute/regex.py +5 -6
  71. infrahub/core/validators/attribute/unique.py +1 -3
  72. infrahub/core/validators/determiner.py +18 -2
  73. infrahub/core/validators/enum.py +12 -0
  74. infrahub/core/validators/node/hierarchy.py +3 -6
  75. infrahub/core/validators/query.py +1 -3
  76. infrahub/core/validators/relationship/count.py +6 -12
  77. infrahub/core/validators/relationship/optional.py +2 -4
  78. infrahub/core/validators/relationship/peer.py +3 -8
  79. infrahub/core/validators/uniqueness/query.py +5 -9
  80. infrahub/database/__init__.py +11 -2
  81. infrahub/events/group_action.py +1 -0
  82. infrahub/git/base.py +5 -3
  83. infrahub/git/integrator.py +102 -3
  84. infrahub/graphql/analyzer.py +139 -18
  85. infrahub/graphql/manager.py +4 -0
  86. infrahub/graphql/mutations/action.py +164 -0
  87. infrahub/graphql/mutations/convert_object_type.py +62 -0
  88. infrahub/graphql/mutations/main.py +24 -175
  89. infrahub/graphql/mutations/proposed_change.py +20 -17
  90. infrahub/graphql/mutations/resource_manager.py +62 -6
  91. infrahub/graphql/queries/convert_object_type_mapping.py +36 -0
  92. infrahub/graphql/queries/resource_manager.py +7 -1
  93. infrahub/graphql/schema.py +6 -0
  94. infrahub/menu/menu.py +31 -0
  95. infrahub/message_bus/messages/__init__.py +0 -10
  96. infrahub/message_bus/operations/__init__.py +0 -8
  97. infrahub/patch/queries/consolidate_duplicated_nodes.py +3 -6
  98. infrahub/patch/queries/delete_duplicated_edges.py +5 -10
  99. infrahub/pools/number.py +5 -3
  100. infrahub/prefect_server/models.py +1 -19
  101. infrahub/proposed_change/models.py +68 -3
  102. infrahub/proposed_change/tasks.py +907 -30
  103. infrahub/task_manager/models.py +10 -6
  104. infrahub/trigger/catalogue.py +2 -0
  105. infrahub/trigger/models.py +18 -2
  106. infrahub/trigger/tasks.py +3 -1
  107. infrahub/types.py +6 -0
  108. infrahub/workflows/catalogue.py +76 -0
  109. infrahub_sdk/client.py +43 -10
  110. infrahub_sdk/node/__init__.py +39 -0
  111. infrahub_sdk/node/attribute.py +122 -0
  112. infrahub_sdk/node/constants.py +21 -0
  113. infrahub_sdk/{node.py → node/node.py} +50 -749
  114. infrahub_sdk/node/parsers.py +15 -0
  115. infrahub_sdk/node/property.py +24 -0
  116. infrahub_sdk/node/related_node.py +266 -0
  117. infrahub_sdk/node/relationship.py +302 -0
  118. infrahub_sdk/protocols.py +112 -0
  119. infrahub_sdk/protocols_base.py +34 -2
  120. infrahub_sdk/query_groups.py +13 -2
  121. infrahub_sdk/schema/main.py +1 -0
  122. infrahub_sdk/schema/repository.py +16 -0
  123. infrahub_sdk/spec/object.py +1 -1
  124. infrahub_sdk/store.py +1 -1
  125. infrahub_sdk/testing/schemas/car_person.py +1 -0
  126. {infrahub_server-1.2.11.dist-info → infrahub_server-1.3.0b1.dist-info}/METADATA +4 -4
  127. {infrahub_server-1.2.11.dist-info → infrahub_server-1.3.0b1.dist-info}/RECORD +134 -122
  128. {infrahub_server-1.2.11.dist-info → infrahub_server-1.3.0b1.dist-info}/WHEEL +1 -1
  129. infrahub_testcontainers/container.py +0 -1
  130. infrahub_testcontainers/docker-compose.test.yml +1 -1
  131. infrahub_testcontainers/helpers.py +8 -2
  132. infrahub/message_bus/messages/check_generator_run.py +0 -26
  133. infrahub/message_bus/messages/finalize_validator_execution.py +0 -15
  134. infrahub/message_bus/messages/proposed_change/base_with_diff.py +0 -16
  135. infrahub/message_bus/messages/proposed_change/request_proposedchange_refreshartifacts.py +0 -11
  136. infrahub/message_bus/messages/request_generatordefinition_check.py +0 -20
  137. infrahub/message_bus/messages/request_proposedchange_pipeline.py +0 -23
  138. infrahub/message_bus/operations/check/__init__.py +0 -3
  139. infrahub/message_bus/operations/check/generator.py +0 -156
  140. infrahub/message_bus/operations/finalize/__init__.py +0 -3
  141. infrahub/message_bus/operations/finalize/validator.py +0 -133
  142. infrahub/message_bus/operations/requests/__init__.py +0 -9
  143. infrahub/message_bus/operations/requests/generator_definition.py +0 -140
  144. infrahub/message_bus/operations/requests/proposed_change.py +0 -629
  145. /infrahub/{message_bus/messages/proposed_change → actions}/__init__.py +0 -0
  146. {infrahub_server-1.2.11.dist-info → infrahub_server-1.3.0b1.dist-info}/LICENSE.txt +0 -0
  147. {infrahub_server-1.2.11.dist-info → infrahub_server-1.3.0b1.dist-info}/entry_points.txt +0 -0
@@ -217,8 +217,7 @@ class RelationshipQuery(Query):
217
217
  )
218
218
  source_query_match = """
219
219
  MATCH (s:Node { uuid: $source_id })
220
- CALL {
221
- WITH s
220
+ CALL (s) {
222
221
  MATCH (s)-[r:IS_PART_OF]->(:Root)
223
222
  WHERE %(source_filter)s
224
223
  RETURN r.status = "active" AS s_is_active
@@ -246,8 +245,7 @@ class RelationshipQuery(Query):
246
245
  )
247
246
  destination_query_match = """
248
247
  MATCH (d:Node { uuid: $destination_id })
249
- CALL {
250
- WITH d
248
+ CALL (d) {
251
249
  MATCH (d)-[r:IS_PART_OF]->(:Root)
252
250
  WHERE %(destination_filter)s
253
251
  RETURN r.status = "active" AS d_is_active
@@ -524,8 +522,7 @@ class RelationshipDeleteQuery(RelationshipQuery):
524
522
  CREATE (s)%(r1)s(rl)
525
523
  CREATE (rl)%(r2)s(d)
526
524
  WITH rl
527
- CALL {
528
- WITH rl
525
+ CALL (rl) {
529
526
  MATCH (rl)-[edge:IS_VISIBLE]->(visible)
530
527
  WHERE %(rel_filter)s AND edge.status = "active"
531
528
  WITH rl, edge, visible
@@ -536,8 +533,7 @@ class RelationshipDeleteQuery(RelationshipQuery):
536
533
  WHERE edge.branch = $branch
537
534
  SET edge.to = $at
538
535
  }
539
- CALL {
540
- WITH rl
536
+ CALL (rl) {
541
537
  MATCH (rl)-[edge:IS_PROTECTED]->(protected)
542
538
  WHERE %(rel_filter)s AND edge.status = "active"
543
539
  WITH rl, edge, protected
@@ -548,8 +544,7 @@ class RelationshipDeleteQuery(RelationshipQuery):
548
544
  WHERE edge.branch = $branch
549
545
  SET edge.to = $at
550
546
  }
551
- CALL {
552
- WITH rl
547
+ CALL (rl) {
553
548
  MATCH (rl)-[edge:HAS_OWNER]->(owner_node)
554
549
  WHERE %(rel_filter)s AND edge.status = "active"
555
550
  WITH rl, edge, owner_node
@@ -560,8 +555,7 @@ class RelationshipDeleteQuery(RelationshipQuery):
560
555
  WHERE edge.branch = $branch
561
556
  SET edge.to = $at
562
557
  }
563
- CALL {
564
- WITH rl
558
+ CALL (rl) {
565
559
  MATCH (rl)-[edge:HAS_SOURCE]->(source_node)
566
560
  WHERE %(rel_filter)s AND edge.status = "active"
567
561
  WITH rl, edge, source_node
@@ -656,8 +650,7 @@ class RelationshipGetPeerQuery(Query):
656
650
  MATCH (source_node:Node)%(arrow_left_start)s[:IS_RELATED]%(arrow_left_end)s(rl:Relationship { name: $rel_identifier })
657
651
  WHERE source_node.uuid IN $source_ids
658
652
  WITH DISTINCT source_node, rl
659
- CALL {
660
- WITH rl, source_node
653
+ CALL (rl, source_node) {
661
654
  MATCH path = (source_node)%(path)s(peer:Node)
662
655
  WHERE
663
656
  $source_kind IN LABELS(source_node) AND
@@ -726,22 +719,19 @@ class RelationshipGetPeerQuery(Query):
726
719
  with_str = ", ".join(
727
720
  [f"{subquery_result_name} as {label}" if label == "peer" else label for label in self.return_labels]
728
721
  )
729
- self.add_subquery(subquery=subquery, with_clause=with_str)
730
-
722
+ self.add_subquery(subquery=subquery, node_alias="peer", with_clause=with_str)
731
723
  # ----------------------------------------------------------------------------
732
724
  # QUERY Properties
733
725
  # ----------------------------------------------------------------------------
734
726
  query = """
735
- CALL {
736
- WITH rl
727
+ CALL (rl) {
737
728
  MATCH (rl)-[r:IS_VISIBLE]-(is_visible)
738
729
  WHERE %(branch_filter)s
739
730
  RETURN r AS rel_is_visible, is_visible
740
731
  ORDER BY r.branch_level DESC, r.from DESC, r.status ASC
741
732
  LIMIT 1
742
733
  }
743
- CALL {
744
- WITH rl
734
+ CALL (rl) {
745
735
  MATCH (rl)-[r:IS_PROTECTED]-(is_protected)
746
736
  WHERE %(branch_filter)s
747
737
  RETURN r AS rel_is_protected, is_protected
@@ -758,8 +748,7 @@ class RelationshipGetPeerQuery(Query):
758
748
  # We must query them one by one otherwise the second one won't return
759
749
  for node_prop in ["source", "owner"]:
760
750
  query = """
761
- CALL {
762
- WITH rl
751
+ CALL (rl) {
763
752
  OPTIONAL MATCH (rl)-[r:HAS_%(node_prop_type)s]-(%(node_prop)s)
764
753
  WHERE %(branch_filter)s
765
754
  RETURN r AS rel_%(node_prop)s, %(node_prop)s
@@ -800,7 +789,7 @@ class RelationshipGetPeerQuery(Query):
800
789
  self.order_by.append(subquery_result_name)
801
790
  self.params.update(subquery_params)
802
791
 
803
- self.add_subquery(subquery=subquery)
792
+ self.add_subquery(subquery=subquery, node_alias="peer")
804
793
 
805
794
  order_cnt += 1
806
795
 
@@ -939,8 +928,7 @@ class RelationshipGetByIdentifierQuery(Query):
939
928
  query = """
940
929
  MATCH (rl:Relationship)
941
930
  WHERE rl.name IN $identifiers
942
- CALL {
943
- WITH rl
931
+ CALL (rl) {
944
932
  MATCH (src:Node)-[r1:IS_RELATED]-(rl:Relationship)-[r2:IS_RELATED]-(dst:Node)
945
933
  WHERE (size($full_identifiers) = 0 OR [src.kind, rl.name, dst.kind] in $full_identifiers)
946
934
  AND NOT src.namespace IN $excluded_namespaces
@@ -1003,8 +991,7 @@ class RelationshipCountPerNodeQuery(Query):
1003
991
  query = """
1004
992
  MATCH (peer_node:Node)%(path)s(rl:Relationship { name: $rel_identifier })
1005
993
  WHERE peer_node.uuid IN $peer_ids AND %(branch_filter)s
1006
- CALL {
1007
- WITH rl
994
+ CALL (rl) {
1008
995
  MATCH path = (peer_node:Node)%(path)s(rl)
1009
996
  WHERE peer_node.uuid IN $peer_ids AND %(branch_filter)s
1010
997
  RETURN peer_node as peer, r as r1
@@ -1082,8 +1069,7 @@ class RelationshipDeleteAllQuery(Query):
1082
1069
  for arrow_left, arrow_right in (("<-", "-"), ("-", "->")):
1083
1070
  for edge_type in edge_types:
1084
1071
  sub_query = """
1085
- CALL {
1086
- WITH rl
1072
+ CALL (rl) {
1087
1073
  MATCH (rl)%(arrow_left)s[active_edge:%(edge_type)s]%(arrow_right)s(n)
1088
1074
  WHERE %(active_rel_filter)s AND active_edge.status ="active"
1089
1075
  CREATE (rl)%(arrow_left)s[deleted_edge:%(edge_type)s $rel_prop]%(arrow_right)s(n)
@@ -1103,8 +1089,7 @@ class RelationshipDeleteAllQuery(Query):
1103
1089
 
1104
1090
  # We only want to return uuid/kind of `Node` connected through `IS_RELATED` edges.
1105
1091
  query += """
1106
- CALL {
1107
- WITH rl
1092
+ CALL (rl) {
1108
1093
  MATCH (rl)-[active_edge:IS_RELATED]->(n)
1109
1094
  WHERE %(active_rel_filter)s
1110
1095
  WITH rl, active_edge, n
@@ -1123,7 +1108,6 @@ class RelationshipDeleteAllQuery(Query):
1123
1108
  "outbound" as rel_direction
1124
1109
 
1125
1110
  UNION
1126
-
1127
1111
  WITH rl
1128
1112
  MATCH (rl)<-[active_edge:IS_RELATED]-(n)
1129
1113
  WHERE %(active_rel_filter)s
@@ -1193,3 +1177,40 @@ class RelationshipDeleteAllQuery(Query):
1193
1177
  changelog_mapper.delete_relationship(peer_id=peer_uuid, peer_kind=kind, rel_schema=deleted_rel_schema)
1194
1178
 
1195
1179
  return [changelog_mapper.changelog for changelog_mapper in rel_identifier_to_changelog_mapper.values()]
1180
+
1181
+
1182
+ class GetAllPeersIds(Query):
1183
+ """
1184
+ Return all peers ids connected to input node. Some peers can be excluded using `exclude_identifiers`.
1185
+ """
1186
+
1187
+ name = "get_peers_ids"
1188
+ type: QueryType = QueryType.READ
1189
+ insert_return = False
1190
+
1191
+ def __init__(self, node_id: str, exclude_identifiers: list[str], **kwargs):
1192
+ self.node_id = node_id
1193
+ self.exclude_identifiers = exclude_identifiers
1194
+ super().__init__(**kwargs)
1195
+
1196
+ async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG002
1197
+ self.params["source_id"] = kwargs["node_id"]
1198
+ self.params["branch"] = self.branch.name
1199
+ self.params["exclude_identifiers"] = self.exclude_identifiers
1200
+
1201
+ active_rel_filter, rel_params = self.branch.get_query_filter_path(
1202
+ at=self.at, variable_name="e1", branch_agnostic=self.branch_agnostic
1203
+ )
1204
+ self.params.update(rel_params)
1205
+
1206
+ query = """
1207
+ MATCH (node:Node { uuid: $source_id })-[e1:IS_RELATED]-(rl:Relationship)-[e2:IS_RELATED]-(peer:Node)
1208
+ WHERE %(active_rel_filter)s AND peer.uuid <> node.uuid AND NOT (rl.name IN $exclude_identifiers)
1209
+ WITH DISTINCT(peer.uuid) as uuid
1210
+ RETURN uuid
1211
+ """ % {"active_rel_filter": active_rel_filter}
1212
+
1213
+ self.add_to_query(query)
1214
+
1215
+ def get_peers_uuids(self) -> list[str]:
1216
+ return [row.data["uuid"] for row in self.results] # type: ignore
@@ -220,8 +220,7 @@ class NumberPoolGetUsed(Query):
220
220
 
221
221
  query = """
222
222
  MATCH (pool:%(number_pool)s { uuid: $pool_id })
223
- CALL {
224
- WITH pool
223
+ CALL (pool) {
225
224
  MATCH (pool)-[res:IS_RESERVED]->(av:AttributeValue)<-[hv:HAS_VALUE]-(attr:Attribute)
226
225
  WHERE
227
226
  attr.name = $attribute_name
@@ -61,7 +61,7 @@ async def build_subquery_filter(
61
61
  where_str = " AND ".join(field_where)
62
62
  branch_level_str = "reduce(br_lvl = 0, r in relationships(path) | br_lvl + r.branch_level)"
63
63
  froms_str = db.render_list_comprehension(items="relationships(path)", item_name="from")
64
- to_return = f"{node_alias} AS {prefix}"
64
+ to_return = f"{prefix}"
65
65
  with_extra = ""
66
66
  final_with_extra = ""
67
67
  is_isnull = filter_name == "isnull"
@@ -82,7 +82,6 @@ async def build_subquery_filter(
82
82
  elif field is not None and field.is_attribute:
83
83
  is_active_filter = "(latest_node_details[2]).value = 'NULL'"
84
84
  query = f"""
85
- WITH {node_alias}
86
85
  {match} path = {filter_str}
87
86
  WHERE {where_str}
88
87
  WITH
@@ -94,7 +93,7 @@ async def build_subquery_filter(
94
93
  ORDER BY branch_level DESC, froms[-1] DESC, froms[-2] DESC
95
94
  WITH head(collect([is_active, {node_alias}{with_extra}])) AS latest_node_details
96
95
  WHERE {is_active_filter}
97
- WITH latest_node_details[1] AS {node_alias}{final_with_extra}
96
+ WITH latest_node_details[1] AS {prefix}{final_with_extra}
98
97
  RETURN {to_return}
99
98
  """
100
99
  return query, params, prefix
@@ -174,7 +173,6 @@ async def build_subquery_order(
174
173
  to_return_str_parts.append(f"CASE WHEN is_active = TRUE THEN {expression} ELSE NULL END AS {alias}")
175
174
  to_return_str = ", ".join(to_return_str_parts)
176
175
  query = f"""
177
- WITH {node_alias}
178
176
  OPTIONAL MATCH path = {filter_str}
179
177
  WHERE {where_str}
180
178
  WITH {with_str_to_alias}
@@ -789,6 +789,9 @@ class RelationshipManager:
789
789
 
790
790
  return len(self._relationships)
791
791
 
792
+ def validate(self) -> None:
793
+ self._relationships.validate()
794
+
792
795
  @overload
793
796
  async def get_peer(
794
797
  self,
@@ -21,7 +21,8 @@ from .profile_schema import ProfileSchema
21
21
  from .relationship_schema import RelationshipSchema
22
22
  from .template_schema import TemplateSchema
23
23
 
24
- MainSchemaTypes: TypeAlias = NodeSchema | GenericSchema | ProfileSchema | TemplateSchema
24
+ NonGenericSchemaTypes: TypeAlias = NodeSchema | ProfileSchema | TemplateSchema
25
+ MainSchemaTypes: TypeAlias = NonGenericSchemaTypes | GenericSchema
25
26
 
26
27
 
27
28
  # -----------------------------------------------------
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import Self
5
+
6
+ from pydantic import ConfigDict, Field, model_validator
7
+
8
+ from infrahub import config
9
+ from infrahub.core.constants.schema import UpdateSupport
10
+ from infrahub.core.models import HashableModel
11
+
12
+
13
+ def get_attribute_parameters_class_for_kind(kind: str) -> type[AttributeParameters]:
14
+ param_classes: dict[str, type[AttributeParameters]] = {
15
+ "NumberPool": NumberPoolParameters,
16
+ "Text": TextAttributeParameters,
17
+ "TextArea": TextAttributeParameters,
18
+ "Number": NumberAttributeParameters,
19
+ }
20
+ return param_classes.get(kind, AttributeParameters)
21
+
22
+
23
+ class AttributeParameters(HashableModel):
24
+ model_config = ConfigDict(extra="forbid")
25
+
26
+
27
+ class TextAttributeParameters(AttributeParameters):
28
+ regex: str | None = Field(
29
+ default=None,
30
+ description="Regular expression that attribute value must match if defined",
31
+ json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
32
+ )
33
+ min_length: int | None = Field(
34
+ default=None,
35
+ description="Set a minimum number of characters allowed.",
36
+ json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
37
+ )
38
+ max_length: int | None = Field(
39
+ default=None,
40
+ description="Set a maximum number of characters allowed.",
41
+ json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
42
+ )
43
+
44
+ @model_validator(mode="after")
45
+ def validate_min_max(self) -> Self:
46
+ if (
47
+ config.SETTINGS.initialized
48
+ and config.SETTINGS.main.schema_strict_mode
49
+ and self.min_length is not None
50
+ and self.max_length is not None
51
+ ):
52
+ if self.min_length > self.max_length:
53
+ raise ValueError(
54
+ "`max_length` can't be less than `min_length` when the schema is configured with strict mode"
55
+ )
56
+
57
+ return self
58
+
59
+
60
+ class NumberAttributeParameters(AttributeParameters):
61
+ min_value: int | None = Field(
62
+ default=None,
63
+ description="Set a minimum value allowed.",
64
+ json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
65
+ )
66
+ max_value: int | None = Field(
67
+ default=None,
68
+ description="Set a maximum value allowed.",
69
+ json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
70
+ )
71
+ excluded_values: str | None = Field(
72
+ default=None,
73
+ description="List of values or range of values not allowed for the attribute, format is: '100,150-200,280,300-400'",
74
+ pattern=r"^(\d+(?:-\d+)?)(?:,\d+(?:-\d+)?)*$",
75
+ json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
76
+ )
77
+
78
+ @model_validator(mode="after")
79
+ def validate_ranges(self) -> Self:
80
+ ranges = self.get_excluded_ranges()
81
+ for i, (start_range_1, end_range_1) in enumerate(ranges):
82
+ if start_range_1 > end_range_1:
83
+ raise ValueError("`start_range` can't be less than `end_range`")
84
+
85
+ # Check for overlapping ranges
86
+ for start_range_2, end_range_2 in ranges[i + 1 :]:
87
+ if not (end_range_1 < start_range_2 or start_range_1 > end_range_2):
88
+ raise ValueError("Excluded ranges cannot overlap")
89
+
90
+ return self
91
+
92
+ @model_validator(mode="after")
93
+ def validate_min_max(self) -> Self:
94
+ if (
95
+ config.SETTINGS.initialized
96
+ and config.SETTINGS.main.schema_strict_mode
97
+ and self.min_value is not None
98
+ and self.max_value is not None
99
+ ):
100
+ if self.min_value > self.max_value:
101
+ raise ValueError(
102
+ "`max_value` can't be less than `min_value` when the schema is configured with strict mode"
103
+ )
104
+
105
+ return self
106
+
107
+ def get_excluded_single_values(self) -> list[int]:
108
+ if not self.excluded_values:
109
+ return []
110
+
111
+ results = [int(value) for value in self.excluded_values.split(",") if "-" not in value]
112
+ return results
113
+
114
+ def get_excluded_ranges(self) -> list[tuple[int, int]]:
115
+ if not self.excluded_values:
116
+ return []
117
+
118
+ ranges = []
119
+ for value in self.excluded_values.split(","):
120
+ if "-" in value:
121
+ start, end = map(int, value.split("-"))
122
+ ranges.append((start, end))
123
+
124
+ return ranges
125
+
126
+ def is_valid_value(self, value: int) -> bool:
127
+ if self.min_value is not None and value < self.min_value:
128
+ return False
129
+ if self.max_value is not None and value > self.max_value:
130
+ return False
131
+ if value in self.get_excluded_single_values():
132
+ return False
133
+ for start, end in self.get_excluded_ranges():
134
+ if start <= value <= end:
135
+ return False
136
+ return True
137
+
138
+
139
+ class NumberPoolParameters(AttributeParameters):
140
+ end_range: int = Field(
141
+ default=sys.maxsize,
142
+ description="End range for numbers for the associated NumberPool",
143
+ json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
144
+ )
145
+ start_range: int = Field(
146
+ default=1,
147
+ description="Start range for numbers for the associated NumberPool",
148
+ json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
149
+ )
150
+ number_pool_id: str | None = Field(
151
+ default=None,
152
+ description="The ID of the numberpool associated with this attribute",
153
+ json_schema_extra={"update": UpdateSupport.NOT_SUPPORTED.value},
154
+ )
155
+
156
+ @model_validator(mode="after")
157
+ def validate_ranges(self) -> Self:
158
+ if self.start_range > self.end_range:
159
+ raise ValueError("`start_range` can't be less than `end_range`")
160
+ return self
@@ -2,15 +2,23 @@ from __future__ import annotations
2
2
 
3
3
  import enum
4
4
  from enum import Enum
5
- from typing import TYPE_CHECKING, Any
5
+ from typing import TYPE_CHECKING, Any, Self
6
6
 
7
- from pydantic import field_validator, model_validator
7
+ from pydantic import Field, ValidationInfo, field_validator, model_validator
8
8
 
9
9
  from infrahub import config
10
+ from infrahub.core.constants.schema import UpdateSupport
10
11
  from infrahub.core.enums import generate_python_enum
11
12
  from infrahub.core.query.attribute import default_attribute_query_filter
12
13
  from infrahub.types import ATTRIBUTE_KIND_LABELS, ATTRIBUTE_TYPES
13
14
 
15
+ from .attribute_parameters import (
16
+ AttributeParameters,
17
+ NumberAttributeParameters,
18
+ NumberPoolParameters,
19
+ TextAttributeParameters,
20
+ get_attribute_parameters_class_for_kind,
21
+ )
14
22
  from .generated.attribute_schema import GeneratedAttributeSchema
15
23
 
16
24
  if TYPE_CHECKING:
@@ -21,6 +29,16 @@ if TYPE_CHECKING:
21
29
  from infrahub.database import InfrahubDatabase
22
30
 
23
31
 
32
+ def get_attribute_schema_class_for_kind(kind: str) -> type[AttributeSchema]:
33
+ attribute_schema_class_by_kind: dict[str, type[AttributeSchema]] = {
34
+ "NumberPool": NumberPoolSchema,
35
+ "Text": TextAttributeSchema,
36
+ "TextArea": TextAttributeSchema,
37
+ "Number": NumberAttributeSchema,
38
+ }
39
+ return attribute_schema_class_by_kind.get(kind, AttributeSchema)
40
+
41
+
24
42
  class AttributeSchema(GeneratedAttributeSchema):
25
43
  _sort_by: list[str] = ["name"]
26
44
  _enum_class: type[enum.Enum] | None = None
@@ -53,16 +71,46 @@ class AttributeSchema(GeneratedAttributeSchema):
53
71
 
54
72
  @model_validator(mode="before")
55
73
  @classmethod
56
- def validate_dropdown_choices(cls, values: dict[str, Any]) -> dict[str, Any]:
74
+ def validate_dropdown_choices(cls, values: Any) -> Any:
57
75
  """Validate that choices are defined for a dropdown but not for other kinds."""
58
- if values.get("kind") != "Dropdown" and values.get("choices"):
59
- raise ValueError(f"Can only specify 'choices' for kind=Dropdown: {values['kind']}")
60
-
61
- if values.get("kind") == "Dropdown" and not values.get("choices"):
76
+ if isinstance(values, dict):
77
+ kind = values.get("kind")
78
+ choices = values.get("choices")
79
+ elif isinstance(values, AttributeSchema):
80
+ kind = values.kind
81
+ choices = values.choices
82
+ else:
83
+ return values
84
+ if kind != "Dropdown" and choices:
85
+ raise ValueError(f"Can only specify 'choices' for kind=Dropdown: {kind}")
86
+
87
+ if kind == "Dropdown" and not choices:
62
88
  raise ValueError("The property 'choices' is required for kind=Dropdown")
63
89
 
64
90
  return values
65
91
 
92
+ @field_validator("parameters", mode="before")
93
+ @classmethod
94
+ def set_parameters_type(cls, value: Any, info: ValidationInfo) -> Any:
95
+ """Override parameters class if using base AttributeParameters class and should be using a subclass"""
96
+ kind = info.data["kind"]
97
+ expected_parameters_class = get_attribute_parameters_class_for_kind(kind=kind)
98
+ if value is None:
99
+ return expected_parameters_class()
100
+ if not isinstance(value, expected_parameters_class) and isinstance(value, AttributeParameters):
101
+ return expected_parameters_class(**value.model_dump())
102
+ return value
103
+
104
+ @model_validator(mode="after")
105
+ def validate_parameters(self) -> Self:
106
+ if isinstance(self.parameters, NumberPoolParameters) and not self.kind == "NumberPool":
107
+ raise ValueError(f"NumberPoolParameters can't be used as parameters for {self.kind}")
108
+
109
+ if isinstance(self.parameters, TextAttributeParameters) and self.kind not in ["Text", "TextArea"]:
110
+ raise ValueError(f"TextAttributeParameters can't be used as parameters for {self.kind}")
111
+
112
+ return self
113
+
66
114
  def get_class(self) -> type[BaseAttribute]:
67
115
  return ATTRIBUTE_TYPES[self.kind].get_infrahub_class()
68
116
 
@@ -106,7 +154,7 @@ class AttributeSchema(GeneratedAttributeSchema):
106
154
 
107
155
  def to_node(self) -> dict[str, Any]:
108
156
  fields_to_exclude = {"id", "state", "filters"}
109
- fields_to_json = {"computed_attribute"}
157
+ fields_to_json = {"computed_attribute", "parameters"}
110
158
  data = self.model_dump(exclude=fields_to_exclude | fields_to_json)
111
159
 
112
160
  for field_name in fields_to_json:
@@ -117,6 +165,15 @@ class AttributeSchema(GeneratedAttributeSchema):
117
165
 
118
166
  return data
119
167
 
168
+ def get_regex(self) -> str | None:
169
+ return self.regex
170
+
171
+ def get_min_length(self) -> int | None:
172
+ return self.min_length
173
+
174
+ def get_max_length(self) -> int | None:
175
+ return self.max_length
176
+
120
177
  async def get_query_filter(
121
178
  self,
122
179
  name: str,
@@ -144,3 +201,49 @@ class AttributeSchema(GeneratedAttributeSchema):
144
201
  partial_match=partial_match,
145
202
  support_profiles=support_profiles,
146
203
  )
204
+
205
+
206
+ class NumberPoolSchema(AttributeSchema):
207
+ parameters: NumberPoolParameters = Field(
208
+ default_factory=NumberPoolParameters,
209
+ description="Extra parameters specific to NumberPool attributes",
210
+ json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
211
+ )
212
+
213
+
214
+ class TextAttributeSchema(AttributeSchema):
215
+ parameters: TextAttributeParameters = Field(
216
+ default_factory=TextAttributeParameters,
217
+ description="Extra parameters specific to text attributes",
218
+ json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
219
+ )
220
+
221
+ @model_validator(mode="after")
222
+ def reconcile_parameters(self) -> Self:
223
+ if self.regex != self.parameters.regex:
224
+ final_regex = self.parameters.regex if self.parameters.regex is not None else self.regex
225
+ self.regex = self.parameters.regex = final_regex
226
+ if self.min_length != self.parameters.min_length:
227
+ final_min_length = self.parameters.min_length if self.parameters.min_length is not None else self.min_length
228
+ self.min_length = self.parameters.min_length = final_min_length
229
+ if self.max_length != self.parameters.max_length:
230
+ final_max_length = self.parameters.max_length if self.parameters.max_length is not None else self.max_length
231
+ self.max_length = self.parameters.max_length = final_max_length
232
+ return self
233
+
234
+ def get_regex(self) -> str | None:
235
+ return self.parameters.regex
236
+
237
+ def get_min_length(self) -> int | None:
238
+ return self.parameters.min_length
239
+
240
+ def get_max_length(self) -> int | None:
241
+ return self.parameters.max_length
242
+
243
+
244
+ class NumberAttributeSchema(AttributeSchema):
245
+ parameters: NumberAttributeParameters = Field(
246
+ default_factory=NumberAttributeParameters,
247
+ description="Extra parameters specific to number attributes",
248
+ json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
249
+ )
@@ -13,7 +13,7 @@ from pydantic import field_validator
13
13
  from infrahub.core.constants import RelationshipCardinality, RelationshipKind
14
14
  from infrahub.core.models import HashableModel, HashableModelDiff
15
15
 
16
- from .attribute_schema import AttributeSchema
16
+ from .attribute_schema import AttributeSchema, get_attribute_schema_class_for_kind
17
17
  from .generated.base_node_schema import GeneratedBaseNodeSchema
18
18
  from .relationship_schema import RelationshipSchema
19
19
 
@@ -74,6 +74,30 @@ class BaseNodeSchema(GeneratedBaseNodeSchema):
74
74
  Be careful hash generated from hash() have a salt by default and they will not be the same across run"""
75
75
  return hash(self.get_hash())
76
76
 
77
+ @field_validator("attributes", mode="before")
78
+ @classmethod
79
+ def set_attribute_type(cls, raw_attributes: Any) -> Any:
80
+ if not isinstance(raw_attributes, list):
81
+ return raw_attributes
82
+ attribute_schemas_with_types: list[Any] = []
83
+ for raw_attr in raw_attributes:
84
+ if not isinstance(raw_attr, (dict, AttributeSchema)):
85
+ attribute_schemas_with_types.append(raw_attr)
86
+ continue
87
+ if isinstance(raw_attr, dict):
88
+ kind = raw_attr.get("kind")
89
+ attribute_type_class = get_attribute_schema_class_for_kind(kind=kind)
90
+ attribute_schemas_with_types.append(attribute_type_class(**raw_attr))
91
+ continue
92
+
93
+ expected_attr_schema_class = get_attribute_schema_class_for_kind(kind=raw_attr.kind)
94
+ if not isinstance(raw_attr, expected_attr_schema_class):
95
+ final_attr = expected_attr_schema_class(**raw_attr.model_dump())
96
+ else:
97
+ final_attr = raw_attr
98
+ attribute_schemas_with_types.append(final_attr)
99
+ return attribute_schemas_with_types
100
+
77
101
  def to_dict(self) -> dict:
78
102
  data = self.model_dump(
79
103
  exclude_unset=True, exclude_none=True, exclude_defaults=True, exclude={"attributes", "relationships"}