infrahub-server 1.7.0rc0__py3-none-any.whl → 1.7.2__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 (124) hide show
  1. infrahub/actions/gather.py +2 -2
  2. infrahub/api/query.py +3 -2
  3. infrahub/api/schema.py +5 -0
  4. infrahub/api/transformation.py +3 -3
  5. infrahub/cli/db.py +6 -2
  6. infrahub/computed_attribute/gather.py +2 -0
  7. infrahub/config.py +2 -2
  8. infrahub/core/attribute.py +21 -2
  9. infrahub/core/branch/models.py +11 -117
  10. infrahub/core/branch/tasks.py +7 -3
  11. infrahub/core/diff/merger/merger.py +5 -1
  12. infrahub/core/diff/model/path.py +43 -0
  13. infrahub/core/graph/__init__.py +1 -1
  14. infrahub/core/graph/index.py +2 -0
  15. infrahub/core/initialization.py +2 -1
  16. infrahub/core/ipam/resource_allocator.py +229 -0
  17. infrahub/core/migrations/graph/__init__.py +10 -0
  18. infrahub/core/migrations/graph/m014_remove_index_attr_value.py +3 -2
  19. infrahub/core/migrations/graph/m015_diff_format_update.py +3 -2
  20. infrahub/core/migrations/graph/m016_diff_delete_bug_fix.py +3 -2
  21. infrahub/core/migrations/graph/m017_add_core_profile.py +6 -4
  22. infrahub/core/migrations/graph/m018_uniqueness_nulls.py +3 -4
  23. infrahub/core/migrations/graph/m020_duplicate_edges.py +3 -3
  24. infrahub/core/migrations/graph/m025_uniqueness_nulls.py +3 -4
  25. infrahub/core/migrations/graph/m026_0000_prefix_fix.py +4 -5
  26. infrahub/core/migrations/graph/m028_delete_diffs.py +3 -2
  27. infrahub/core/migrations/graph/m029_duplicates_cleanup.py +3 -2
  28. infrahub/core/migrations/graph/m031_check_number_attributes.py +4 -3
  29. infrahub/core/migrations/graph/m032_cleanup_orphaned_branch_relationships.py +3 -2
  30. infrahub/core/migrations/graph/m034_find_orphaned_schema_fields.py +3 -2
  31. infrahub/core/migrations/graph/m035_orphan_relationships.py +3 -3
  32. infrahub/core/migrations/graph/m036_drop_attr_value_index.py +3 -2
  33. infrahub/core/migrations/graph/m037_index_attr_vals.py +3 -2
  34. infrahub/core/migrations/graph/m038_redo_0000_prefix_fix.py +4 -5
  35. infrahub/core/migrations/graph/m039_ipam_reconcile.py +3 -2
  36. infrahub/core/migrations/graph/m041_deleted_dup_edges.py +3 -2
  37. infrahub/core/migrations/graph/m042_profile_attrs_in_db.py +5 -4
  38. infrahub/core/migrations/graph/m043_create_hfid_display_label_in_db.py +12 -5
  39. infrahub/core/migrations/graph/m044_backfill_hfid_display_label_in_db.py +15 -4
  40. infrahub/core/migrations/graph/m045_backfill_hfid_display_label_in_db_profile_template.py +10 -4
  41. infrahub/core/migrations/graph/m046_fill_agnostic_hfid_display_labels.py +6 -5
  42. infrahub/core/migrations/graph/m047_backfill_or_null_display_label.py +19 -5
  43. infrahub/core/migrations/graph/m048_undelete_rel_props.py +6 -4
  44. infrahub/core/migrations/graph/m049_remove_is_visible_relationship.py +3 -3
  45. infrahub/core/migrations/graph/m050_backfill_vertex_metadata.py +3 -3
  46. infrahub/core/migrations/graph/m051_subtract_branched_from_microsecond.py +39 -0
  47. infrahub/core/migrations/graph/m052_fix_global_branch_level.py +51 -0
  48. infrahub/core/migrations/graph/m053_fix_branch_level_zero.py +61 -0
  49. infrahub/core/migrations/graph/m054_cleanup_orphaned_nodes.py +87 -0
  50. infrahub/core/migrations/graph/m055_remove_webhook_validate_certificates_default.py +86 -0
  51. infrahub/core/migrations/runner.py +6 -3
  52. infrahub/core/migrations/schema/attribute_kind_update.py +8 -11
  53. infrahub/core/migrations/schema/attribute_supports_profile.py +3 -8
  54. infrahub/core/migrations/schema/models.py +8 -0
  55. infrahub/core/migrations/schema/node_attribute_add.py +24 -29
  56. infrahub/core/migrations/schema/tasks.py +7 -1
  57. infrahub/core/migrations/shared.py +37 -30
  58. infrahub/core/node/__init__.py +2 -1
  59. infrahub/core/node/lock_utils.py +23 -2
  60. infrahub/core/node/resource_manager/ip_address_pool.py +5 -11
  61. infrahub/core/node/resource_manager/ip_prefix_pool.py +5 -21
  62. infrahub/core/node/resource_manager/number_pool.py +109 -39
  63. infrahub/core/query/__init__.py +7 -1
  64. infrahub/core/query/branch.py +18 -2
  65. infrahub/core/query/ipam.py +629 -40
  66. infrahub/core/query/node.py +128 -0
  67. infrahub/core/query/resource_manager.py +114 -1
  68. infrahub/core/relationship/model.py +9 -3
  69. infrahub/core/schema/attribute_parameters.py +28 -1
  70. infrahub/core/schema/attribute_schema.py +9 -2
  71. infrahub/core/schema/definitions/core/webhook.py +0 -1
  72. infrahub/core/schema/definitions/internal.py +7 -4
  73. infrahub/core/schema/manager.py +50 -38
  74. infrahub/core/validators/attribute/kind.py +5 -2
  75. infrahub/core/validators/determiner.py +4 -0
  76. infrahub/graphql/analyzer.py +3 -1
  77. infrahub/graphql/app.py +7 -10
  78. infrahub/graphql/execution.py +95 -0
  79. infrahub/graphql/manager.py +8 -2
  80. infrahub/graphql/mutations/proposed_change.py +15 -0
  81. infrahub/graphql/parser.py +10 -7
  82. infrahub/graphql/queries/ipam.py +20 -25
  83. infrahub/graphql/queries/search.py +29 -9
  84. infrahub/lock.py +7 -0
  85. infrahub/proposed_change/tasks.py +2 -0
  86. infrahub/services/adapters/cache/redis.py +7 -0
  87. infrahub/services/adapters/http/httpx.py +27 -0
  88. infrahub/trigger/catalogue.py +2 -0
  89. infrahub/trigger/models.py +73 -4
  90. infrahub/trigger/setup.py +1 -1
  91. infrahub/trigger/system.py +36 -0
  92. infrahub/webhook/models.py +4 -2
  93. infrahub/webhook/tasks.py +2 -2
  94. infrahub/workflows/initialization.py +2 -2
  95. infrahub_sdk/analyzer.py +2 -2
  96. infrahub_sdk/branch.py +12 -39
  97. infrahub_sdk/checks.py +4 -4
  98. infrahub_sdk/client.py +36 -0
  99. infrahub_sdk/ctl/cli_commands.py +2 -1
  100. infrahub_sdk/ctl/graphql.py +15 -4
  101. infrahub_sdk/ctl/utils.py +2 -2
  102. infrahub_sdk/enums.py +6 -0
  103. infrahub_sdk/graphql/renderers.py +21 -0
  104. infrahub_sdk/graphql/utils.py +85 -0
  105. infrahub_sdk/node/attribute.py +12 -2
  106. infrahub_sdk/node/constants.py +11 -0
  107. infrahub_sdk/node/metadata.py +69 -0
  108. infrahub_sdk/node/node.py +65 -14
  109. infrahub_sdk/node/property.py +3 -0
  110. infrahub_sdk/node/related_node.py +24 -1
  111. infrahub_sdk/node/relationship.py +10 -1
  112. infrahub_sdk/operation.py +2 -2
  113. infrahub_sdk/schema/repository.py +1 -2
  114. infrahub_sdk/transforms.py +2 -2
  115. infrahub_sdk/types.py +18 -2
  116. {infrahub_server-1.7.0rc0.dist-info → infrahub_server-1.7.2.dist-info}/METADATA +8 -8
  117. {infrahub_server-1.7.0rc0.dist-info → infrahub_server-1.7.2.dist-info}/RECORD +123 -114
  118. {infrahub_server-1.7.0rc0.dist-info → infrahub_server-1.7.2.dist-info}/entry_points.txt +0 -1
  119. infrahub_testcontainers/docker-compose-cluster.test.yml +16 -10
  120. infrahub_testcontainers/docker-compose.test.yml +11 -10
  121. infrahub_testcontainers/performance_test.py +1 -1
  122. infrahub/pools/address.py +0 -16
  123. {infrahub_server-1.7.0rc0.dist-info → infrahub_server-1.7.2.dist-info}/WHEEL +0 -0
  124. {infrahub_server-1.7.0rc0.dist-info → infrahub_server-1.7.2.dist-info}/licenses/LICENSE.txt +0 -0
