infrahub-server 1.7.0b0__py3-none-any.whl → 1.7.1__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 (177) hide show
  1. infrahub/api/exceptions.py +2 -2
  2. infrahub/api/schema.py +5 -0
  3. infrahub/cli/db.py +54 -24
  4. infrahub/core/account.py +12 -9
  5. infrahub/core/branch/models.py +11 -117
  6. infrahub/core/branch/tasks.py +7 -3
  7. infrahub/core/diff/branch_differ.py +1 -1
  8. infrahub/core/diff/conflict_transferer.py +1 -1
  9. infrahub/core/diff/data_check_synchronizer.py +1 -1
  10. infrahub/core/diff/enricher/cardinality_one.py +1 -1
  11. infrahub/core/diff/enricher/hierarchy.py +1 -1
  12. infrahub/core/diff/enricher/labels.py +1 -1
  13. infrahub/core/diff/merger/merger.py +6 -2
  14. infrahub/core/diff/repository/repository.py +3 -1
  15. infrahub/core/graph/__init__.py +1 -1
  16. infrahub/core/graph/constraints.py +1 -1
  17. infrahub/core/initialization.py +2 -1
  18. infrahub/core/ipam/reconciler.py +8 -6
  19. infrahub/core/ipam/utilization.py +8 -15
  20. infrahub/core/manager.py +1 -26
  21. infrahub/core/merge.py +1 -1
  22. infrahub/core/migrations/graph/__init__.py +2 -0
  23. infrahub/core/migrations/graph/m012_convert_account_generic.py +12 -12
  24. infrahub/core/migrations/graph/m013_convert_git_password_credential.py +4 -4
  25. infrahub/core/migrations/graph/m014_remove_index_attr_value.py +3 -2
  26. infrahub/core/migrations/graph/m015_diff_format_update.py +3 -2
  27. infrahub/core/migrations/graph/m016_diff_delete_bug_fix.py +3 -2
  28. infrahub/core/migrations/graph/m017_add_core_profile.py +6 -4
  29. infrahub/core/migrations/graph/m018_uniqueness_nulls.py +3 -4
  30. infrahub/core/migrations/graph/m020_duplicate_edges.py +3 -3
  31. infrahub/core/migrations/graph/m025_uniqueness_nulls.py +3 -4
  32. infrahub/core/migrations/graph/m026_0000_prefix_fix.py +4 -5
  33. infrahub/core/migrations/graph/m028_delete_diffs.py +3 -2
  34. infrahub/core/migrations/graph/m029_duplicates_cleanup.py +3 -2
  35. infrahub/core/migrations/graph/m031_check_number_attributes.py +4 -3
  36. infrahub/core/migrations/graph/m032_cleanup_orphaned_branch_relationships.py +3 -2
  37. infrahub/core/migrations/graph/m034_find_orphaned_schema_fields.py +3 -2
  38. infrahub/core/migrations/graph/m035_orphan_relationships.py +3 -3
  39. infrahub/core/migrations/graph/m036_drop_attr_value_index.py +3 -2
  40. infrahub/core/migrations/graph/m037_index_attr_vals.py +3 -2
  41. infrahub/core/migrations/graph/m038_redo_0000_prefix_fix.py +4 -5
  42. infrahub/core/migrations/graph/m039_ipam_reconcile.py +3 -2
  43. infrahub/core/migrations/graph/m041_deleted_dup_edges.py +4 -3
  44. infrahub/core/migrations/graph/m042_profile_attrs_in_db.py +5 -4
  45. infrahub/core/migrations/graph/m043_create_hfid_display_label_in_db.py +12 -5
  46. infrahub/core/migrations/graph/m044_backfill_hfid_display_label_in_db.py +15 -4
  47. infrahub/core/migrations/graph/m045_backfill_hfid_display_label_in_db_profile_template.py +10 -4
  48. infrahub/core/migrations/graph/m046_fill_agnostic_hfid_display_labels.py +6 -5
  49. infrahub/core/migrations/graph/m047_backfill_or_null_display_label.py +19 -5
  50. infrahub/core/migrations/graph/m048_undelete_rel_props.py +6 -4
  51. infrahub/core/migrations/graph/m049_remove_is_visible_relationship.py +19 -4
  52. infrahub/core/migrations/graph/m050_backfill_vertex_metadata.py +3 -3
  53. infrahub/core/migrations/graph/m051_subtract_branched_from_microsecond.py +39 -0
  54. infrahub/core/migrations/query/__init__.py +2 -2
  55. infrahub/core/migrations/query/schema_attribute_update.py +1 -1
  56. infrahub/core/migrations/runner.py +6 -3
  57. infrahub/core/migrations/schema/attribute_kind_update.py +8 -11
  58. infrahub/core/migrations/schema/attribute_name_update.py +1 -1
  59. infrahub/core/migrations/schema/attribute_supports_profile.py +5 -10
  60. infrahub/core/migrations/schema/models.py +8 -0
  61. infrahub/core/migrations/schema/node_attribute_add.py +11 -14
  62. infrahub/core/migrations/schema/node_attribute_remove.py +1 -1
  63. infrahub/core/migrations/schema/node_kind_update.py +1 -1
  64. infrahub/core/migrations/schema/tasks.py +7 -1
  65. infrahub/core/migrations/shared.py +37 -30
  66. infrahub/core/node/__init__.py +3 -2
  67. infrahub/core/node/base.py +9 -5
  68. infrahub/core/node/delete_validator.py +1 -1
  69. infrahub/core/order.py +30 -0
  70. infrahub/core/protocols.py +1 -0
  71. infrahub/core/protocols_base.py +4 -0
  72. infrahub/core/query/__init__.py +8 -5
  73. infrahub/core/query/attribute.py +3 -3
  74. infrahub/core/query/branch.py +1 -1
  75. infrahub/core/query/delete.py +1 -1
  76. infrahub/core/query/diff.py +3 -3
  77. infrahub/core/query/ipam.py +104 -43
  78. infrahub/core/query/node.py +454 -101
  79. infrahub/core/query/relationship.py +83 -26
  80. infrahub/core/query/resource_manager.py +107 -18
  81. infrahub/core/relationship/constraints/count.py +1 -1
  82. infrahub/core/relationship/constraints/peer_kind.py +1 -1
  83. infrahub/core/relationship/constraints/peer_parent.py +1 -1
  84. infrahub/core/relationship/constraints/peer_relatives.py +1 -1
  85. infrahub/core/relationship/constraints/profiles_kind.py +1 -1
  86. infrahub/core/relationship/constraints/profiles_removal.py +1 -1
  87. infrahub/core/relationship/model.py +8 -2
  88. infrahub/core/schema/attribute_parameters.py +28 -1
  89. infrahub/core/schema/attribute_schema.py +9 -15
  90. infrahub/core/schema/basenode_schema.py +3 -0
  91. infrahub/core/schema/definitions/core/__init__.py +8 -2
  92. infrahub/core/schema/definitions/core/account.py +10 -10
  93. infrahub/core/schema/definitions/core/artifact.py +14 -8
  94. infrahub/core/schema/definitions/core/check.py +10 -4
  95. infrahub/core/schema/definitions/core/generator.py +26 -6
  96. infrahub/core/schema/definitions/core/graphql_query.py +1 -1
  97. infrahub/core/schema/definitions/core/group.py +9 -2
  98. infrahub/core/schema/definitions/core/ipam.py +80 -10
  99. infrahub/core/schema/definitions/core/menu.py +41 -7
  100. infrahub/core/schema/definitions/core/permission.py +16 -2
  101. infrahub/core/schema/definitions/core/profile.py +16 -2
  102. infrahub/core/schema/definitions/core/propose_change.py +24 -4
  103. infrahub/core/schema/definitions/core/propose_change_comment.py +23 -11
  104. infrahub/core/schema/definitions/core/propose_change_validator.py +50 -21
  105. infrahub/core/schema/definitions/core/repository.py +10 -0
  106. infrahub/core/schema/definitions/core/resource_pool.py +8 -1
  107. infrahub/core/schema/definitions/core/template.py +19 -2
  108. infrahub/core/schema/definitions/core/transform.py +11 -5
  109. infrahub/core/schema/definitions/core/webhook.py +27 -9
  110. infrahub/core/schema/manager.py +50 -38
  111. infrahub/core/schema/schema_branch.py +68 -2
  112. infrahub/core/utils.py +3 -3
  113. infrahub/core/validators/aggregated_checker.py +1 -1
  114. infrahub/core/validators/attribute/choices.py +1 -1
  115. infrahub/core/validators/attribute/enum.py +1 -1
  116. infrahub/core/validators/attribute/kind.py +6 -3
  117. infrahub/core/validators/attribute/length.py +1 -1
  118. infrahub/core/validators/attribute/min_max.py +1 -1
  119. infrahub/core/validators/attribute/number_pool.py +1 -1
  120. infrahub/core/validators/attribute/optional.py +1 -1
  121. infrahub/core/validators/attribute/regex.py +1 -1
  122. infrahub/core/validators/node/attribute.py +1 -1
  123. infrahub/core/validators/node/relationship.py +1 -1
  124. infrahub/core/validators/relationship/peer.py +1 -1
  125. infrahub/database/__init__.py +1 -1
  126. infrahub/git/utils.py +1 -1
  127. infrahub/graphql/app.py +2 -2
  128. infrahub/graphql/field_extractor.py +1 -1
  129. infrahub/graphql/manager.py +17 -3
  130. infrahub/graphql/mutations/account.py +1 -1
  131. infrahub/graphql/order.py +14 -0
  132. infrahub/graphql/queries/diff/tree.py +5 -5
  133. infrahub/graphql/queries/resource_manager.py +25 -24
  134. infrahub/graphql/resolvers/ipam.py +3 -3
  135. infrahub/graphql/resolvers/resolver.py +44 -3
  136. infrahub/graphql/types/standard_node.py +8 -4
  137. infrahub/lock.py +7 -0
  138. infrahub/menu/repository.py +1 -1
  139. infrahub/patch/queries/base.py +1 -1
  140. infrahub/pools/number.py +1 -8
  141. infrahub/profiles/node_applier.py +1 -1
  142. infrahub/profiles/queries/get_profile_data.py +1 -1
  143. infrahub/proposed_change/action_checker.py +1 -1
  144. infrahub/services/__init__.py +1 -1
  145. infrahub/services/adapters/cache/nats.py +1 -1
  146. infrahub/services/adapters/cache/redis.py +7 -0
  147. infrahub/webhook/gather.py +1 -1
  148. infrahub/webhook/tasks.py +22 -6
  149. infrahub_sdk/analyzer.py +2 -2
  150. infrahub_sdk/branch.py +12 -39
  151. infrahub_sdk/checks.py +4 -4
  152. infrahub_sdk/client.py +36 -0
  153. infrahub_sdk/ctl/cli_commands.py +2 -1
  154. infrahub_sdk/ctl/graphql.py +15 -4
  155. infrahub_sdk/ctl/utils.py +2 -2
  156. infrahub_sdk/enums.py +6 -0
  157. infrahub_sdk/graphql/renderers.py +21 -0
  158. infrahub_sdk/graphql/utils.py +85 -0
  159. infrahub_sdk/node/attribute.py +12 -2
  160. infrahub_sdk/node/constants.py +11 -0
  161. infrahub_sdk/node/metadata.py +69 -0
  162. infrahub_sdk/node/node.py +65 -14
  163. infrahub_sdk/node/property.py +3 -0
  164. infrahub_sdk/node/related_node.py +24 -1
  165. infrahub_sdk/node/relationship.py +10 -1
  166. infrahub_sdk/operation.py +2 -2
  167. infrahub_sdk/schema/repository.py +1 -2
  168. infrahub_sdk/transforms.py +2 -2
  169. infrahub_sdk/types.py +18 -2
  170. {infrahub_server-1.7.0b0.dist-info → infrahub_server-1.7.1.dist-info}/METADATA +6 -6
  171. {infrahub_server-1.7.0b0.dist-info → infrahub_server-1.7.1.dist-info}/RECORD +176 -172
  172. {infrahub_server-1.7.0b0.dist-info → infrahub_server-1.7.1.dist-info}/entry_points.txt +0 -1
  173. infrahub_testcontainers/models.py +3 -3
  174. infrahub_testcontainers/performance_test.py +1 -1
  175. infrahub/graphql/models.py +0 -36
  176. {infrahub_server-1.7.0b0.dist-info → infrahub_server-1.7.1.dist-info}/WHEEL +0 -0
  177. {infrahub_server-1.7.0b0.dist-info → infrahub_server-1.7.1.dist-info}/licenses/LICENSE.txt +0 -0
