infrahub-server 1.6.2__py3-none-any.whl → 1.7.0__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 (253) hide show
  1. infrahub/actions/tasks.py +4 -2
  2. infrahub/api/exceptions.py +2 -2
  3. infrahub/api/schema.py +3 -1
  4. infrahub/artifacts/tasks.py +1 -0
  5. infrahub/auth.py +2 -2
  6. infrahub/cli/db.py +54 -28
  7. infrahub/computed_attribute/gather.py +3 -4
  8. infrahub/computed_attribute/tasks.py +23 -6
  9. infrahub/config.py +8 -0
  10. infrahub/constants/enums.py +12 -0
  11. infrahub/core/account.py +12 -9
  12. infrahub/core/attribute.py +106 -108
  13. infrahub/core/branch/models.py +44 -71
  14. infrahub/core/branch/tasks.py +5 -3
  15. infrahub/core/changelog/diff.py +1 -20
  16. infrahub/core/changelog/models.py +0 -7
  17. infrahub/core/constants/__init__.py +17 -0
  18. infrahub/core/constants/database.py +0 -1
  19. infrahub/core/constants/schema.py +0 -1
  20. infrahub/core/convert_object_type/repository_conversion.py +3 -4
  21. infrahub/core/diff/branch_differ.py +1 -1
  22. infrahub/core/diff/conflict_transferer.py +1 -1
  23. infrahub/core/diff/data_check_synchronizer.py +4 -3
  24. infrahub/core/diff/enricher/cardinality_one.py +2 -2
  25. infrahub/core/diff/enricher/hierarchy.py +1 -1
  26. infrahub/core/diff/enricher/labels.py +1 -1
  27. infrahub/core/diff/merger/merger.py +28 -2
  28. infrahub/core/diff/merger/serializer.py +3 -10
  29. infrahub/core/diff/model/diff.py +1 -1
  30. infrahub/core/diff/query/merge.py +376 -135
  31. infrahub/core/diff/repository/repository.py +3 -1
  32. infrahub/core/graph/__init__.py +1 -1
  33. infrahub/core/graph/constraints.py +3 -3
  34. infrahub/core/graph/schema.py +2 -12
  35. infrahub/core/ipam/reconciler.py +8 -6
  36. infrahub/core/ipam/utilization.py +8 -15
  37. infrahub/core/manager.py +133 -152
  38. infrahub/core/merge.py +1 -1
  39. infrahub/core/metadata/__init__.py +0 -0
  40. infrahub/core/metadata/interface.py +37 -0
  41. infrahub/core/metadata/model.py +31 -0
  42. infrahub/core/metadata/query/__init__.py +0 -0
  43. infrahub/core/metadata/query/node_metadata.py +301 -0
  44. infrahub/core/migrations/graph/__init__.py +4 -0
  45. infrahub/core/migrations/graph/m012_convert_account_generic.py +12 -12
  46. infrahub/core/migrations/graph/m013_convert_git_password_credential.py +7 -12
  47. infrahub/core/migrations/graph/m017_add_core_profile.py +5 -2
  48. infrahub/core/migrations/graph/m018_uniqueness_nulls.py +2 -1
  49. infrahub/core/migrations/graph/m019_restore_rels_to_time.py +0 -10
  50. infrahub/core/migrations/graph/m020_duplicate_edges.py +0 -8
  51. infrahub/core/migrations/graph/m025_uniqueness_nulls.py +2 -1
  52. infrahub/core/migrations/graph/m026_0000_prefix_fix.py +2 -1
  53. infrahub/core/migrations/graph/m029_duplicates_cleanup.py +0 -1
  54. infrahub/core/migrations/graph/m031_check_number_attributes.py +2 -2
  55. infrahub/core/migrations/graph/m038_redo_0000_prefix_fix.py +2 -1
  56. infrahub/core/migrations/graph/m041_deleted_dup_edges.py +1 -1
  57. infrahub/core/migrations/graph/m049_remove_is_visible_relationship.py +53 -0
  58. infrahub/core/migrations/graph/m050_backfill_vertex_metadata.py +168 -0
  59. infrahub/core/migrations/query/__init__.py +2 -2
  60. infrahub/core/migrations/query/attribute_add.py +17 -6
  61. infrahub/core/migrations/query/attribute_remove.py +19 -5
  62. infrahub/core/migrations/query/attribute_rename.py +21 -5
  63. infrahub/core/migrations/query/node_duplicate.py +19 -4
  64. infrahub/core/migrations/query/schema_attribute_update.py +1 -1
  65. infrahub/core/migrations/schema/attribute_kind_update.py +26 -6
  66. infrahub/core/migrations/schema/attribute_name_update.py +1 -1
  67. infrahub/core/migrations/schema/attribute_supports_profile.py +5 -3
  68. infrahub/core/migrations/schema/models.py +3 -0
  69. infrahub/core/migrations/schema/node_attribute_add.py +5 -2
  70. infrahub/core/migrations/schema/node_attribute_remove.py +1 -1
  71. infrahub/core/migrations/schema/node_kind_update.py +1 -1
  72. infrahub/core/migrations/schema/node_remove.py +24 -2
  73. infrahub/core/migrations/schema/tasks.py +4 -1
  74. infrahub/core/migrations/shared.py +13 -6
  75. infrahub/core/models.py +6 -6
  76. infrahub/core/node/__init__.py +157 -58
  77. infrahub/core/node/base.py +9 -5
  78. infrahub/core/node/create.py +7 -3
  79. infrahub/core/node/delete_validator.py +1 -1
  80. infrahub/core/node/standard.py +100 -14
  81. infrahub/core/order.py +30 -0
  82. infrahub/core/property.py +0 -1
  83. infrahub/core/protocols.py +1 -0
  84. infrahub/core/protocols_base.py +10 -2
  85. infrahub/core/query/__init__.py +11 -6
  86. infrahub/core/query/attribute.py +164 -49
  87. infrahub/core/query/branch.py +58 -70
  88. infrahub/core/query/delete.py +1 -1
  89. infrahub/core/query/diff.py +7 -7
  90. infrahub/core/query/ipam.py +104 -43
  91. infrahub/core/query/node.py +1072 -281
  92. infrahub/core/query/relationship.py +531 -325
  93. infrahub/core/query/resource_manager.py +107 -18
  94. infrahub/core/query/standard_node.py +25 -5
  95. infrahub/core/query/utils.py +2 -4
  96. infrahub/core/relationship/constraints/count.py +1 -1
  97. infrahub/core/relationship/constraints/peer_kind.py +1 -1
  98. infrahub/core/relationship/constraints/peer_parent.py +1 -1
  99. infrahub/core/relationship/constraints/peer_relatives.py +1 -1
  100. infrahub/core/relationship/constraints/profiles_kind.py +1 -1
  101. infrahub/core/relationship/constraints/profiles_removal.py +168 -0
  102. infrahub/core/relationship/model.py +293 -139
  103. infrahub/core/schema/attribute_parameters.py +28 -1
  104. infrahub/core/schema/attribute_schema.py +11 -17
  105. infrahub/core/schema/basenode_schema.py +3 -0
  106. infrahub/core/schema/definitions/core/__init__.py +8 -2
  107. infrahub/core/schema/definitions/core/account.py +10 -10
  108. infrahub/core/schema/definitions/core/artifact.py +14 -8
  109. infrahub/core/schema/definitions/core/check.py +10 -4
  110. infrahub/core/schema/definitions/core/generator.py +26 -6
  111. infrahub/core/schema/definitions/core/graphql_query.py +1 -1
  112. infrahub/core/schema/definitions/core/group.py +9 -2
  113. infrahub/core/schema/definitions/core/ipam.py +80 -10
  114. infrahub/core/schema/definitions/core/menu.py +41 -7
  115. infrahub/core/schema/definitions/core/permission.py +16 -2
  116. infrahub/core/schema/definitions/core/profile.py +16 -2
  117. infrahub/core/schema/definitions/core/propose_change.py +24 -4
  118. infrahub/core/schema/definitions/core/propose_change_comment.py +23 -11
  119. infrahub/core/schema/definitions/core/propose_change_validator.py +50 -21
  120. infrahub/core/schema/definitions/core/repository.py +10 -0
  121. infrahub/core/schema/definitions/core/resource_pool.py +8 -1
  122. infrahub/core/schema/definitions/core/template.py +19 -2
  123. infrahub/core/schema/definitions/core/transform.py +11 -5
  124. infrahub/core/schema/definitions/core/webhook.py +27 -9
  125. infrahub/core/schema/manager.py +63 -43
  126. infrahub/core/schema/relationship_schema.py +6 -2
  127. infrahub/core/schema/schema_branch.py +115 -11
  128. infrahub/core/task/task.py +4 -2
  129. infrahub/core/utils.py +3 -25
  130. infrahub/core/validators/aggregated_checker.py +1 -1
  131. infrahub/core/validators/attribute/choices.py +1 -1
  132. infrahub/core/validators/attribute/enum.py +1 -1
  133. infrahub/core/validators/attribute/kind.py +6 -3
  134. infrahub/core/validators/attribute/length.py +1 -1
  135. infrahub/core/validators/attribute/min_max.py +1 -1
  136. infrahub/core/validators/attribute/number_pool.py +1 -1
  137. infrahub/core/validators/attribute/optional.py +1 -1
  138. infrahub/core/validators/attribute/regex.py +1 -1
  139. infrahub/core/validators/determiner.py +3 -3
  140. infrahub/core/validators/node/attribute.py +1 -1
  141. infrahub/core/validators/node/relationship.py +1 -1
  142. infrahub/core/validators/relationship/peer.py +1 -1
  143. infrahub/database/__init__.py +4 -4
  144. infrahub/dependencies/builder/constraint/grouped/node_runner.py +2 -0
  145. infrahub/dependencies/builder/constraint/relationship_manager/profiles_removal.py +8 -0
  146. infrahub/dependencies/registry.py +2 -0
  147. infrahub/display_labels/tasks.py +12 -3
  148. infrahub/git/integrator.py +18 -18
  149. infrahub/git/tasks.py +1 -1
  150. infrahub/git/utils.py +1 -1
  151. infrahub/graphql/app.py +2 -2
  152. infrahub/graphql/constants.py +3 -0
  153. infrahub/graphql/context.py +1 -1
  154. infrahub/graphql/field_extractor.py +1 -1
  155. infrahub/graphql/initialization.py +11 -0
  156. infrahub/graphql/loaders/account.py +134 -0
  157. infrahub/graphql/loaders/node.py +5 -12
  158. infrahub/graphql/loaders/peers.py +5 -7
  159. infrahub/graphql/manager.py +175 -21
  160. infrahub/graphql/metadata.py +91 -0
  161. infrahub/graphql/mutations/account.py +6 -6
  162. infrahub/graphql/mutations/attribute.py +0 -2
  163. infrahub/graphql/mutations/branch.py +9 -5
  164. infrahub/graphql/mutations/computed_attribute.py +1 -1
  165. infrahub/graphql/mutations/display_label.py +1 -1
  166. infrahub/graphql/mutations/hfid.py +1 -1
  167. infrahub/graphql/mutations/ipam.py +4 -6
  168. infrahub/graphql/mutations/main.py +9 -4
  169. infrahub/graphql/mutations/profile.py +16 -22
  170. infrahub/graphql/mutations/proposed_change.py +4 -4
  171. infrahub/graphql/mutations/relationship.py +40 -10
  172. infrahub/graphql/mutations/repository.py +14 -12
  173. infrahub/graphql/mutations/schema.py +2 -2
  174. infrahub/graphql/order.py +14 -0
  175. infrahub/graphql/queries/branch.py +62 -6
  176. infrahub/graphql/queries/diff/tree.py +5 -5
  177. infrahub/graphql/queries/resource_manager.py +25 -24
  178. infrahub/graphql/resolvers/account_metadata.py +84 -0
  179. infrahub/graphql/resolvers/ipam.py +6 -8
  180. infrahub/graphql/resolvers/many_relationship.py +77 -35
  181. infrahub/graphql/resolvers/resolver.py +59 -14
  182. infrahub/graphql/resolvers/single_relationship.py +87 -23
  183. infrahub/graphql/subscription/graphql_query.py +2 -0
  184. infrahub/graphql/types/__init__.py +0 -1
  185. infrahub/graphql/types/attribute.py +10 -5
  186. infrahub/graphql/types/branch.py +40 -53
  187. infrahub/graphql/types/enums.py +3 -0
  188. infrahub/graphql/types/metadata.py +28 -0
  189. infrahub/graphql/types/node.py +22 -2
  190. infrahub/graphql/types/relationship.py +10 -2
  191. infrahub/graphql/types/standard_node.py +12 -7
  192. infrahub/hfid/tasks.py +12 -3
  193. infrahub/lock.py +7 -0
  194. infrahub/menu/repository.py +1 -1
  195. infrahub/patch/queries/base.py +1 -1
  196. infrahub/pools/number.py +1 -8
  197. infrahub/profiles/gather.py +56 -0
  198. infrahub/profiles/mandatory_fields_checker.py +116 -0
  199. infrahub/profiles/models.py +66 -0
  200. infrahub/profiles/node_applier.py +154 -13
  201. infrahub/profiles/queries/get_profile_data.py +143 -31
  202. infrahub/profiles/tasks.py +79 -27
  203. infrahub/profiles/triggers.py +22 -0
  204. infrahub/proposed_change/action_checker.py +1 -1
  205. infrahub/proposed_change/tasks.py +4 -1
  206. infrahub/services/__init__.py +1 -1
  207. infrahub/services/adapters/cache/nats.py +1 -1
  208. infrahub/services/adapters/cache/redis.py +7 -0
  209. infrahub/tasks/artifact.py +1 -0
  210. infrahub/transformations/tasks.py +2 -2
  211. infrahub/trigger/catalogue.py +2 -0
  212. infrahub/trigger/models.py +1 -0
  213. infrahub/trigger/setup.py +3 -3
  214. infrahub/trigger/tasks.py +3 -0
  215. infrahub/validators/tasks.py +1 -0
  216. infrahub/webhook/gather.py +1 -1
  217. infrahub/webhook/models.py +1 -1
  218. infrahub/webhook/tasks.py +23 -7
  219. infrahub/workers/dependencies.py +9 -3
  220. infrahub/workers/infrahub_async.py +13 -4
  221. infrahub/workflows/catalogue.py +19 -0
  222. infrahub_sdk/analyzer.py +2 -2
  223. infrahub_sdk/branch.py +12 -39
  224. infrahub_sdk/checks.py +4 -4
  225. infrahub_sdk/client.py +36 -0
  226. infrahub_sdk/ctl/cli_commands.py +2 -1
  227. infrahub_sdk/ctl/graphql.py +15 -4
  228. infrahub_sdk/ctl/utils.py +2 -2
  229. infrahub_sdk/enums.py +6 -0
  230. infrahub_sdk/graphql/renderers.py +21 -0
  231. infrahub_sdk/graphql/utils.py +85 -0
  232. infrahub_sdk/node/attribute.py +12 -2
  233. infrahub_sdk/node/constants.py +12 -0
  234. infrahub_sdk/node/metadata.py +69 -0
  235. infrahub_sdk/node/node.py +65 -14
  236. infrahub_sdk/node/property.py +3 -0
  237. infrahub_sdk/node/related_node.py +37 -5
  238. infrahub_sdk/node/relationship.py +18 -1
  239. infrahub_sdk/operation.py +2 -2
  240. infrahub_sdk/schema/repository.py +1 -2
  241. infrahub_sdk/transforms.py +2 -2
  242. infrahub_sdk/types.py +18 -2
  243. {infrahub_server-1.6.2.dist-info → infrahub_server-1.7.0.dist-info}/METADATA +17 -16
  244. {infrahub_server-1.6.2.dist-info → infrahub_server-1.7.0.dist-info}/RECORD +252 -231
  245. infrahub_testcontainers/container.py +3 -3
  246. infrahub_testcontainers/docker-compose-cluster.test.yml +7 -7
  247. infrahub_testcontainers/docker-compose.test.yml +13 -5
  248. infrahub_testcontainers/models.py +3 -3
  249. infrahub_testcontainers/performance_test.py +1 -1
  250. infrahub/graphql/models.py +0 -6
  251. {infrahub_server-1.6.2.dist-info → infrahub_server-1.7.0.dist-info}/WHEEL +0 -0
  252. {infrahub_server-1.6.2.dist-info → infrahub_server-1.7.0.dist-info}/entry_points.txt +0 -0
  253. {infrahub_server-1.6.2.dist-info → infrahub_server-1.7.0.dist-info}/licenses/LICENSE.txt +0 -0
