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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. infrahub/actions/tasks.py +8 -0
  2. infrahub/api/diff/diff.py +1 -1
  3. infrahub/cli/db.py +24 -0
  4. infrahub/cli/db_commands/clean_duplicate_schema_fields.py +212 -0
  5. infrahub/core/attribute.py +3 -3
  6. infrahub/core/branch/tasks.py +2 -1
  7. infrahub/core/changelog/models.py +4 -12
  8. infrahub/core/constants/infrahubkind.py +1 -0
  9. infrahub/core/diff/model/path.py +4 -0
  10. infrahub/core/diff/payload_builder.py +1 -1
  11. infrahub/core/graph/__init__.py +1 -1
  12. infrahub/core/ipam/utilization.py +1 -1
  13. infrahub/core/manager.py +6 -3
  14. infrahub/core/migrations/graph/__init__.py +4 -0
  15. infrahub/core/migrations/graph/m041_create_hfid_display_label_in_db.py +97 -0
  16. infrahub/core/migrations/graph/m042_backfill_hfid_display_label_in_db.py +86 -0
  17. infrahub/core/migrations/schema/node_attribute_add.py +5 -2
  18. infrahub/core/migrations/shared.py +5 -6
  19. infrahub/core/node/__init__.py +142 -40
  20. infrahub/core/node/constraints/attribute_uniqueness.py +3 -1
  21. infrahub/core/node/node_property_attribute.py +230 -0
  22. infrahub/core/node/standard.py +1 -1
  23. infrahub/core/protocols.py +7 -1
  24. infrahub/core/query/node.py +14 -1
  25. infrahub/core/registry.py +2 -2
  26. infrahub/core/relationship/constraints/count.py +1 -1
  27. infrahub/core/relationship/model.py +1 -1
  28. infrahub/core/schema/basenode_schema.py +42 -2
  29. infrahub/core/schema/definitions/core/__init__.py +2 -0
  30. infrahub/core/schema/definitions/core/generator.py +2 -0
  31. infrahub/core/schema/definitions/core/group.py +16 -2
  32. infrahub/core/schema/definitions/internal.py +14 -1
  33. infrahub/core/schema/generated/base_node_schema.py +6 -1
  34. infrahub/core/schema/node_schema.py +5 -2
  35. infrahub/core/schema/schema_branch.py +134 -0
  36. infrahub/core/schema/schema_branch_display.py +123 -0
  37. infrahub/core/schema/schema_branch_hfid.py +114 -0
  38. infrahub/core/validators/aggregated_checker.py +1 -1
  39. infrahub/core/validators/determiner.py +12 -1
  40. infrahub/core/validators/relationship/peer.py +1 -1
  41. infrahub/core/validators/tasks.py +1 -1
  42. infrahub/display_labels/__init__.py +0 -0
  43. infrahub/display_labels/gather.py +48 -0
  44. infrahub/display_labels/models.py +240 -0
  45. infrahub/display_labels/tasks.py +186 -0
  46. infrahub/display_labels/triggers.py +22 -0
  47. infrahub/events/group_action.py +1 -1
  48. infrahub/events/node_action.py +1 -1
  49. infrahub/generators/constants.py +7 -0
  50. infrahub/generators/models.py +7 -0
  51. infrahub/generators/tasks.py +31 -15
  52. infrahub/git/integrator.py +22 -14
  53. infrahub/graphql/analyzer.py +1 -1
  54. infrahub/graphql/mutations/display_label.py +111 -0
  55. infrahub/graphql/mutations/generator.py +25 -7
  56. infrahub/graphql/mutations/hfid.py +118 -0
  57. infrahub/graphql/mutations/relationship.py +2 -2
  58. infrahub/graphql/mutations/resource_manager.py +2 -2
  59. infrahub/graphql/mutations/schema.py +5 -5
  60. infrahub/graphql/queries/resource_manager.py +1 -1
  61. infrahub/graphql/resolvers/resolver.py +2 -0
  62. infrahub/graphql/schema.py +4 -0
  63. infrahub/groups/tasks.py +1 -1
  64. infrahub/hfid/__init__.py +0 -0
  65. infrahub/hfid/gather.py +48 -0
  66. infrahub/hfid/models.py +240 -0
  67. infrahub/hfid/tasks.py +185 -0
  68. infrahub/hfid/triggers.py +22 -0
  69. infrahub/lock.py +15 -4
  70. infrahub/middleware.py +26 -1
  71. infrahub/proposed_change/tasks.py +10 -1
  72. infrahub/server.py +16 -3
  73. infrahub/services/__init__.py +8 -5
  74. infrahub/trigger/catalogue.py +4 -0
  75. infrahub/trigger/models.py +2 -0
  76. infrahub/trigger/tasks.py +3 -0
  77. infrahub/workflows/catalogue.py +72 -0
  78. infrahub/workflows/initialization.py +16 -0
  79. infrahub_sdk/checks.py +1 -1
  80. infrahub_sdk/ctl/cli_commands.py +2 -0
  81. infrahub_sdk/ctl/generator.py +4 -0
  82. infrahub_sdk/ctl/graphql.py +184 -0
  83. infrahub_sdk/ctl/schema.py +6 -2
  84. infrahub_sdk/generator.py +7 -1
  85. infrahub_sdk/graphql/__init__.py +12 -0
  86. infrahub_sdk/graphql/constants.py +1 -0
  87. infrahub_sdk/graphql/plugin.py +85 -0
  88. infrahub_sdk/graphql/query.py +77 -0
  89. infrahub_sdk/{graphql.py → graphql/renderers.py} +81 -73
  90. infrahub_sdk/graphql/utils.py +40 -0
  91. infrahub_sdk/protocols.py +14 -0
  92. infrahub_sdk/schema/__init__.py +38 -0
  93. infrahub_sdk/schema/repository.py +8 -0
  94. infrahub_sdk/spec/object.py +84 -10
  95. infrahub_sdk/spec/range_expansion.py +1 -1
  96. infrahub_sdk/transforms.py +1 -1
  97. {infrahub_server-1.5.0b0.dist-info → infrahub_server-1.5.0b1.dist-info}/METADATA +5 -4
  98. {infrahub_server-1.5.0b0.dist-info → infrahub_server-1.5.0b1.dist-info}/RECORD +104 -79
  99. infrahub_testcontainers/container.py +1 -1
  100. infrahub_testcontainers/docker-compose-cluster.test.yml +1 -1
  101. infrahub_testcontainers/docker-compose.test.yml +1 -1
  102. {infrahub_server-1.5.0b0.dist-info → infrahub_server-1.5.0b1.dist-info}/LICENSE.txt +0 -0
  103. {infrahub_server-1.5.0b0.dist-info → infrahub_server-1.5.0b1.dist-info}/WHEEL +0 -0
  104. {infrahub_server-1.5.0b0.dist-info → infrahub_server-1.5.0b1.dist-info}/entry_points.txt +0 -0