@@ -14,7 +14,7 @@ from infrahub.core.changelog.models import (
14
14
  )
15
15
  from infrahub.core.constants import InfrahubKind, MetadataOptions, RelationshipDirection, RelationshipStatus
16
16
  from infrahub.core.constants.database import DatabaseEdgeType
17
- from infrahub.core.query import Query, QueryType
17
+ from infrahub.core.query import Query, QueryResult, QueryType
18
18
  from infrahub.core.query.subquery import build_subquery_filter, build_subquery_order
19
19
  from infrahub.core.timestamp import Timestamp
20
20
  from infrahub.core.utils import extract_field_filters
@@ -159,7 +159,7 @@ class RelationshipQuery(Query):
159
159
  branch: Branch | None = None,
160
160
  at: Timestamp | str | None = None,
161
161
  **kwargs,
162
- ):
162
+ ) -> None:
163
163
  if not source and not source_id:
164
164
  raise ValueError("Either source or source_id must be provided.")
165
165
  if not rel and not rel_id:
@@ -278,7 +278,7 @@ class RelationshipCreateQuery(RelationshipQuery):
278
278
  destination: Node = None,
279
279
  destination_id: UUID | None = None,
280
280
  **kwargs,
281
- ):
281
+ ) -> None:
282
282
  if not destination and not destination_id:
283
283
  raise ValueError("Either destination or destination_id must be provided.")
284
284
 
@@ -399,7 +399,7 @@ class RelationshipUpdatePropertyQuery(RelationshipQuery):
399
399
  flag_properties_to_update: dict[str, bool],
400
400
  node_properties_to_update: dict[str, str],
401
401
  **kwargs,
402
- ):
402
+ ) -> None:
403
403
  if not flag_properties_to_update and not node_properties_to_update:
404
404
  raise ValueError("Either flag_properties_to_update or node_properties_to_update must be set")
405
405
  self.flag_properties_to_update = flag_properties_to_update
@@ -557,7 +557,7 @@ class RelationshipDeleteQuery(RelationshipQuery):
557
557
  insert_return = False
558
558
  raise_error_if_empty = False
559
559
 
560
- def __init__(self, source_branch: Branch, destination_branch: Branch, **kwargs):
560
+ def __init__(self, source_branch: Branch, destination_branch: Branch, **kwargs) -> None:
561
561
  self.source_branch = source_branch
562
562
  self.destination_branch = destination_branch
563
563
  super().__init__(**kwargs)
@@ -690,7 +690,7 @@ class RelationshipGetPeerQuery(Query):
690
690
  at: Timestamp | str | None = None,
691
691
  include_metadata: MetadataOptions = MetadataOptions.NONE,
692
692
  **kwargs,
693
- ):
693
+ ) -> None:
694
694
  if not source and not source_ids:
695
695
  raise ValueError("Either source or source_ids must be provided.")
696
696
  if not rel and not rel_type:
@@ -1124,6 +1124,25 @@ class RelationshipGetByIdentifierQuery(Query):
1124
1124
  yield data
1125
1125
 
1126
1126
 