infrahub_sdk/enums.py ADDED
@@ -0,0 +1,6 @@
1
+ from enum import Enum
2
+
3
+
4
+ class OrderDirection(str, Enum):
5
+ ASC = "ASC"
6
+ DESC = "DESC"
@@ -7,6 +7,8 @@ from typing import Any
7
7
 
8
8
  from pydantic import BaseModel
9
9
 
10
+ from infrahub_sdk.types import Order
11
+
10
12
  from .constants import VARIABLE_TYPE_MAPPING
11
13
 
12
14
 
@@ -53,6 +55,16 @@ def convert_to_graphql_as_string(value: Any, convert_enum: bool = False) -> str:
53
55
  if isinstance(value, list):
54
56
  values_as_string = [convert_to_graphql_as_string(value=item, convert_enum=convert_enum) for item in value]
55
57
  return "[" + ", ".join(values_as_string) + "]"
58
+ if isinstance(value, Order):
59
+ data = value.model_dump(exclude_none=True)
60
+ return (
61
+ "{ "
62
+ + ", ".join(
63
+ f"{key}: {convert_to_graphql_as_string(value=val, convert_enum=convert_enum)}"
64
+ for key, val in data.items()
65
+ )
66
+ + " }"
67
+ )
56
68
  if isinstance(value, BaseModel):