@@ -2180,6 +2180,134 @@ WITH %(tracked_vars)s,
2180
2180
  return [str(result.get("n.uuid")) for result in self.get_results()]
2181
2181
 
2182
2182
 
2183
+ @dataclass(frozen=True)
2184
+ class NodeGetListByAttributeValueQueryResult:
2185
+ """Result from NodeGetListByAttributeValueQuery."""
2186
+
2187
+ uuid: str
2188
+ kind: str
2189
+
2190
+
2191
+ class NodeGetListByAttributeValueQuery(Query):
2192
+ """Query to find nodes by searching attribute values.
2193
+
2194
+ This query is optimized for search operations by starting from the AttributeValueIndexed
2195
+ nodes and using a TEXT index for efficient CONTAINS searches. This approach is more
2196
+ efficient than the standard NodeGetListQuery when searching for values across all nodes.
2197
+ """
2198
+
2199
+ name = "node_get_list_by_attribute_value"
2200
+ type = QueryType.READ
2201
+
2202
+ def __init__(
2203
+ self,
2204
+ search_value: str,
2205
+ kinds: list[str] | None = None,
2206
+ partial_match: bool = True,
2207
+ **kwargs: Any,
2208
+ ) -> None:
2209
+ self.search_value = search_value
2210
+ self.kinds = kinds
2211
+ self.partial_match = partial_match
2212
+
2213
+ super().__init__(**kwargs)
2214
+
2215
+ async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: # noqa: ARG002
2216
+ self.order_by = ["n.uuid"]
2217
+ self.return_labels = ["DISTINCT n.uuid as uuid", "n.kind as kind"]
2218
+
2219
+ branch_filter, branch_params = self.branch.get_query_filter_path(
2220
+ at=self.at, branch_agnostic=self.branch_agnostic
2221
+ )
2222
+ self.params.update(branch_params)
2223
+
2224
+ # Build search values for case-insensitive matching without using toLower/toString
2225
+ # which would disable index lookup. We search for four case variations:
2226
+ # 1. Original (as provided), 2. lowercase, 3. UPPERCASE, 4. Title Case (first char upper, rest lower)
2227
+ search_original = self.search_value
2228
+ search_lower = self.search_value.lower()
2229
+ search_upper = self.search_value.upper()
2230
+ search_title = self.search_value.capitalize()
2231
+
2232
+ # Build the search predicate based on partial_match
2233
+ # We avoid toLower/toString to allow TEXT index usage
2234
+ if self.partial_match:
2235
+ # Use CONTAINS with multiple case variations to leverage TEXT index
2236
+ search_predicate = (
2237
+ "(av.value CONTAINS $search_original OR av.value CONTAINS $search_lower "
2238
+ "OR av.value CONTAINS $search_upper OR av.value CONTAINS $search_title)"
2239
+ )
2240
+ else:
2241
+ # Exact match with case variations
2242
+ search_predicate = (
2243
+ "(av.value = $search_original OR av.value = $search_lower "
2244
+ "OR av.value = $search_upper OR av.value = $search_title)"
2245
+ )
2246
+
2247
+ self.params["search_original"] = search_original
2248
+ self.params["search_lower"] = search_lower
2249
+ self.params["search_upper"] = search_upper
2250
+ self.params["search_title"] = search_title
2251
+
2252
+ # Build kind filter if specified
2253
+ kind_filter = ""
2254
+ if self.kinds:
2255
+ kind_filter = "AND any(l IN labels(n) WHERE l in $kinds)"
2256
+ self.params["kinds"] = self.kinds
2257
+
2258
+ # The query starts from AttributeValueIndexed nodes to leverage the TEXT index
2259
+ # This approach is more efficient for search operations as it:
2260
+ # 1. Starts from AttributeValueIndexed nodes (smaller set when filtered)
2261
+ # 2. Traverses from matching values back to their owning nodes
2262
+ # 3. Filters nodes by branch and status
2263
+ query = """
2264
+ // --------------------------
2265
+ // start with all possible Node-Attribute-AttributeValue combinations
2266
+ // --------------------------
2267
+ MATCH (av:AttributeValueIndexed)<-[:HAS_VALUE]-(attr:Attribute)<-[:HAS_ATTRIBUTE]-(n)
2268
+ WHERE %(search_predicate)s %(kind_filter)s
2269
+ WITH DISTINCT n, attr, av
2270
+ // --------------------------
2271
+ // filter HAS_VALUE edges
2272
+ // --------------------------
2273
+ CALL (av, attr) {
2274
+ MATCH (av)<-[r:HAS_VALUE]-(attr)
2275
+ WHERE %(branch_filter)s
2276
+ RETURN r
2277
+ ORDER BY r.branch_level DESC, r.from DESC, r.status ASC
2278
+ LIMIT 1
2279
+ }
2280
+ WITH n, attr
2281
+ WHERE r.status = "active"
2282
+ // --------------------------
2283
+ // filter HAS_ATTRIBUTE edges
2284
+ // --------------------------
2285
+ CALL (n, attr) {
2286
+ MATCH (attr)<-[r:HAS_ATTRIBUTE]-(n)
2287
+ WHERE %(branch_filter)s
2288
+ RETURN r
2289
+ ORDER BY r.branch_level DESC, r.from DESC, r.status ASC
2290
+ LIMIT 1
2291
+ }
2292
+ WITH n, attr, r
2293
+ WHERE r.status = "active"
2294
+ """ % {
2295
+ "search_predicate": search_predicate,
2296
+ "kind_filter": kind_filter,
2297
+ "branch_filter": branch_filter,
2298
+ }
2299
+
2300
+ self.add_to_query(query)
2301
+
2302
+ def get_data(self) -> Generator[NodeGetListByAttributeValueQueryResult, None, None]:
2303
+ """Yield results as typed dataclass instances."""
2304
+ for result in self.get_results():
2305
+ yield NodeGetListByAttributeValueQueryResult(
2306
+ uuid=result.get_as_str("uuid"),
2307
+ kind=result.get_as_str("kind"),
2308
+ )
2309
+
2310
+
2183
2311
  class NodeGetHierarchyQuery(Query):