1127
+ @dataclass(frozen=True)
1128
+ class RelationshipCountPerNodeResult:
1129
+ """Result from RelationshipCountPerNodeQuery containing peer count info."""
1130
+
1131
+ peer_uuid: str
1132
+ """UUID of the peer node."""
1133
+
1134
+ count: int
1135
+ """Number of relationship peers for this node."""
1136
+
1137
+ @classmethod
1138
+ def from_db(cls, result: QueryResult) -> RelationshipCountPerNodeResult:
1139
+ """Convert raw QueryResult to typed dataclass."""
1140
+ return cls(
1141
+ peer_uuid=result.get_as_type("peer_node.uuid", str),
1142
+ count=result.get_as_type("nbr_peers", int),
1143
+ )
1144
+
1145
+
1127
1146
  class RelationshipCountPerNodeQuery(Query):
1128
1147
  name = "relationship_count_per_node"
1129
1148
  type: QueryType = QueryType.READ
@@ -1134,7 +1153,7 @@ class RelationshipCountPerNodeQuery(Query):
1134
1153
  identifier: str,
1135
1154
  direction: RelationshipDirection,
1136
1155
  **kwargs,
1137
- ):
1156
+ ) -> None:
1138
1157
  self.node_ids = node_ids
1139
1158
  self.identifier = identifier
1140
1159
  self.direction = direction
@@ -1172,10 +1191,18 @@ class RelationshipCountPerNodeQuery(Query):
1172
1191
  self.order_by = ["peer_node.uuid"]
1173
1192
  self.return_labels = ["peer_node.uuid", "COUNT(peer_node.uuid) as nbr_peers"]
1174
1193
 
1194
+ def get_data(self) -> list[RelationshipCountPerNodeResult]:
1195
+ """Return results as typed dataclass instances.
1196
+
1197
+ Returns:
1198
+ List of RelationshipCountPerNodeResult containing peer count info.
1199
+ """
1200
+ return [RelationshipCountPerNodeResult.from_db(result) for result in self.get_results()]
1201
+
1175
1202
  async def get_count_per_peer(self) -> dict[str, int]:
1176
1203
  data: dict[str, int] = {}
1177
- for result in self.results:
1178
- data[result.get("peer_node.uuid")] = result.get("nbr_peers")
1204
+ for item in self.get_data():
1205
+ data[item.peer_uuid] = item.count
1179
1206
 
1180
1207
  for node_id in self.node_ids:
1181
1208
  if node_id not in data:
@@ -1184,6 +1211,33 @@ class RelationshipCountPerNodeQuery(Query):
1184
1211
  return data
1185
1212
 
1186
1213
 
1214
+ @dataclass(frozen=True)
1215
+ class RelationshipDeleteAllQueryResult:
1216
+ """Result from RelationshipDeleteAllQuery containing deleted relationship info."""
1217
+
1218
+ uuid: str
1219
+ """UUID of the peer node whose relationship was deleted."""
1220
+
1221
+ kind: str
1222
+ """Kind/type of the peer node."""
1223
+
1224
+ rel_identifier: str
1225
+ """Relationship schema identifier name."""
1226
+
1227
+ rel_direction: str
1228
+ """Direction of the relationship ("outbound" or "inbound")."""
1229
+
1230
+ @classmethod
1231
+ def from_db(cls, result: QueryResult) -> RelationshipDeleteAllQueryResult:
1232
+ """Convert raw QueryResult to typed dataclass."""
1233
+ return cls(
1234
+ uuid=result.get_as_type("uuid", str),
1235
+ kind=result.get_as_type("kind", str),
1236
+ rel_identifier=result.get_as_type("rel_identifier", str),
1237
+ rel_direction=result.get_as_type("rel_direction", str),
1238
+ )
1239
+
1240
+
1187
1241
  class RelationshipDeleteAllQuery(Query):