57
69
  data = value.model_dump()
58
70
  return (
@@ -63,6 +75,15 @@ def convert_to_graphql_as_string(value: Any, convert_enum: bool = False) -> str:
63
75
  )
64
76
  + " }"
65
77
  )
78
+ if isinstance(value, dict):
79
+ return (
80
+ "{ "
81
+ + ", ".join(
82
+ f"{key}: {convert_to_graphql_as_string(value=val, convert_enum=convert_enum)}"
83
+ for key, val in value.items()
84
+ )
85
+ + " }"
86
+ )
66
87
 
67
88
  return str(value)
68
89
 
@@ -1,5 +1,90 @@
1
1
  import ast
2
2
 
3
+ from graphql import (
4
+ FieldNode,
5
+ FragmentDefinitionNode,
6
+ FragmentSpreadNode,
7
+ InlineFragmentNode,
8
+ OperationDefinitionNode,
9
+ SelectionNode,
10
+ SelectionSetNode,
11
+ )
12
+
13
+
14
+ def strip_typename_from_selection_set(selection_set: SelectionSetNode | None) -> SelectionSetNode | None:
15
+ """Recursively strip __typename fields from a SelectionSetNode.
16
+
17
+ The __typename meta-field is an introspection field that is not part of the schema's
18
+ type definitions. When code generation tools like ariadne-codegen try to look up
19
+ __typename in the schema, they fail because it's a reserved introspection field.
20
+
21
+ This function removes all __typename fields from the selection set, allowing
22
+ code generation to proceed without errors.
23
+ """
24
+ if selection_set is None:
25
+ return None
26
+
27
+ new_selections: list[SelectionNode] = []
28
+ for selection in selection_set.selections:
29
+ if isinstance(selection, FieldNode):
30
+ # Skip __typename fields
31
+ if selection.name.value == "__typename":
32
+ continue
33
+ # Recursively process nested selection sets
34
+ new_field = FieldNode(
35
+ alias=selection.alias,
36
+ name=selection.name,
37
+ arguments=selection.arguments,
38
+ directives=selection.directives,
39
+ selection_set=strip_typename_from_selection_set(selection.selection_set),
40
+ )
41
+ new_selections.append(new_field)
42
+ elif isinstance(selection, InlineFragmentNode):
43
+ # Process inline fragments
44
+ new_inline = InlineFragmentNode(
45
+ type_condition=selection.type_condition,
46
+ directives=selection.directives,
47
+ selection_set=strip_typename_from_selection_set(selection.selection_set),
48
+ )
49
+ new_selections.append(new_inline)
50
+ elif isinstance(selection, FragmentSpreadNode):
51
+ # FragmentSpread references a named fragment - keep as-is
52
+ new_selections.append(selection)
53
+ else:
54
+ raise TypeError(f"Unexpected GraphQL selection node type '{type(selection).__name__}'.")
55
+
56
+ return SelectionSetNode(selections=tuple(new_selections))
57
+
58
+
59
+ def strip_typename_from_operation(operation: OperationDefinitionNode) -> OperationDefinitionNode:
60
+ """Strip __typename fields from an operation definition.
61
+
62
+ Returns a new OperationDefinitionNode with all __typename fields removed
63
+ from its selection set and any nested selection sets.
64
+ """
65
+ return OperationDefinitionNode(
66
+ operation=operation.operation,
67
+ name=operation.name,
68
+ variable_definitions=operation.variable_definitions,
69
+ directives=operation.directives,
70
+ selection_set=strip_typename_from_selection_set(operation.selection_set),
71
+ )
72
+
73
+
74
+ def strip_typename_from_fragment(fragment: FragmentDefinitionNode) -> FragmentDefinitionNode:
75
+ """Strip __typename fields from a fragment definition.
76
+
77
+ Returns a new FragmentDefinitionNode with all __typename fields removed
78
+ from its selection set and any nested selection sets.
79
+ """
80
+ return FragmentDefinitionNode(
81
+ name=fragment.name,
82
+ type_condition=fragment.type_condition,
83
+ variable_definitions=fragment.variable_definitions,
84
+ directives=fragment.directives,
85
+ selection_set=strip_typename_from_selection_set(fragment.selection_set),
86
+ )
87
+
3
88
 