2184
2312
  name = "node_get_hierarchy"
2185
2313
  type = QueryType.READ
@@ -70,6 +70,21 @@ class NumberPoolAllocatedResult:
70
70
  )
71
71
 
72
72
 
73
+ @dataclass(frozen=True)
74
+ class NumberPoolFreeData:
75
+ value: int
76
+ is_free: bool
77
+ is_last: bool
78
+
79
+ @classmethod
80
+ def from_db(cls, result: QueryResult) -> NumberPoolFreeData:
81
+ return cls(
82
+ value=result.get_as_type("value", return_type=int),
83
+ is_free=result.get_as_type("is_free", return_type=bool),
84
+ is_last=result.get_as_type("is_last", return_type=bool),
85
+ )
86
+
87
+
73
88
  class IPAddressPoolGetIdentifiers(Query):
74
89
  name = "ipaddresspool_get_identifiers"
75
90
  type = QueryType.READ
@@ -393,7 +408,9 @@ class NumberPoolGetUsed(Query):
393
408
  n.uuid = res.identifier AND
394
409
  attr.name = $attribute_name AND
395
410
  all(r in [res, hv, ha] WHERE (%(branch_filter)s))
396
- ORDER BY res.branch_level DESC, hv.branch_level DESC, ha.branch_level DESC, res.from DESC, hv.from DESC, ha.from DESC
411
+ ORDER BY res.branch_level DESC, hv.branch_level DESC, ha.branch_level DESC,
412
+ res.from DESC, hv.from DESC, ha.from DESC,
413
+ res.status ASC, hv.status ASC, ha.status ASC
397
414
  RETURN (res.status = "active" AND hv.status = "active" AND ha.status = "active") AS is_active