1188
1242
  """
1189
1243
  Delete all relationships linked to a given node on a given branch at a given time. For every IS_RELATED edge:
@@ -1198,7 +1252,7 @@ class RelationshipDeleteAllQuery(Query):
1198
1252
  type = QueryType.WRITE
1199
1253
  insert_return = False
1200
1254
 
1201
- def __init__(self, node_id: str, **kwargs):
1255
+ def __init__(self, node_id: str, **kwargs) -> None:
1202
1256
  self.node_id = node_id
1203
1257
  super().__init__(**kwargs)
1204
1258
 
@@ -1313,51 +1367,54 @@ class RelationshipDeleteAllQuery(Query):
1313
1367
  "peer_node_metadata_update": peer_node_metadata_update,
1314
1368
  }
1315
1369
  self.add_to_query(query)
1370
+ self.return_labels = ["uuid", "kind", "rel_identifier", "rel_direction"]
1371
+
1372
+ def get_data(self) -> list[RelationshipDeleteAllQueryResult]:
1373
+ """Return results as typed dataclass instances.
1374
+
1375
+ Returns:
1376
+ List of RelationshipDeleteAllQueryResult containing deleted relationship info.
1377
+ """
1378
+ return [RelationshipDeleteAllQueryResult.from_db(result) for result in self.get_results()]
1316
1379
 
1317
1380
  def get_deleted_relationships_changelog(
1318
1381
  self, node_schema: NodeSchema
1319
1382
  ) -> list[RelationshipCardinalityOneChangelog | RelationshipCardinalityManyChangelog]:
1320
- rel_identifier_to_changelog_mapper = {}
1383
+ rel_identifier_to_changelog_mapper: dict[str, ChangelogRelationshipMapper] = {}
1321
1384
 
1322
- for result in self.get_results():
1323
- peer_uuid = result.data["uuid"]
1324
- if peer_uuid == self.node_id:
1385
+ for item in self.get_data():
1386
+ if item.uuid == self.node_id:
1325
1387
  continue
1326
1388
 
1327
- rel_identifier = result.data["rel_identifier"]
1328
- kind = result.data["kind"]
1329
1389
  deleted_rel_schemas = [
1330
- rel_schema for rel_schema in node_schema.relationships if rel_schema.identifier == rel_identifier
1390
+ rel_schema for rel_schema in node_schema.relationships if rel_schema.identifier == item.rel_identifier
1331
1391
  ]
1332
1392
 
1333
1393
  if len(deleted_rel_schemas) == 0:
1334
1394
  continue # TODO Unidirectional relationship changelog should be handled, cf IFC-1319.
1335
1395
 
1336
1396
  if len(deleted_rel_schemas) > 2:
1337
- log.error(f"Duplicated relationship schema with identifier {rel_identifier}")
1397
+ log.error(f"Duplicated relationship schema with identifier {item.rel_identifier}")
1338
1398
  continue
1339
1399
 
1340
1400
  if len(deleted_rel_schemas) == 2:
1341
1401
  # Hierarchical schema nodes have 2 relationships with `parent_child` identifiers,
1342
1402
  # which are differentiated by their direction within the database.
1343
- # assert rel_identifier != PARENT_CHILD_IDENTIFIER
1344
-
1345
- rel_direction = result.data["rel_direction"]
1346
1403
  deleted_rel_schema = (
1347
1404
  deleted_rel_schemas[0]
1348
- if deleted_rel_schemas[0].direction.value == rel_direction
1405
+ if deleted_rel_schemas[0].direction.value == item.rel_direction
1349
1406
  else deleted_rel_schemas[1]
1350
1407
  )
1351
1408
  else:
1352
1409
  deleted_rel_schema = deleted_rel_schemas[0]
1353
1410
 
1354
1411
  try:
1355
- changelog_mapper = rel_identifier_to_changelog_mapper[rel_identifier]
1412
+ changelog_mapper = rel_identifier_to_changelog_mapper[item.rel_identifier]
1356
1413
  except KeyError:
1357
1414
  changelog_mapper = ChangelogRelationshipMapper(schema=deleted_rel_schema)
1358
- rel_identifier_to_changelog_mapper[rel_identifier] = changelog_mapper
1415
+ rel_identifier_to_changelog_mapper[item.rel_identifier] = changelog_mapper
1359
1416
 
1360
- changelog_mapper.delete_relationship(peer_id=peer_uuid, peer_kind=kind, rel_schema=deleted_rel_schema)
1417
+ changelog_mapper.delete_relationship(peer_id=item.uuid, peer_kind=item.kind, rel_schema=deleted_rel_schema)
1361
1418
 
1362
1419
  return [changelog_mapper.changelog for changelog_mapper in rel_identifier_to_changelog_mapper.values()]
1363
1420
 
@@ -1371,7 +1428,7 @@ class GetAllPeersIds(Query):
1371
1428
  type: QueryType = QueryType.READ
1372
1429
  insert_return = False
1373
1430
 
1374
- def __init__(self, node_id: str, exclude_identifiers: list[str], **kwargs):
1431
+ def __init__(self, node_id: str, exclude_identifiers: list[str], **kwargs) -> None:
1375
1432
  self.node_id = node_id
1376
1433
  self.exclude_identifiers = exclude_identifiers
1377
1434
  super().__init__(**kwargs)
@@ -1,24 +1,74 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from dataclasses import dataclass
3
4
  from typing import TYPE_CHECKING, Any, Generator
4
5
 
5
- from pydantic import BaseModel, ConfigDict
6
-
7
6
  from infrahub.core import registry
8
7
  from infrahub.core.constants import InfrahubKind, RelationshipStatus
9
- from infrahub.core.query import Query, QueryType
8
+ from infrahub.core.query import Query, QueryResult, QueryType
10
9
 
11
10
  if TYPE_CHECKING:
12
11
  from infrahub.core.protocols import CoreNumberPool
13
12
  from infrahub.database import InfrahubDatabase
14
13
 
15
14
 
16
- class NumberPoolIdentifierData(BaseModel):
17
- model_config = ConfigDict(frozen=True)
15
+ @dataclass(frozen=True)
16
+ class NumberPoolIdentifierData:
17
+ """Result containing a pool reservation value and its identifier."""
18
18
 
19
19
  value: int
20
20
  identifier: str
21
21
 
22
+ @classmethod
23
+ def from_db(cls, result: QueryResult) -> NumberPoolIdentifierData:
24
+ """Convert raw QueryResult to typed dataclass."""
25
+ return cls(
26
+ value=result.get_as_type("value", return_type=int),
27
+ identifier=result.get_as_type("identifier", return_type=str),
28
+ )
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class PoolIdentifierResult:
33
+ """Result from pool identifier queries containing allocation and identifier data."""
34
+
35
+ allocated_uuid: str
36
+ """UUID of the allocated resource (address or prefix)."""
37
+
38
+ identifier: str
39
+ """Identifier used for the reservation."""
40
+
41
+ @classmethod
42
+ def from_db(cls, result: QueryResult) -> PoolIdentifierResult:
43
+ """Convert raw QueryResult to typed dataclass."""
44
+ return cls(
45
+ allocated_uuid=result.get_as_type("allocated_uuid", str),
46
+ identifier=result.get_as_type("identifier", str),
47
+ )
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class NumberPoolAllocatedResult:
52
+ """Result from NumberPoolGetAllocated containing allocated number info."""
53
+
54
+ id: str
55
+ """UUID of the node with the allocated number."""
56
+
57
+ branch: str
58
+ """Branch where the allocation exists."""
59
+
60
+ value: int
61
+ """The allocated number value."""
62
+
63
+ @classmethod
64
+ def from_db(cls, result: QueryResult) -> NumberPoolAllocatedResult:
65
+ """Convert raw QueryResult to typed dataclass."""
66
+ return cls(
67
+ id=result.get_as_type("id", str),
68
+ branch=result.get_as_type("branch", str),
69
+ value=result.get_as_type("value", int),
70
+ )
71
+
22
72
 
23
73
  class IPAddressPoolGetIdentifiers(Query):
24
74
  name = "ipaddresspool_get_identifiers"
@@ -44,7 +94,15 @@ class IPAddressPoolGetIdentifiers(Query):
44
94
  WHERE allocated.uuid in $addresses
45
95
  """ % {"ipaddress_pool": InfrahubKind.IPADDRESSPOOL}