4
89
  def get_class_def_index(module: ast.Module) -> int:
5
90
  """Get the index of the first class definition in the module.
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any, get_args
6
6
 
7
7
  from ..protocols_base import CoreNodeBase
8
8
  from ..uuidt import UUIDT
9
- from .constants import IP_TYPES, PROPERTIES_FLAG, PROPERTIES_OBJECT, SAFE_VALUE
9
+ from .constants import ATTRIBUTE_METADATA_OBJECT, IP_TYPES, PROPERTIES_FLAG, PROPERTIES_OBJECT, SAFE_VALUE
10
10
  from .property import NodeProperty
11
11
 
12
12
  if TYPE_CHECKING:
@@ -57,11 +57,16 @@ class Attribute:
57
57
 
58
58
  self.source: NodeProperty | None = None
59
59
  self.owner: NodeProperty | None = None
60
+ self.updated_by: NodeProperty | None = None
60
61
 
61
62
  for prop_name in self._properties_object:
62
63
  if data.get(prop_name):
63
64
  setattr(self, prop_name, NodeProperty(data=data.get(prop_name))) # type: ignore[arg-type]
64
65
 
66
+ for prop_name in ATTRIBUTE_METADATA_OBJECT:
67
+ if data.get(prop_name):
68
+ setattr(self, prop_name, NodeProperty(data=data.get(prop_name))) # type: ignore[arg-type]
69
+
65
70
  @property
66
71
  def value(self) -> Any:
67
72
  return self._value
@@ -104,7 +109,7 @@ class Attribute:
104
109
 
105
110
  return {"data": data, "variables": variables}
106
111
 
107
- def _generate_query_data(self, property: bool = False) -> dict | None:
112
+ def _generate_query_data(self, property: bool = False, include_metadata: bool = False) -> dict | None:
108
113
  data: dict[str, Any] = {"value": None}
109
114
 
110
115
  if property:
@@ -115,6 +120,11 @@ class Attribute:
115
120
  for prop_name in self._properties_object:
116
121
  data[prop_name] = {"id": None, "display_label": None, "__typename": None}
117
122
 
123
+ if include_metadata:
124
+ data["updated_at"] = None
125
+ for prop_name in ATTRIBUTE_METADATA_OBJECT:
126
+ data[prop_name] = {"id": None, "display_label": None, "__typename": None}
127
+
118
128
  return data
119
129
 
120
130
  def _generate_mutation_query(self) -> dict[str, Any]:
@@ -3,6 +3,17 @@ import re
3
3
 
4
4
  PROPERTIES_FLAG = ["is_protected", "updated_at"]
5
5
  PROPERTIES_OBJECT = ["source", "owner"]
6
+
7
+ # Attribute-level metadata object fields (in addition to PROPERTIES_OBJECT)
8
+ ATTRIBUTE_METADATA_OBJECT = ["updated_by"]
9
+
10
+ # Node metadata fields (for node_metadata in GraphQL response)
11
+ NODE_METADATA_FIELDS_FLAG = ["created_at", "updated_at"]
12
+ NODE_METADATA_FIELDS_OBJECT = ["created_by", "updated_by"]
13
+
14
+ # Relationship metadata fields (for relationship_metadata in GraphQL response)
15
+ RELATIONSHIP_METADATA_FIELDS_FLAG = ["updated_at"]
16
+ RELATIONSHIP_METADATA_FIELDS_OBJECT = ["updated_by"]
6
17
  SAFE_VALUE = re.compile(r"(^[\. /:a-zA-Z0-9_-]+$)|(^$)")
7
18
 
8
19
  IP_TYPES = ipaddress.IPv4Interface | ipaddress.IPv6Interface | ipaddress.IPv4Network | ipaddress.IPv6Network
@@ -20,3 +31,4 @@ ARTIFACT_DEFINITION_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE = (
20
31
  HIERARCHY_FETCH_FEATURE_NOT_SUPPORTED_MESSAGE = "Hierarchical fields are not supported for this node."
21
32
 
22
33
  HFID_STR_SEPARATOR = "__"
34
+ PROFILE_KIND_PREFIX = "Profile"
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from .property import NodeProperty
4
+
5
+
6
+ class NodeMetadata:
7
+ """Represents metadata about a node (created_at, created_by, updated_at, updated_by)."""
8
+
9
+ def __init__(self, data: dict | None = None) -> None:
10
+ """
11
+ Args:
12
+ data: Data containing the metadata fields from the GraphQL response.
13
+ """
14
+ self.created_at: str | None = None
15
+ self.created_by: NodeProperty | None = None
16
+ self.updated_at: str | None = None
17
+ self.updated_by: NodeProperty | None = None
18
+
19
+ if data:
20
+ self.created_at = data.get("created_at")
21
+ self.updated_at = data.get("updated_at")
22
+ if data.get("created_by"):
23
+ self.created_by = NodeProperty(data["created_by"])
24
+ if data.get("updated_by"):
25
+ self.updated_by = NodeProperty(data["updated_by"])
26
+
27
+ def __repr__(self) -> str:
28
+ return (
29
+ f"NodeMetadata(created_at={self.created_at!r}, created_by={self.created_by!r}, "
30
+ f"updated_at={self.updated_at!r}, updated_by={self.updated_by!r})"
31
+ )
32
+
33
+ @classmethod
34
+ def _generate_query_data(cls) -> dict:
35
+ """Generate the query structure for node_metadata fields."""
36
+ return {
37
+ "created_at": None,
38
+ "created_by": {"id": None, "__typename": None, "display_label": None},
39
+ "updated_at": None,
40
+ "updated_by": {"id": None, "__typename": None, "display_label": None},
41
+ }
42
+
43
+
44
+ class RelationshipMetadata:
45
+ """Represents metadata about a relationship edge (updated_at, updated_by)."""
46
+
47
+ def __init__(self, data: dict | None = None) -> None:
48
+ """
49
+ Args:
50
+ data: Data containing the metadata fields from the GraphQL response.
51
+ """
52
+ self.updated_at: str | None = None
53
+ self.updated_by: NodeProperty | None = None
54
+
55
+ if data:
56
+ self.updated_at = data.get("updated_at")
57
+ if data.get("updated_by"):
58
+ self.updated_by = NodeProperty(data["updated_by"])
59
+
60
+ def __repr__(self) -> str:
61
+ return f"RelationshipMetadata(updated_at={self.updated_at!r}, updated_by={self.updated_by!r})"
62
+
63
+ @classmethod
64
+ def _generate_query_data(cls) -> dict:
65
+ """Generate the query structure for relationship_metadata fields."""
66
+ return {
67
+ "updated_at": None,
68
+ "updated_by": {"id": None, "__typename": None, "display_label": None},
69
+ }
infrahub_sdk/node/node.py CHANGED
@@ -23,6 +23,7 @@ from .constants import (
23
23
  ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE,
24
24
  PROPERTIES_OBJECT,
25
25
  )
26
+ from .metadata import NodeMetadata
26
27
  from .related_node import RelatedNode, RelatedNodeBase, RelatedNodeSync
27
28
  from .relationship import RelationshipManager, RelationshipManagerBase, RelationshipManagerSync
28
29
 
@@ -50,6 +51,7 @@ class InfrahubNodeBase:
50
51
  self._branch = branch
51
52
  self._existing: bool = True
52
53
  self._attribute_data: dict[str, Attribute] = {}
54
+ self._metadata: NodeMetadata | None = None
53
55
 
54
56
  # Generate a unique ID only to be used inside the SDK
55
57
  # The format if this ID is purposely different from the ID used by the API
@@ -152,6 +154,10 @@ class InfrahubNodeBase:
152
154
  def hfid_str(self) -> str | None:
153
155
  return self.get_human_friendly_id_as_string(include_kind=True)
154
156
 
157
+ def get_node_metadata(self) -> NodeMetadata | None:
158
+ """Returns the node metadata (created_at, created_by, updated_at, updated_by) if fetched."""
159
+ return self._metadata
160
+
155
161
  def _init_attributes(self, data: dict | None = None) -> None:
156
162
  for attr_schema in self._schema.attributes:
157
163
  attr_data = data.get(attr_schema.name, None) if isinstance(data, dict) else None
@@ -192,8 +198,8 @@ class InfrahubNodeBase:
192
198
  return self._schema.kind
193
199
 
194
200
  def get_all_kinds(self) -> list[str]:
195
- if hasattr(self._schema, "inherit_from"):
196
- return [self._schema.kind] + self._schema.inherit_from
201
+ if inherit_from := getattr(self._schema, "inherit_from", None):
202
+ return [self._schema.kind] + inherit_from
197
203
  return [self._schema.kind]
198
204
 
199
205
  def is_ip_prefix(self) -> bool:
@@ -210,7 +216,7 @@ class InfrahubNodeBase:
210
216
  def get_raw_graphql_data(self) -> dict | None:
211
217
  return self._data
212
218
 
213
- def _generate_input_data( # noqa: C901
219
+ def _generate_input_data( # noqa: C901, PLR0915
214
220
  self,
215
221
  exclude_unmodified: bool = False,
216
222
  exclude_hfid: bool = False,
@@ -253,7 +259,10 @@ class InfrahubNodeBase:
253
259
  rel: RelatedNodeBase | RelationshipManagerBase = getattr(self, item_name)
254
260
 
255
261
  if rel_schema.cardinality == RelationshipCardinality.ONE and rel_schema.optional and not rel.initialized:
256
- data[item_name] = None
262
+ # Only include None for existing nodes to allow clearing relationships
263
+ # For new nodes, omit the field to allow object template defaults to be applied
264
+ if self._existing:
265
+ data[item_name] = None
257
266
  continue
258
267
 
259
268
  if rel is None or not rel.initialized:
@@ -419,12 +428,16 @@ class InfrahubNodeBase:
419
428
  exclude: list[str] | None = None,
420
429
  partial_match: bool = False,
421
430
  order: Order | None = None,
431
+ include_metadata: bool = False,
422
432
  ) -> dict[str, Any | dict]:
423
433
  data: dict[str, Any] = {
424
434
  "count": None,
425
435
  "edges": {"node": {"id": None, "hfid": None, "display_label": None, "__typename": None}},
426
436
  }
427
437
 
438
+ if include_metadata:
439
+ data["edges"]["node_metadata"] = NodeMetadata._generate_query_data()
440
+
428
441
  data["@filters"] = deepcopy(filters) if filters is not None else {}
429
442
 
430
443
  if order:
@@ -496,8 +509,12 @@ class InfrahubNode(InfrahubNodeBase):
496
509
  """