398
415
  LIMIT 1
399
416
  }
@@ -419,6 +436,102 @@ class NumberPoolGetUsed(Query):
419
436
  yield NumberPoolIdentifierData.from_db(result)
420
437
 
421
438
 
439
+ class NumberPoolGetFree(Query):
440
+ name = "number_pool_get_free"
441
+ type = QueryType.READ
442
+
443
+ def __init__(
444
+ self,
445
+ pool: CoreNumberPool,
446
+ min_value: int | None = None,
447
+ max_value: int | None = None,
448
+ **kwargs: dict[str, Any],
449
+ ) -> None:
450
+ self.pool = pool
451
+ self.min_value = min_value
452
+ self.max_value = max_value
453
+
454
+ super().__init__(**kwargs) # type: ignore[arg-type]
455
+
456
+ async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> None: # noqa: ARG002
457
+ self.params["pool_id"] = self.pool.get_id()
458
+ # Use min_value/max_value if provided, otherwise use pool's start_range/end_range
459
+ self.params["start_range"] = self.min_value if self.min_value is not None else self.pool.start_range.value
460
+ self.params["end_range"] = self.max_value if self.max_value is not None else self.pool.end_range.value
461
+ self.limit = 1 # Query only works at returning a single, free entry
462
+
463
+ branch_filter, branch_params = self.branch.get_query_filter_path(
464
+ at=self.at.to_string(), branch_agnostic=self.branch_agnostic
465
+ )
466
+
467
+ self.params.update(branch_params)
468
+ self.params["attribute_name"] = self.pool.node_attribute.value
469
+
470
+ query = """
471
+ MATCH (pool:%(number_pool)s { uuid: $pool_id })-[res:IS_RESERVED]->(av:AttributeValueIndexed)
472
+ WHERE toInteger(av.value) >= $start_range and toInteger(av.value) <= $end_range
473
+ CALL (pool, res, av) {
474
+ MATCH (pool)-[res]->(av)<-[hv:HAS_VALUE]-(attr:Attribute)<-[ha:HAS_ATTRIBUTE]-(n:%(node)s)
475
+ WHERE
476
+ n.uuid = res.identifier AND
477
+ attr.name = $attribute_name AND
478
+ all(r in [res, hv, ha] WHERE (%(branch_filter)s))
479
+ ORDER BY res.branch_level DESC, hv.branch_level DESC, ha.branch_level DESC,
480
+ res.from DESC, hv.from DESC, ha.from DESC,
481
+ res.status ASC, hv.status ASC, ha.status ASC
482
+ RETURN (res.status = "active" AND hv.status = "active" AND ha.status = "active") AS is_active
483
+ LIMIT 1
484
+ }
485
+ WITH av, res, is_active
486
+ WHERE is_active = True
487
+ WITH DISTINCT toInteger(av.value) AS used_value
488
+ ORDER BY used_value ASC
489
+ WITH [$start_range - 1] + collect(used_value) AS nums
490
+ UNWIND range(0, size(nums) - 1) AS idx
491
+ CALL (nums, idx) {
492
+ WITH nums[idx] AS curr, idx - 1 + $start_range AS expected
493
+ RETURN expected AS number, expected <> curr AS is_free, idx = size(nums) - 1 AS is_last
494
+ }
495
+ WITH number, is_free, is_last
496
+ WHERE is_free = true OR is_last = true
497
+ WITH number AS free_number, is_free, is_last
498
+ """ % {
499
+ "branch_filter": branch_filter,
500
+ "number_pool": InfrahubKind.NUMBERPOOL,
501
+ "node": self.pool.node.value,
502
+ }
503
+
504
+ self.add_to_query(query)
505
+ self.return_labels = ["free_number as value", "is_free", "is_last"]
506
+ self.order_by = ["value"]
507
+
508
+ def get_free_data(self) -> NumberPoolFreeData | None:
509
+ if not self.results:
510
+ return None
511
+
512
+ return NumberPoolFreeData.from_db(result=self.results[0])
513
+
514
+ def get_result_value(self) -> int | None:
515
+ """Get the free number from query results, handling edge cases.
516
+
517
+ Returns:
518
+ The free number if found, None if pool is exhausted in queried range.
519
+ """
520
+ result_data = self.get_free_data()
521
+ if result_data is None:
522
+ # No reservations in range - return start_range
523
+ if self.params["start_range"] <= self.params["end_range"]:
524
+ return self.params["start_range"]
525
+ return None
526
+
527
+ if result_data.is_free:
528
+ return result_data.value
529
+ # is_last=True and is_free=False means all numbers up to value are used
530
+ if result_data.is_last and result_data.value < self.params["end_range"]:
531
+ return result_data.value + 1
532
+ return None
533
+
534
+
422
535
  class NumberPoolSetReserved(Query):