46
96
  self.add_to_query(query)
47
- self.return_labels = ["allocated", "reservation"]
97
+ self.return_labels = ["allocated.uuid AS allocated_uuid", "reservation.identifier AS identifier"]
98
+
99
+ def get_data(self) -> list[PoolIdentifierResult]:
100
+ """Return results as typed dataclass instances.
101
+
102
+ Returns:
103
+ List of PoolIdentifierResult containing allocation and identifier data.
104
+ """
105
+ return [PoolIdentifierResult.from_db(result) for result in self.get_results()]
48
106
 
49
107
 
50
108
  class IPAddressPoolGetReserved(Query):
@@ -159,6 +217,14 @@ class NumberPoolGetAllocated(Query):
159
217
  self.return_labels = ["n.uuid as id", "hv.branch as branch", "av.value as value"]
160
218
  self.order_by = ["av.value"]
161
219
 
220
+ def get_data(self) -> list[NumberPoolAllocatedResult]:
221
+ """Return results as typed dataclass instances.
222
+
223
+ Returns:
224
+ List of NumberPoolAllocatedResult containing allocated number info.
225
+ """
226
+ return [NumberPoolAllocatedResult.from_db(result) for result in self.get_results()]
227
+
162
228
 
163
229
  class NumberPoolGetReserved(Query):
164
230
  name = "numberpool_get_reserved"
@@ -205,17 +271,31 @@ class NumberPoolGetReserved(Query):
205
271
  self.return_labels = ["reservation.value AS value", "r.identifier AS identifier"]
206
272
 
207
273
  def get_reservation(self) -> int | None:
274
+ """Return the reserved value for a single identifier.
275
+
276
+ Returns:
277
+ The reserved integer value, or None if no reservation exists.
278
+ """
208
279
  result = self.get_result()
209
280
  if result:
210
281
  return result.get_as_optional_type("value", return_type=int)
211
282
  return None
212
283
 
284
+ def get_data(self) -> list[NumberPoolIdentifierData]:
285
+ """Return all reservations as typed dataclass instances.
286
+
287
+ Returns:
288
+ List of NumberPoolIdentifierData containing value and identifier.
289
+ """
290
+ return [NumberPoolIdentifierData.from_db(result) for result in self.get_results()]
291
+
213
292
  def get_reservations(self) -> Generator[NumberPoolIdentifierData]:
214
- for result in self.results:
215
- yield NumberPoolIdentifierData.model_construct(
216
- value=result.get_as_type("value", return_type=int),
217
- identifier=result.get_as_type("identifier", return_type=str),
218
- )
293
+ """Yield reservations as typed dataclass instances.
294
+
295
+ Yields:
296
+ NumberPoolIdentifierData for each reservation.
297
+ """
298
+ yield from self.get_data()
219
299
 
220
300
 
221
301
  class PoolChangeReserved(Query):
@@ -282,7 +362,6 @@ This will be especially important as we want to support upsert with NumberPool
282
362
  class NumberPoolGetUsed(Query):
283
363
  name = "number_pool_get_used"
284
364
  type = QueryType.READ
285
- return_model = NumberPoolIdentifierData
286
365
 