@@ -9,7 +9,7 @@ from enum import Enum
9
9
  from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, overload
10
10
 
11
11
  from infrahub_sdk.utils import compare_lists, intersection
12
- from pydantic import field_validator
12
+ from pydantic import ConfigDict, field_validator
13
13
 
14
14
  from infrahub.core.constants import HashableModelState, RelationshipCardinality, RelationshipKind
15
15
  from infrahub.core.models import HashableModel, HashableModelDiff
@@ -19,6 +19,7 @@ from .generated.base_node_schema import GeneratedBaseNodeSchema
19
19
  from .relationship_schema import RelationshipSchema
20
20
 
21
21
  if TYPE_CHECKING:
22
+ from pydantic.config import JsonDict
22
23
  from typing_extensions import Self
23
24
 
24
25
  from infrahub.core.schema import GenericSchema, NodeSchema
@@ -26,6 +27,7 @@ if TYPE_CHECKING:
26
27
 
27
28
 
28
29
  NODE_METADATA_ATTRIBUTES = ["_source", "_owner"]
30
+ NODE_PROPERTY_ATTRIBUTES = ["display_label", "human_friendly_id"]
29
31
  INHERITED = "INHERITED"
30
32
 
31
33
  OPTIONAL_TEXT_FIELDS = [
@@ -39,10 +41,43 @@ OPTIONAL_TEXT_FIELDS = [
39
41
  ]
40
42
 
41
43
 
44
+ def _json_schema_extra(schema: JsonDict) -> None:
45
+ """
46
+ Mutate the generated JSON Schema in place to:
47
+ - allow `null` for `display_labels`
48
+ - mark the non-null branch as deprecated
49
+ """
50
+ props = schema.get("properties")
51
+ if not isinstance(props, dict):
52
+ return
53
+ dl = props.get("display_labels")
54
+ if not isinstance(dl, dict):
55
+ return
56
+
57
+ if "anyOf" in dl:
58
+ dl["anyOf"] = [
59
+ {
60
+ "type": "array",
61
+ "items": {
62
+ "type": "string",
63
+ "deprecationMessage": "display_labels are deprecated use display_label instead",
64
+ },
65
+ },
66
+ {"type": "null"},
67
+ ]
68
+
69
+
42
70
  class BaseNodeSchema(GeneratedBaseNodeSchema):
43
71
  _exclude_from_hash: list[str] = ["attributes", "relationships"]
44
72
  _sort_by: list[str] = ["namespace", "name"]
45
73
 
74
+ model_config = ConfigDict(extra="forbid", json_schema_extra=_json_schema_extra)
75
+
76
+ @property
77
+ def is_schema_node(self) -> bool:
78
+ """Tell if this node represent a part of the schema. Not to confuse this with `is_node_schema`."""
79
+ return self.namespace == "Schema"
80
+
46
81
  @property
47
82
  def is_node_schema(self) -> bool:
48
83
  return False
@@ -240,6 +275,11 @@ class BaseNodeSchema(GeneratedBaseNodeSchema):
240
275
  return None
241
276
 
242
277
  def get_attribute(self, name: str) -> AttributeSchema:
278
+ if name == "human_friendly_id":
279
+ return AttributeSchema(name="human_friendly_id", kind="List", optional=True, branch=self.branch)
280
+ if name == "display_label":
281
+ return AttributeSchema(name="display_label", kind="Text", optional=True, branch=self.branch)
282
+
243
283
  for item in self.attributes:
244
284
  if item.name == name:
245
285
  return item
@@ -329,7 +369,7 @@ class BaseNodeSchema(GeneratedBaseNodeSchema):
329
369
 
330
370
  @property
331
371
  def valid_input_names(self) -> list[str]:
332
- return self.attribute_names + self.relationship_names + NODE_METADATA_ATTRIBUTES
372
+ return self.attribute_names + self.relationship_names + NODE_METADATA_ATTRIBUTES + NODE_PROPERTY_ATTRIBUTES
333
373
 
334
374
  @property
335
375
  def valid_local_names(self) -> list[str]:
@@ -29,6 +29,7 @@ from .core import core_node, core_task_target
29
29
  from .generator import core_generator_definition, core_generator_instance
30
30
  from .graphql_query import core_graphql_query
31
31
  from .group import (
32
+ core_generator_aware_group,
32
33
  core_generator_group,
33
34
  core_graphql_query_group,
34
35
  core_group,
@@ -128,6 +129,7 @@ core_models_mixed: dict[str, list] = {
128
129
  core_group_action,
129
130
  core_standard_group,
130
131
  core_generator_group,
132
+ core_generator_aware_group,
131
133
  core_graphql_query_group,
132
134
  core_repository_group,
133
135
  builtin_tag,
@@ -33,6 +33,8 @@ core_generator_definition = NodeSchema(
33
33
  Attr(name="file_path", kind="Text"),
34
34
  Attr(name="class_name", kind="Text"),
35
35
  Attr(name="convert_query_response", kind="Boolean", optional=True, default_value=False),
36
+ Attr(name="execute_in_proposed_change", kind="Boolean", optional=True, default_value=True),
37
+ Attr(name="execute_after_merge", kind="Boolean", optional=True, default_value=True),
36
38
  ],
37
39
  relationships=[
38
40
  Rel(
@@ -70,10 +70,10 @@ core_standard_group = NodeSchema(
70
70
  core_generator_group = NodeSchema(
71
71
  name="GeneratorGroup",
72
72
  namespace="Core",
73
- description="Group of nodes that are created by a generator.",
73
+ description="Group of nodes that are created by a generator. (local)",
74
74
  include_in_menu=False,
75
75
  icon="mdi:state-machine",
76
- label="Generator Group",
76
+ label="Generator Group (local)",
77
77
  default_filter="name__value",
78
78
  order_by=["name__value"],
79
79
  display_labels=["name__value"],
@@ -82,6 +82,20 @@ core_generator_group = NodeSchema(
82
82
  generate_profile=False,
83
83
  )
84
84
 
85
+ core_generator_aware_group = NodeSchema(
86
+ name="GeneratorAwareGroup",
87
+ namespace="Core",
88
+ description="Group of nodes that are created by a generator. (Aware)",
89
+ include_in_menu=False,
90
+ icon="mdi:state-machine",
91
+ label="Generator Group (aware)",
92
+ default_filter="name__value",
93
+ order_by=["name__value"],
94
+ display_labels=["name__value"],
95
+ branch=BranchSupportType.AWARE,
96
+ inherit_from=[InfrahubKind.GENERICGROUP],
97
+ generate_profile=False,
98
+ )
85
99
 
86
100
  core_graphql_query_group = NodeSchema(
87
101
  name="GraphQLQueryGroup",
@@ -179,7 +179,9 @@ class SchemaNode(BaseModel):
179
179
  default_filter: str | None = None
180
180
  attributes: list[SchemaAttribute]
181
181
  relationships: list[SchemaRelationship]
182
+ display_label: str | None = None
182
183
  display_labels: list[str]
184
+ uniqueness_constraints: list[list[str]] | None = None
183
185
 
184
186
  def to_dict(self) -> dict[str, Any]:
185
187
  return {
@@ -194,7 +196,9 @@ class SchemaNode(BaseModel):
194
196
  if attribute.name not in ["id", "attributes", "relationships"]
195
197
  ],
196
198
  "relationships": [relationship.to_dict() for relationship in self.relationships],
199
+ "display_label": self.display_label,
197
200
  "display_labels": self.display_labels,
201
+ "uniqueness_constraints": self.uniqueness_constraints,
198
202
  }
199
203
 
200
204
  def without_duplicates(self, other: SchemaNode) -> SchemaNode:
@@ -292,11 +296,18 @@ base_node_schema = SchemaNode(
292
296
  optional=True,
293
297
  extra={"update": UpdateSupport.ALLOWED},
294
298
  ),
299
+ SchemaAttribute(
300
+ name="display_label",
301
+ kind="Text",
302
+ description="Attribute or Jinja2 template to use to generate the display label",
303
+ optional=True,
304
+ extra={"update": UpdateSupport.ALLOWED},
305
+ ),
295
306
  SchemaAttribute(
296
307
  name="display_labels",
297
308
  kind="List",
298
309
  internal_kind=str,
299
- description="List of attributes to use to generate the display label",
310
+ description="List of attributes to use to generate the display label (deprecated)",
300
311
  optional=True,
301
312
  extra={"update": UpdateSupport.ALLOWED},
302
313
  ),
@@ -465,6 +476,7 @@ attribute_schema = SchemaNode(
465
476
  include_in_menu=False,
466
477
  default_filter=None,
467
478
  display_labels=["name__value"],
479
+ uniqueness_constraints=[["name__value", "node"]],
468
480
  attributes=[
469
481
  SchemaAttribute(
470
482
  name="id",
@@ -669,6 +681,7 @@ relationship_schema = SchemaNode(
669
681
  include_in_menu=False,
670
682
  default_filter=None,
671
683
  display_labels=["name__value"],
684
+ uniqueness_constraints=[["name__value", "node"]],
672
685
  attributes=[
673
686
  SchemaAttribute(
674
687
  name="id",
@@ -58,9 +58,14 @@ class GeneratedBaseNodeSchema(HashableModel):
58
58
  description="Human friendly and unique identifier for the object.",
59
59
  json_schema_extra={"update": "allowed"},
60
60
  )
61
+ display_label: str | None = Field(
62
+ default=None,
63
+ description="Attribute or Jinja2 template to use to generate the display label",
64
+ json_schema_extra={"update": "allowed"},
65
+ )
61
66
  display_labels: list[str] | None = Field(
62
67
  default=None,
63
- description="List of attributes to use to generate the display label",
68
+ description="List of attributes to use to generate the display label (deprecated)",
64
69
  json_schema_extra={"update": "allowed"},
65
70
  )
66
71
  include_in_menu: bool | None = Field(
@@ -90,6 +90,7 @@ class NodeSchema(GeneratedNodeSchema):
90
90
 
91
91
  properties_to_inherit = [
92
92
  "human_friendly_id",
93
+ "display_label",
93
94
  "display_labels",
94
95
  "default_filter",
95
96
  "menu_placement",
@@ -129,10 +130,12 @@ class NodeSchema(GeneratedNodeSchema):
129
130
  item_idx = existing_inherited_relationships[relationship.name]
130
131
  self.relationships[item_idx].update_from_generic(other=new_relationship)
131
132
 
132
- def get_hierarchy_schema(self, db: InfrahubDatabase, branch: Branch | str | None = None) -> GenericSchema:
133
+ def get_hierarchy_schema(
134
+ self, db: InfrahubDatabase, branch: Branch | str | None = None, duplicate: bool = False
135
+ ) -> GenericSchema:
133
136
  if not self.hierarchy:
134
137
  raise ValueError("The node is not part of a hierarchy")
135
- schema = db.schema.get(name=self.hierarchy, branch=branch)
138
+ schema = db.schema.get(name=self.hierarchy, branch=branch, duplicate=duplicate)
136
139
  if not isinstance(schema, GenericSchema):
137
140
  raise TypeError
138
141
  return schema
@@ -66,6 +66,8 @@ from ... import config
66
66
  from ..constants.schema import PARENT_CHILD_IDENTIFIER
67
67
  from .constants import INTERNAL_SCHEMA_NODE_KINDS, SchemaNamespace
68
68
  from .schema_branch_computed import ComputedAttributes
69
+ from .schema_branch_display import DisplayLabels
70
+ from .schema_branch_hfid import HFIDs
69
71
 
70
72
  log = get_logger()
71
73
 
@@ -77,6 +79,8 @@ class SchemaBranch:
77
79
  name: str | None = None,
78
80
  data: dict[str, dict[str, str]] | None = None,
79
81
  computed_attributes: ComputedAttributes | None = None,
82
+ display_labels: DisplayLabels | None = None,
83
+ hfids: HFIDs | None = None,
80
84
  ):
81
85
  self._cache: dict[str, NodeSchema | GenericSchema] = cache
82
86
  self.name: str | None = name
@@ -85,6 +89,8 @@ class SchemaBranch:
85
89
  self.profiles: dict[str, str] = {}
86
90
  self.templates: dict[str, str] = {}
87
91
  self.computed_attributes = computed_attributes or ComputedAttributes()
92
+ self.display_labels = display_labels or DisplayLabels()
93
+ self.hfids = hfids or HFIDs()
88
94
 
89
95
  if data:
90
96
  self.nodes = data.get("nodes", {})
@@ -270,6 +276,8 @@ class SchemaBranch:
270
276
  data=copy.deepcopy(self.to_dict()),
271
277
  cache=self._cache,
272
278
  computed_attributes=self.computed_attributes.duplicate(),
279
+ display_labels=self.display_labels.duplicate(),
280
+ hfids=self.hfids.duplicate(),
273
281
  )
274
282
 
275
283
  def set(self, name: str, schema: MainSchemaTypes) -> str:
@@ -504,6 +512,9 @@ class SchemaBranch:
504
512
  self.process_post_validation()
505
513
 
506
514
  def process_pre_validation(self) -> None:
515
+ self.process_nodes_state()
516
+ self.process_attributes_state()
517
+ self.process_relationships_state()
507
518
  self.generate_identifiers()
508
519
  self.process_default_values()
509
520
  self.process_deprecations()
@@ -529,6 +540,7 @@ class SchemaBranch:
529
540
  self.validate_identifiers()
530
541
  self.sync_uniqueness_constraints_and_unique_attributes()
531
542
  self.validate_uniqueness_constraints()
543
+ self.validate_display_label()
532
544
  self.validate_display_labels()
533
545
  self.validate_order_by()
534
546
  self.validate_default_filters()
@@ -752,6 +764,35 @@ class SchemaBranch:
752
764
  element_name=element_name,
753
765
  )
754
766
 
767
+ def validate_display_label(self) -> None:
768
+ self.display_labels = DisplayLabels()
769
+ for name in self.all_names:
770
+ node_schema = self.get(name=name, duplicate=False)
771
+
772
+ if node_schema.display_label is None and node_schema.display_labels:
773
+ update_candidate = self.get(name=name, duplicate=True)
774
+ if len(node_schema.display_labels) == 1:
775
+ # If the previous display_labels consist of a single attribute convert
776
+ # it to an attribute based display label
777
+ converted_display_label = node_schema.display_labels[0]
778
+ if "__" not in converted_display_label:
779
+ # Previously we allowed defining a raw attribute name as a component of a
780
+ # display_label, if this is the case we need to append '__value'
781
+ converted_display_label = f"{converted_display_label}__value"
782
+ update_candidate.display_label = converted_display_label
783
+ else:
784
+ # If the previous display label consists of multiple attributes
785
+ # convert it to a Jinja2 based display label
786
+ update_candidate.display_label = " ".join(
787
+ [f"{{{{ {display_label} }}}}" for display_label in node_schema.display_labels]
788
+ )
789
+ self.set(name=name, schema=update_candidate)
790
+
791
+ if not node_schema.display_label:
792
+ continue
793
+
794
+ self._validate_display_label(node=node_schema)
795
+
755
796
  def validate_display_labels(self) -> None:
756
797
  for name in self.all_names:
757
798
  node_schema = self.get(name=name, duplicate=False)
@@ -849,6 +890,7 @@ class SchemaBranch:
849
890
  return False
850
891
 
851
892
  def validate_human_friendly_id(self) -> None:
893
+ self.hfids = HFIDs()
852
894
  for name in self.generic_names_without_templates + self.node_names:
853
895
  node_schema = self.get(name=name, duplicate=False)
854
896
 
@@ -860,7 +902,14 @@ class SchemaBranch:
860
902
  # Mapping relationship identifiers -> list of attributes paths
861
903
  rel_schemas_to_paths: dict[str, tuple[MainSchemaTypes, list[str]]] = {}
862
904
 
905
+ visited_paths: list[str] = []
863
906
  for hfid_path in node_schema.human_friendly_id:
907
+ if config.SETTINGS.main.schema_strict_mode and hfid_path in visited_paths:
908
+ raise ValidationError(
909
+ f"HFID of {node_schema.kind} cannot use the same path more than once: {hfid_path}"
910
+ )
911
+
912
+ visited_paths.append(hfid_path)
864
913
  schema_path = self.validate_schema_path(
865
914
  node_schema=node_schema,
866
915
  path=hfid_path,
@@ -875,6 +924,11 @@ class SchemaBranch:
875
924
  rel_schemas_to_paths[rel_identifier] = (schema_path.related_schema, [])
876
925
  rel_schemas_to_paths[rel_identifier][1].append(schema_path.attribute_path_as_str)
877
926
 
927
+ if node_schema.is_node_schema and node_schema.namespace not in ["Schema", "Internal"]:
928
+ self.hfids.register_hfid_schema_path(
929
+ kind=node_schema.kind, schema_path=schema_path, hfid=node_schema.human_friendly_id
930
+ )
931
+
878
932
  if config.SETTINGS.main.schema_strict_mode:
879
933
  # For every relationship referred within hfid, check whether the combination of attributes is unique is the peer schema node
880
934
  for related_schema, attrs_paths in rel_schemas_to_paths.values():
@@ -1137,6 +1191,50 @@ class SchemaBranch:
1137
1191
  node=node_schema, attribute=attribute, generic=generic_schema
1138
1192
  )
1139
1193
 
1194
+ def _validate_display_label(self, node: MainSchemaTypes) -> None:
1195
+ if not node.display_label:
1196
+ return
1197
+
1198
+ if not any(c in node.display_label for c in "{}"):
1199
+ schema_path = self.validate_schema_path(
1200
+ node_schema=node,
1201
+ path=node.display_label,
1202
+ allowed_path_types=SchemaElementPathType.ATTR_WITH_PROP,
1203
+ element_name="display_label - non Jinja2",
1204
+ )
1205
+ if schema_path.attribute_schema and node.is_node_schema and node.namespace not in ["Internal", "Schema"]:
1206
+ self.display_labels.register_attribute_based_display_label(
1207
+ kind=node.kind, attribute_name=schema_path.attribute_schema.name
1208
+ )
1209
+ return
1210
+
1211
+ jinja_template = Jinja2Template(template=node.display_label)
1212
+ try:
1213
+ variables = jinja_template.get_variables()
1214
+ jinja_template.validate(restricted=config.SETTINGS.security.restrict_untrusted_jinja2_filters)
1215
+ except (JinjaTemplateOperationViolationError, JinjaTemplateError) as exc:
1216
+ raise ValueError(
1217
+ f"{node.kind}: display_label is set to a jinja2 template, but has an invalid template: {exc.message}"
1218
+ ) from exc
1219
+
1220
+ allowed_path_types = (
1221
+ SchemaElementPathType.ATTR_WITH_PROP
1222
+ | SchemaElementPathType.REL_ONE_MANDATORY_ATTR_WITH_PROP
1223
+ | SchemaElementPathType.REL_ONE_ATTR_WITH_PROP
1224
+ )
1225
+ for variable in variables:
1226
+ schema_path = self.validate_schema_path(
1227
+ node_schema=node, path=variable, allowed_path_types=allowed_path_types, element_name="display_label"
1228
+ )
1229
+
1230
+ if schema_path.is_type_attribute and schema_path.active_attribute_schema.name == "display_label":
1231
+ raise ValueError(f"{node.kind}: display_label the '{variable}' variable is a reference to itself")
1232
+
1233
+ if node.is_node_schema and node.namespace not in ["Internal", "Schema"]:
1234
+ self.display_labels.register_template_schema_path(
1235
+ kind=node.kind, schema_path=schema_path, template=node.display_label
1236
+ )
1237
+
1140
1238
  def _validate_computed_attribute(self, node: NodeSchema, attribute: AttributeSchema) -> None:
1141
1239
  if not attribute.computed_attribute or attribute.computed_attribute.kind == ComputedAttributeKind.USER:
1142
1240
  return
@@ -1612,6 +1710,42 @@ class SchemaBranch:
1612
1710
 
1613
1711
  self.set(name=name, schema=node)
1614
1712
 
1713
+ def process_relationships_state(self) -> None:
1714
+ for name in self.node_names + self.generic_names_without_templates:
1715
+ node = self.get(name=name, duplicate=False)
1716
+ if node.id or (not node.id and not node.relationships):
1717
+ continue
1718
+
1719
+ filtered_relationships = [
1720
+ relationship for relationship in node.relationships if relationship.state != HashableModelState.ABSENT
1721
+ ]
1722
+ if len(filtered_relationships) == len(node.relationships):
1723
+ continue
1724
+ updated_node = node.duplicate()
1725
+ updated_node.relationships = filtered_relationships
1726
+ self.set(name=name, schema=updated_node)
1727
+
1728
+ def process_attributes_state(self) -> None:
1729
+ for name in self.node_names + self.generic_names_without_templates:
1730
+ node = self.get(name=name, duplicate=False)
1731
+ if not node.attributes:
1732
+ continue
1733
+
1734
+ filtered_attributes = [
1735
+ attribute for attribute in node.attributes if attribute.state != HashableModelState.ABSENT
1736
+ ]
1737
+ if len(filtered_attributes) == len(node.attributes):
1738
+ continue
1739
+ updated_node = node.duplicate()
1740
+ updated_node.attributes = filtered_attributes
1741
+ self.set(name=name, schema=updated_node)
1742
+
1743
+ def process_nodes_state(self) -> None:
1744
+ for name in self.node_names + self.generic_names_without_templates:
1745
+ node = self.get(name=name, duplicate=False)
1746
+ if not node.id and node.state == HashableModelState.ABSENT:
1747
+ self.delete(name=name)
1748
+
1615
1749
  def _generate_weight_generics(self) -> None:
1616
1750
  """Generate order_weight for all generic schemas."""
1617
1751
  for name in self.generic_names:
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from copy import deepcopy
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from infrahub.core.schema import SchemaAttributePath
10
+
11
+
12
+ @dataclass
13
+ class TemplateLabel:
14
+ template: str
15
+ attributes: set[str] = field(default_factory=set)
16
+ relationships: set[str] = field(default_factory=set)
17
+ filter_key: str = "ids"
18
+
19
+ @property
20
+ def fields(self) -> list[str]:
21
+ return sorted(list(self.attributes) + list(self.relationships))
22
+
23
+ @property
24
+ def has_related_components(self) -> bool:
25
+ """Indicate if the associated template use variables from relationships"""
26
+ return len(self.relationships) > 0
27
+
28
+ def get_hash(self) -> str:
29
+ return hashlib.md5(self.template.encode(), usedforsecurity=False).hexdigest()
30
+
31
+
32
+ @dataclass
33
+ class RelationshipIdentifier:
34
+ kind: str
35
+ filter_key: str
36
+ template: str
37
+
38
+ def __hash__(self) -> int:
39
+ return hash(f"{self.kind}::{self.filter_key}::{self.template}")
40
+
41
+
42
+ @dataclass
43
+ class RelationshipTriggers:
44
+ attributes: dict[str, set[RelationshipIdentifier]] = field(default_factory=dict)
45
+
46
+
47
+ class DisplayLabels:
48
+ def __init__(
49
+ self,
50
+ template_based_display_labels: dict[str, TemplateLabel] | None = None,
51
+ template_relationship_triggers: dict[str, RelationshipTriggers] | None = None,
52
+ ) -> None:
53
+ self._template_based_display_labels: dict[str, TemplateLabel] = template_based_display_labels or {}
54
+ self._template_relationship_triggers: dict[str, RelationshipTriggers] = template_relationship_triggers or {}
55
+
56
+ def duplicate(self) -> DisplayLabels:
57
+ """Clone the current object."""
58
+ return self.__class__(
59
+ template_based_display_labels=deepcopy(self._template_based_display_labels),
60
+ template_relationship_triggers=deepcopy(self._template_relationship_triggers),
61
+ )
62
+
63
+ def register_attribute_based_display_label(self, kind: str, attribute_name: str) -> None:
64
+ """Register nodes where the display label consists of a single defined attribute name."""
65
+ self._template_based_display_labels[kind] = TemplateLabel(
66
+ template=f"{{{{ {attribute_name}__value }}}}", attributes={attribute_name}
67
+ )
68
+
69
+ def register_template_schema_path(self, kind: str, schema_path: SchemaAttributePath, template: str) -> None:
70
+ """Register Jinja2 template based display labels using the schema path of each impacted variable in the node."""
71
+
72
+ if kind not in self._template_based_display_labels:
73
+ self._template_based_display_labels[kind] = TemplateLabel(template=template)
74
+
75
+ if schema_path.is_type_attribute:
76
+ self._template_based_display_labels[kind].attributes.add(schema_path.active_attribute_schema.name)
77
+ elif schema_path.is_type_relationship and schema_path.related_schema:
78
+ self._template_based_display_labels[kind].relationships.add(schema_path.active_relationship_schema.name)
79
+ if schema_path.related_schema.kind not in self._template_relationship_triggers:
80
+ self._template_relationship_triggers[schema_path.related_schema.kind] = RelationshipTriggers()
81
+ if (
82
+ schema_path.active_attribute_schema.name
83
+ not in self._template_relationship_triggers[schema_path.related_schema.kind].attributes
84
+ ):
85
+ self._template_relationship_triggers[schema_path.related_schema.kind].attributes[
86
+ schema_path.active_attribute_schema.name
87
+ ] = set()
88
+ self._template_relationship_triggers[schema_path.related_schema.kind].attributes[
89
+ schema_path.active_attribute_schema.name
90
+ ].add(
91
+ RelationshipIdentifier(
92
+ kind=kind, filter_key=f"{schema_path.active_relationship_schema.name}__ids", template=template
93
+ )
94
+ )
95
+
96
+ def targets_node(self, kind: str) -> bool:
97
+ """Indicates if there is a display_label defined for the targeted node"""
98
+ return kind in self._template_based_display_labels
99
+
100
+ def get_template_node(self, kind: str) -> TemplateLabel:
101
+ """Return node kinds together with their template definitions."""
102
+ return self._template_based_display_labels[kind]
103
+
104
+ def get_template_nodes(self) -> dict[str, TemplateLabel]:
105
+ """Return node kinds together with their template definitions."""
106
+ return self._template_based_display_labels
107
+
108
+ def get_related_trigger_nodes(self) -> dict[str, RelationshipTriggers]:
109
+ """Return node kinds that other nodes use within their templates for display_labels."""
110
+ return self._template_relationship_triggers
111
+
112
+ def get_related_template(self, related_kind: str, target_kind: str) -> TemplateLabel:
113
+ relationship_trigger = self._template_relationship_triggers[related_kind]
114
+ for applicable_kinds in relationship_trigger.attributes.values():
115
+ for relationship_identifier in applicable_kinds:
116
+ if target_kind == relationship_identifier.kind:
117
+ template_label = self.get_template_node(kind=target_kind)
118
+ template_label.filter_key = relationship_identifier.filter_key
119
+ return template_label
120
+
121
+ raise ValueError(
122
+ f"Unable to find registered template for {target_kind} registered on related node {related_kind}"
123
+ )