497
510
  self._client = client
498
511
 
499
- if isinstance(data, dict) and isinstance(data.get("node"), dict):
500
- data = data.get("node")
512
+ # Extract node_metadata before extracting node data (node_metadata is sibling to node in edges)
513
+ node_metadata_data: dict | None = None
514
+ if isinstance(data, dict):
515
+ node_metadata_data = data.get("node_metadata")
516
+ if isinstance(data.get("node"), dict):
517
+ data = data.get("node")
501
518
 
502
519
  self._relationship_cardinality_many_data: dict[str, RelationshipManager] = {}
503
520
  self._relationship_cardinality_one_data: dict[str, RelatedNode] = {}
@@ -505,6 +522,10 @@ class InfrahubNode(InfrahubNodeBase):
505
522
 
506
523
  super().__init__(schema=schema, branch=branch or client.default_branch, data=data)
507
524
 
525
+ # Initialize metadata after base class init
526
+ if node_metadata_data:
527
+ self._metadata = NodeMetadata(node_metadata_data)
528
+
508
529
  @classmethod
509
530
  async def from_graphql(
510
531
  cls,
@@ -785,6 +806,7 @@ class InfrahubNode(InfrahubNodeBase):
785
806
  partial_match: bool = False,
786
807
  property: bool = False,
787
808
  order: Order | None = None,
809
+ include_metadata: bool = False,
788
810
  ) -> dict[str, Any | dict]:
789
811
  data = self.generate_query_data_init(
790
812
  filters=filters,
@@ -794,6 +816,7 @@ class InfrahubNode(InfrahubNodeBase):
794
816
  exclude=exclude,
795
817
  partial_match=partial_match,
796
818
  order=order,
819
+ include_metadata=include_metadata,
797
820
  )
798
821
  data["edges"]["node"].update(
799
822
  await self.generate_query_data_node(
@@ -802,6 +825,7 @@ class InfrahubNode(InfrahubNodeBase):
802
825
  prefetch_relationships=prefetch_relationships,
803
826
  inherited=True,
804
827
  property=property,
828
+ include_metadata=include_metadata,
805
829
  )
806
830
  )
807
831
 
@@ -825,6 +849,7 @@ class InfrahubNode(InfrahubNodeBase):
825
849
  inherited=False,
826
850
  insert_alias=True,
827
851
  property=property,
852
+ include_metadata=include_metadata,
828
853
  )
829
854
 
830
855
  if child_data:
@@ -840,6 +865,7 @@ class InfrahubNode(InfrahubNodeBase):
840
865
  insert_alias: bool = False,
841
866
  prefetch_relationships: bool = False,
842
867
  property: bool = False,
868
+ include_metadata: bool = False,
843
869
  ) -> dict[str, Any | dict]:
844
870
  """Generate the node part of a GraphQL Query with attributes and nodes.
845
871
 
@@ -850,6 +876,7 @@ class InfrahubNode(InfrahubNodeBase):
850
876
  Defaults to True.
851
877
  insert_alias (bool, optional): If True, inserts aliases in the query for each attribute or relationship.
852
878
  prefetch_relationships (bool, optional): If True, pre-fetches relationship data as part of the query.
879
+ include_metadata (bool, optional): If True, includes node_metadata and relationship_metadata in the query.
853
880
 
854
881
  Returns:
855
882
  dict[str, Union[Any, Dict]]: GraphQL query in dictionary format
@@ -866,7 +893,7 @@ class InfrahubNode(InfrahubNodeBase):
866
893
  if not inherited and attr._schema.inherited:
867
894
  continue
868
895
 
869
- attr_data = attr._generate_query_data(property=property)
896
+ attr_data = attr._generate_query_data(property=property, include_metadata=include_metadata)
870
897
  if attr_data:
871
898
  data[attr_name] = attr_data
872
899
  if insert_alias:
@@ -898,11 +925,14 @@ class InfrahubNode(InfrahubNodeBase):
898
925
  peer_node = InfrahubNode(client=self._client, schema=peer_schema, branch=self._branch)
899
926
  peer_data = await peer_node.generate_query_data_node(
900
927
  property=property,
928
+ include_metadata=include_metadata,
901
929
  )
902
930
 
903
931
  rel_data: dict[str, Any]
904
932
  if rel_schema and rel_schema.cardinality == "one":
905
- rel_data = RelatedNode._generate_query_data(peer_data=peer_data, property=property)
933
+ rel_data = RelatedNode._generate_query_data(
934
+ peer_data=peer_data, property=property, include_metadata=include_metadata
935
+ )
906
936
  # Nodes involved in a hierarchy are required to inherit from a common ancestor node, and graphql
907
937
  # tries to resolve attributes in this ancestor instead of actual node. To avoid
908
938
  # invalid queries issues when attribute is missing in the common ancestor, we use a fragment
@@ -912,7 +942,9 @@ class InfrahubNode(InfrahubNodeBase):
912
942
  rel_data["node"] = {}
913
943
  rel_data["node"][f"...on {rel_schema.peer}"] = data_node
914
944
  elif rel_schema and rel_schema.cardinality == "many":
915
- rel_data = RelationshipManager._generate_query_data(peer_data=peer_data, property=property)
945
+ rel_data = RelationshipManager._generate_query_data(
946
+ peer_data=peer_data, property=property, include_metadata=include_metadata
947
+ )
916
948
  else:
917
949
  continue
918
950
 
@@ -1285,8 +1317,12 @@ class InfrahubNodeSync(InfrahubNodeBase):
1285
1317
  """