423
536
  name = "numberpool_set_reserved"
424
537
  type = QueryType.WRITE
@@ -652,7 +652,7 @@ class Relationship(FlagPropertyMixin, NodePropertyMixin, MetadataInterface):
652
652
  destination_id=peer.id,
653
653
  status="active",
654
654
  direction=self.schema.direction.value,
655
- branch_level=self.branch.hierarchy_level,
655
+ branch_level=branch.hierarchy_level,
656
656
  branch_support=self.schema.branch.value if self.schema.branch else None,
657
657
  hierarchical=self.schema.hierarchical,
658
658
  is_protected=self.is_protected,
@@ -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")
@@ -87,7 +87,6 @@ core_webhook = GenericSchema(
87
87
  name="validate_certificates",
88
88
  kind="Boolean",
89
89
  description="Whether to validate SSL/TLS certificates",
90
- default_value=True,
91
90
  optional=True,
92
91
  order_weight=5000,
93
92
  ),
@@ -182,6 +182,7 @@ class SchemaNode(BaseModel):
182
182
  display_label: str | None = None
183
183
  display_labels: list[str]
184
184
  uniqueness_constraints: list[list[str]] | None = None
185
+ human_friendly_id: list[str] | None = None
185
186
 
186
187
  def to_dict(self) -> dict[str, Any]:
187
188
  return {
@@ -199,6 +200,7 @@ class SchemaNode(BaseModel):
199
200
  "display_label": self.display_label,
200
201
  "display_labels": self.display_labels,
201
202
  "uniqueness_constraints": self.uniqueness_constraints,
203
+ "human_friendly_id": self.human_friendly_id,
202
204
  }
203
205
 
204
206
  def without_duplicates(self, other: SchemaNode) -> SchemaNode:
@@ -225,7 +227,6 @@ base_node_schema = SchemaNode(
225
227
  namespace="Schema",
226
228
  branch=BranchSupportType.AWARE.value,
227
229
  include_in_menu=False,
228
- default_filter="name__value",
229
230
  display_labels=["label__value"],
230
231
  attributes=[
231
232
  SchemaAttribute(
@@ -239,7 +240,7 @@ base_node_schema = SchemaNode(
239
240
  name="name",
240
241
  kind="Text",
241
242
  description="Node name, must be unique within a namespace and must start with an uppercase letter.",
242
- unique=True,
243
+ unique=False,
243
244
  regex=str(NODE_NAME_REGEX),
244
245
  min_length=DEFAULT_NAME_MIN_LENGTH,
245
246
  max_length=DEFAULT_NAME_MAX_LENGTH,
@@ -394,8 +395,9 @@ node_schema = SchemaNode(
394
395
  namespace="Schema",
395
396
  branch=BranchSupportType.AWARE.value,
396
397
  include_in_menu=False,
397
- default_filter="name__value",
398
398
  display_labels=["label__value"],
399
+ human_friendly_id=["namespace__value", "name__value"],
400
+ uniqueness_constraints=[["namespace__value", "name__value"]],
399
401
  attributes=base_node_schema.attributes
400
402
  + [
401
403
  SchemaAttribute(
@@ -898,8 +900,9 @@ generic_schema = SchemaNode(
898
900
  namespace="Schema",
899
901
  branch=BranchSupportType.AWARE.value,
900
902
  include_in_menu=False,
901
- default_filter="name__value",
902
903
  display_labels=["label__value"],
904
+ human_friendly_id=["namespace__value", "name__value"],
905
+ uniqueness_constraints=[["namespace__value", "name__value"]],
903
906
  attributes=base_node_schema.attributes
904
907
  + [
905
908
  SchemaAttribute(