287
366
  def __init__(
288
367
  self,
@@ -331,11 +410,13 @@ class NumberPoolGetUsed(Query):
331
410
  self.order_by = ["value"]
332
411
 
333
412
  def iter_results(self) -> Generator[NumberPoolIdentifierData]:
334
- for result in self.results:
335
- yield self.return_model.model_construct(
336
- value=result.get_as_type("value", return_type=int),
337
- identifier=result.get_as_type("identifier", return_type=str),
338
- )
413
+ """Yield used pool values as typed dataclass instances.
414
+
415
+ Yields:
416
+ NumberPoolIdentifierData for each used value in the pool.
417
+ """
418
+ for result in self.get_results():
419
+ yield NumberPoolIdentifierData.from_db(result)
339
420
 
340
421
 
341
422
  class NumberPoolSetReserved(Query):
@@ -405,7 +486,15 @@ class PrefixPoolGetIdentifiers(Query):
405
486
  WHERE allocated.uuid in $prefixes
406
487
  """ % {"ipaddress_pool": InfrahubKind.IPPREFIXPOOL}
407
488
  self.add_to_query(query)
408
- self.return_labels = ["allocated", "reservation"]
489
+ self.return_labels = ["allocated.uuid AS allocated_uuid", "reservation.identifier AS identifier"]
490
+
491
+ def get_data(self) -> list[PoolIdentifierResult]:
492
+ """Return results as typed dataclass instances.
493
+
494
+ Returns:
495
+ List of PoolIdentifierResult containing allocation and identifier data.
496
+ """
497
+ return [PoolIdentifierResult.from_db(result) for result in self.get_results()]
409
498
 
410
499
 
411
500
  class PrefixPoolGetReserved(Query):
@@ -22,7 +22,7 @@ class NodeToValidate:
22
22
 
23
23
 
24
24
  class RelationshipCountConstraint(RelationshipManagerConstraintInterface):
25
- def __init__(self, db: InfrahubDatabase, branch: Branch | None = None):
25
+ def __init__(self, db: InfrahubDatabase, branch: Branch | None = None) -> None:
26
26
  self.db = db
27
27
  self.branch = branch
28
28
 
@@ -23,7 +23,7 @@ class NodeToValidate:
23
23
 
24
24
 
25
25
  class RelationshipPeerKindConstraint(RelationshipManagerConstraintInterface):
26
- def __init__(self, db: InfrahubDatabase, branch: Branch | None = None):
26
+ def __init__(self, db: InfrahubDatabase, branch: Branch | None = None) -> None:
27
27
  self.db = db
28
28
  self.branch = branch
29
29
 
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
16
16
 
17
17
 
18
18
  class RelationshipPeerParentConstraint(RelationshipManagerConstraintInterface):
19
- def __init__(self, db: InfrahubDatabase, branch: Branch | None = None):
19
+ def __init__(self, db: InfrahubDatabase, branch: Branch | None = None) -> None:
20
20
  self.db = db
21
21
  self.branch = branch
22
22
 
@@ -25,7 +25,7 @@ class NodeToValidate:
25
25
 
26
26
 
27
27
  class RelationshipPeerRelativesConstraint(RelationshipManagerConstraintInterface):
28
- def __init__(self, db: InfrahubDatabase, branch: Branch | None = None):
28
+ def __init__(self, db: InfrahubDatabase, branch: Branch | None = None) -> None:
29
29
  self.db = db
30
30
  self.branch = branch
31
31
 
@@ -18,7 +18,7 @@ if TYPE_CHECKING:
18
18
 
19
19
 
20
20
  class RelationshipProfilesKindConstraint(RelationshipManagerConstraintInterface):
21
- def __init__(self, db: InfrahubDatabase, branch: Branch | None = None):
21
+ def __init__(self, db: InfrahubDatabase, branch: Branch | None = None) -> None:
22
22
  self.db = db
23
23
  self.branch = branch
24
24
  self.schema_branch = registry.schema.get_schema_branch(branch.name if branch else registry.default_branch)
@@ -28,7 +28,7 @@ class RelationshipProfileRemovalConstraint(RelationshipManagerConstraintInterfac
28
28
  In both cases, it will perform checks only if peers are being removed from the relationship being changed.
29
29
  """
30
30
 
31
- def __init__(self, db: InfrahubDatabase, branch: Branch | None = None):
31
+ def __init__(self, db: InfrahubDatabase, branch: Branch | None = None) -> None:
32
32
  self.db = db
33
33
  self.branch = branch
34
34
  self.schema_branch = registry.schema.get_schema_branch(branch.name if branch else registry.default_branch)
@@ -1181,6 +1181,7 @@ class RelationshipManager:
1181
1181
  db: InfrahubDatabase,
1182
1182
  process_delete: bool = True,
1183
1183
  user_id: str = SYSTEM_USER_ID,
1184
+ at: Timestamp | None = None,
1184
1185
  ) -> bool:
1185
1186
  """Replace and Update the list of relationships with this one."""
1186
1187
  if not isinstance(data, list):
@@ -1189,6 +1190,7 @@ class RelationshipManager:
1189
1190
  list_data = data
1190
1191
 
1191
1192
  await self._validate_hierarchy()
1193
+ update_at = Timestamp(at)
1192
1194
 
1193
1195
  # Reset the list of relationship and save the previous one to see if we can reuse some
1194
1196
  previous_relationships = {rel.peer_id: rel for rel in await self.get_relationships(db=db) if rel.peer_id}
@@ -1211,7 +1213,7 @@ class RelationshipManager:
1211
1213
  if previous_relationships:
1212
1214
  if process_delete:
1213
1215
  for rel in previous_relationships.values():
1214
- await rel.delete(db=db, user_id=user_id)
1216
+ await rel.delete(db=db, at=update_at, user_id=user_id)
1215
1217
  changed = True
1216
1218
  continue
1217
1219
 
@@ -1231,7 +1233,11 @@ class RelationshipManager:
1231
1233
  # If the item is not present in the previous list of relationship, we create a new one.
1232
1234
  self._relationships.append(
1233
1235
  await self.rel_class(
1234
- schema=self.schema, branch=self.branch, source_kind=self.node.get_kind(), at=self.at, node=self.node
1236
+ schema=self.schema,
1237
+ branch=self.branch,
1238
+ source_kind=self.node.get_kind(),
1239
+ at=update_at,
1240
+ node=self.node,
1235
1241
  ).new(db=db, data=item)
1236
1242
  )