1286
1318
  self._client = client
1287
1319
 
1288
- if isinstance(data, dict) and isinstance(data.get("node"), dict):
1289
- data = data.get("node")
1320
+ # Extract node_metadata before extracting node data (node_metadata is sibling to node in edges)
1321
+ node_metadata_data: dict | None = None
1322
+ if isinstance(data, dict):
1323
+ node_metadata_data = data.get("node_metadata")
1324
+ if isinstance(data.get("node"), dict):
1325
+ data = data.get("node")
1290
1326
 
1291
1327
  self._relationship_cardinality_many_data: dict[str, RelationshipManagerSync] = {}
1292
1328
  self._relationship_cardinality_one_data: dict[str, RelatedNodeSync] = {}
@@ -1294,6 +1330,10 @@ class InfrahubNodeSync(InfrahubNodeBase):
1294
1330
 
1295
1331
  super().__init__(schema=schema, branch=branch or client.default_branch, data=data)
1296
1332
 
1333
+ # Initialize metadata after base class init
1334
+ if node_metadata_data:
1335
+ self._metadata = NodeMetadata(node_metadata_data)
1336
+
1297
1337
  @classmethod
1298
1338
  def from_graphql(
1299
1339
  cls,
@@ -1571,6 +1611,7 @@ class InfrahubNodeSync(InfrahubNodeBase):
1571
1611
  partial_match: bool = False,
1572
1612
  property: bool = False,
1573
1613
  order: Order | None = None,
1614
+ include_metadata: bool = False,
1574
1615
  ) -> dict[str, Any | dict]:
1575
1616
  data = self.generate_query_data_init(
1576
1617
  filters=filters,
@@ -1580,6 +1621,7 @@ class InfrahubNodeSync(InfrahubNodeBase):
1580
1621
  exclude=exclude,
1581
1622
  partial_match=partial_match,
1582
1623
  order=order,
1624
+ include_metadata=include_metadata,
1583
1625
  )
1584
1626
  data["edges"]["node"].update(
1585
1627
  self.generate_query_data_node(
@@ -1588,6 +1630,7 @@ class InfrahubNodeSync(InfrahubNodeBase):
1588
1630
  prefetch_relationships=prefetch_relationships,
1589
1631
  inherited=True,
1590
1632
  property=property,
1633
+ include_metadata=include_metadata,
1591
1634
  )
1592
1635
  )
1593
1636
 
@@ -1610,6 +1653,7 @@ class InfrahubNodeSync(InfrahubNodeBase):
1610
1653
  inherited=False,
1611
1654
  insert_alias=True,
1612
1655
  property=property,
1656
+ include_metadata=include_metadata,
1613
1657
  )
1614
1658
 
1615
1659
  if child_data:
@@ -1625,6 +1669,7 @@ class InfrahubNodeSync(InfrahubNodeBase):
1625
1669
  insert_alias: bool = False,
1626
1670
  prefetch_relationships: bool = False,
1627
1671
  property: bool = False,
1672
+ include_metadata: bool = False,
1628
1673
  ) -> dict[str, Any | dict]:
1629
1674
  """Generate the node part of a GraphQL Query with attributes and nodes.
1630
1675
 
@@ -1635,6 +1680,7 @@ class InfrahubNodeSync(InfrahubNodeBase):
1635
1680
  Defaults to True.
1636
1681
  insert_alias (bool, optional): If True, inserts aliases in the query for each attribute or relationship.
1637
1682
  prefetch_relationships (bool, optional): If True, pre-fetches relationship data as part of the query.
1683
+ include_metadata (bool, optional): If True, includes node_metadata and relationship_metadata in the query.
1638
1684
 
1639
1685
  Returns:
1640
1686
  dict[str, Union[Any, Dict]]: GraphQL query in dictionary format
@@ -1651,7 +1697,7 @@ class InfrahubNodeSync(InfrahubNodeBase):
1651
1697
  if not inherited and attr._schema.inherited:
1652
1698
  continue
1653
1699
 
1654
- attr_data = attr._generate_query_data(property=property)
1700
+ attr_data = attr._generate_query_data(property=property, include_metadata=include_metadata)
1655
1701
  if attr_data:
1656
1702
  data[attr_name] = attr_data
1657
1703
  if insert_alias:
@@ -1683,11 +1729,14 @@ class InfrahubNodeSync(InfrahubNodeBase):
1683
1729
  peer_node = InfrahubNodeSync(client=self._client, schema=peer_schema, branch=self._branch)
1684
1730
  peer_data = peer_node.generate_query_data_node(
1685
1731
  property=property,
1732
+ include_metadata=include_metadata,
1686
1733
  )
1687
1734
 
1688
1735
  rel_data: dict[str, Any]
1689
1736
  if rel_schema and rel_schema.cardinality == "one":
1690
- rel_data = RelatedNodeSync._generate_query_data(peer_data=peer_data, property=property)
1737
+ rel_data = RelatedNodeSync._generate_query_data(
1738
+ peer_data=peer_data, property=property, include_metadata=include_metadata
1739
+ )
1691
1740
  # Nodes involved in a hierarchy are required to inherit from a common ancestor node, and graphql
1692
1741
  # tries to resolve attributes in this ancestor instead of actual node. To avoid
1693
1742
  # invalid queries issues when attribute is missing in the common ancestor, we use a fragment
@@ -1697,7 +1746,9 @@ class InfrahubNodeSync(InfrahubNodeBase):
1697
1746
  rel_data["node"] = {}
1698
1747
  rel_data["node"][f"...on {rel_schema.peer}"] = data_node
1699
1748
  elif rel_schema and rel_schema.cardinality == "many":
1700
- rel_data = RelationshipManagerSync._generate_query_data(peer_data=peer_data, property=property)
1749
+ rel_data = RelationshipManagerSync._generate_query_data(
1750
+ peer_data=peer_data, property=property, include_metadata=include_metadata
1751
+ )
1701
1752
  else:
1702
1753
  continue
1703
1754
 
@@ -20,5 +20,8 @@ class NodeProperty:
20
20
  self.display_label = data.get("display_label", None)
21
21
  self.typename = data.get("__typename", None)
22
22
 
23
+ def __repr__(self) -> str:
24
+ return f"NodeProperty({{'id': {self.id!r}, 'display_label': {self.display_label!r}, '__typename': {self.typename!r}}})"
25
+
23
26
  def _generate_input_data(self) -> str | None:
24
27
  return self.id
@@ -1,12 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
3
4
  from typing import TYPE_CHECKING, Any
4
5
 
5
- from ..exceptions import (
6
- Error,
7
- )
6
+ from ..exceptions import Error
8
7
  from ..protocols_base import CoreNodeBase
9
- from .constants import PROPERTIES_FLAG, PROPERTIES_OBJECT
8
+ from .constants import PROFILE_KIND_PREFIX, PROPERTIES_FLAG, PROPERTIES_OBJECT
9
+ from .metadata import NodeMetadata, RelationshipMetadata
10
10
 
11
11
  if TYPE_CHECKING:
12
12
  from ..client import InfrahubClient, InfrahubClientSync
@@ -40,11 +40,14 @@ class RelatedNodeBase:
40
40
  self._display_label: str | None = None
41
41
  self._typename: str | None = None
42
42
  self._kind: str | None = None
43
+ self._source_typename: str | None = None
44
+ self._relationship_metadata: RelationshipMetadata | None = None
43
45
 
44
46
  if isinstance(data, (CoreNodeBase)):
45
47
  self._peer = data
46
48
  for prop in self._properties:
47
49
  setattr(self, prop, None)
50
+ self._relationship_metadata = None
48
51
 
49
52
  elif isinstance(data, list):
50
53
  data = {"hfid": data}
@@ -74,11 +77,17 @@ class RelatedNodeBase:
74
77
  prop_data = properties_data.get(prop, properties_data.get(f"_relation__{prop}", None))
75
78
  if prop_data and isinstance(prop_data, dict) and "id" in prop_data:
76
79
  setattr(self, prop, prop_data["id"])
80
+ if prop == "source" and "__typename" in prop_data:
81
+ self._source_typename = prop_data["__typename"]
77
82
  elif prop_data and isinstance(prop_data, (str, bool)):
78
83
  setattr(self, prop, prop_data)
79
84
  else:
80
85
  setattr(self, prop, None)
81
86
 
87
+ # Parse relationship metadata (at edge level)
88
+ if data.get("relationship_metadata"):
89
+ self._relationship_metadata = RelationshipMetadata(data["relationship_metadata"])
90
+
82
91
  @property
83
92
  def id(self) -> str | None:
84
93
  if self._peer:
@@ -125,6 +134,17 @@ class RelatedNodeBase:
125
134
  return self._peer.get_kind()
126
135
  return self._kind
127
136
 
137
+ @property
138
+ def is_from_profile(self) -> bool:
139
+ """Return whether this relationship was set from a profile. Done by checking if the source is of a profile kind."""
140
+ if not self._source_typename:
141
+ return False
142
+ return bool(re.match(rf"^{PROFILE_KIND_PREFIX}[A-Z]", self._source_typename))
143
+
144
+ def get_relationship_metadata(self) -> RelationshipMetadata | None:
145
+ """Returns the relationship metadata (updated_at, updated_by) if fetched."""
146
+ return self._relationship_metadata
147
+
128
148
  def _generate_input_data(self, allocate_from_pool: bool = False) -> dict[str, Any]:
129
149
  data: dict[str, Any] = {}
130
150
 
@@ -151,12 +171,17 @@ class RelatedNodeBase:
151
171
  return {}
152
172
 
153
173
  @classmethod
154
- def _generate_query_data(cls, peer_data: dict[str, Any] | None = None, property: bool = False) -> dict:
174
+ def _generate_query_data(
175
+ cls, peer_data: dict[str, Any] | None = None, property: bool = False, include_metadata: bool = False
176
+ ) -> dict:
155
177
  """Generates the basic structure of a GraphQL query for a single relationship.
156
178
 
157
179
  Args:
158
180
  peer_data (dict[str, Union[Any, Dict]], optional): Additional data to be included in the query for the node.
159
181
  This is used to add extra fields when prefetching related node data.
182
+ property (bool, optional): If True, includes property fields (is_protected, source, owner, etc.).
183
+ include_metadata (bool, optional): If True, includes node_metadata (for the peer node) and
184
+ relationship_metadata (for the relationship edge) fields.
160
185
 
161
186
  Returns:
162
187
  Dict: A dictionary representing the basic structure of a GraphQL query, including the node's ID, display label,
@@ -172,6 +197,13 @@ class RelatedNodeBase:
172
197
  properties[prop_name] = {"id": None, "display_label": None, "__typename": None}
173
198
 
174
199
  data["properties"] = properties
200
+
201
+ if include_metadata:
202
+ # node_metadata is for the peer InfrahubNode (populated via from_graphql)
203
+ data["node_metadata"] = NodeMetadata._generate_query_data()
204
+ # relationship_metadata is for the relationship edge itself
205
+ data["relationship_metadata"] = RelationshipMetadata._generate_query_data()
206
+
175
207
  if peer_data:
176
208
  data["node"].update(peer_data)
177
209