1237
1243
  changed = True
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import sys
4
- from typing import Self
4
+ from typing import Any, Self
5
5
 
6
6
  from pydantic import ConfigDict, Field, model_validator
7
7
 
@@ -24,6 +24,33 @@ def get_attribute_parameters_class_for_kind(kind: str) -> type[AttributeParamete
24
24
  class AttributeParameters(HashableModel):
25
25
  model_config = ConfigDict(extra="forbid")
26
26
 
27
+ @classmethod
28
+ def convert_from(cls, source: AttributeParameters) -> Self:
29
+ """Convert from another AttributeParameters subclass.
30
+
31
+ Args:
32
+ source: The source AttributeParameters instance to convert from
33
+
34
+ Returns:
35
+ A new instance of the target class with compatible fields populated
36
+ """
37
+ source_data = source.model_dump()
38
+ return cls.convert_from_dict(source_data=source_data)
39
+
40
+ @classmethod
41
+ def convert_from_dict(cls, source_data: dict[str, Any]) -> Self:
42
+ """Convert from a dictionary to the target class.
43
+
44
+ Args:
45
+ source_data: The source dictionary to convert from
46
+
47
+ Returns:
48
+ A new instance of the target class with compatible fields populated
49
+ """
50
+ target_fields = set(cls.model_fields.keys())
51
+ filtered_data = {k: v for k, v in source_data.items() if k in target_fields}
52
+ return cls(**filtered_data)
53
+
27
54
 
28
55
  class TextAttributeParameters(AttributeParameters):
29
56
  regex: str | None = Field(
@@ -114,13 +114,20 @@ class AttributeSchema(GeneratedAttributeSchema):
114
114
  @field_validator("parameters", mode="before")
115
115
  @classmethod
116
116
  def set_parameters_type(cls, value: Any, info: ValidationInfo) -> Any:
117
- """Override parameters class if using base AttributeParameters class and should be using a subclass"""
117
+ """Override parameters class if using base AttributeParameters class and should be using a subclass.
118
+
119
+ This validator handles parameter type conversion when an attribute's kind changes.
120
+ Fields from the source that don't exist in the target are silently dropped.
121
+ Fields with the same name in both classes are preserved.
122
+ """
118
123
  kind = info.data["kind"]
119
124
  expected_parameters_class = get_attribute_parameters_class_for_kind(kind=kind)
120
125
  if value is None:
121
126
  return expected_parameters_class()
122
127
  if not isinstance(value, expected_parameters_class) and isinstance(value, AttributeParameters):
123
- return expected_parameters_class(**value.model_dump())
128
+ return expected_parameters_class.convert_from(value)
129
+ if isinstance(value, dict):
130
+ return expected_parameters_class.convert_from_dict(source_data=value)
124
131
  return value
125
132
 
126
133
  @model_validator(mode="after")
@@ -238,19 +245,6 @@ class TextAttributeSchema(AttributeSchema):
238
245
  json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
239
246
  )
240
247
 
241
- @model_validator(mode="after")
242
- def reconcile_parameters(self) -> Self:
243
- if self.regex != self.parameters.regex:
244
- final_regex = self.parameters.regex if self.parameters.regex is not None else self.regex
245
- self.regex = self.parameters.regex = final_regex
246
- if self.min_length != self.parameters.min_length:
247
- final_min_length = self.parameters.min_length if self.parameters.min_length is not None else self.min_length
248
- self.min_length = self.parameters.min_length = final_min_length
249
- if self.max_length != self.parameters.max_length:
250
- final_max_length = self.parameters.max_length if self.parameters.max_length is not None else self.max_length
251
- self.max_length = self.parameters.max_length = final_max_length
252
- return self
253
-
254
248
  def get_regex(self) -> str | None:
255
249
  return self.parameters.regex
256
250
 
@@ -263,6 +263,9 @@ class BaseNodeSchema(GeneratedBaseNodeSchema):
263
263
  ) -> AttributeSchema | RelationshipSchema | None: ...
264
264
 
265
265
  def get_field(self, name: str, raise_on_error: bool = True) -> AttributeSchema | RelationshipSchema | None:
266
+ if name in NODE_PROPERTY_ATTRIBUTES:
267
+ return self.get_attribute(name=name)
268
+
266
269
  if field := self.get_attribute_or_none(name=name):
267
270
  return field
268
271
 
@@ -1,4 +1,4 @@
1
- from typing import Any
1
+ from typing import Any, TypedDict
2
2
 
3
3
  from infrahub.actions.schema import (
4
4
  core_action,
@@ -93,7 +93,13 @@ from .template import core_object_component_template, core_object_template
93
93
  from .transform import core_transform, core_transform_jinja2, core_transform_python
94
94
  from .webhook import core_custom_webhook, core_standard_webhook, core_webhook
95
95
 
96
- core_models_mixed: dict[str, list] = {
96
+
97
+ class CoreModelsMixedType(TypedDict):
98
+ generics: list[GenericSchema]
99
+ nodes: list[NodeSchema]
100
+
101
+
102
+ core_models_mixed: CoreModelsMixedType = {
97
103
  "generics": [
98
104
  core_action,
99
105
  core_trigger_rule,