infrahub-server 1.2.1__py3-none-any.whl → 1.2.3__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 (41) hide show
  1. infrahub/computed_attribute/tasks.py +71 -67
  2. infrahub/config.py +3 -0
  3. infrahub/core/graph/__init__.py +1 -1
  4. infrahub/core/migrations/graph/__init__.py +4 -1
  5. infrahub/core/migrations/graph/m024_missing_hierarchy_backfill.py +69 -0
  6. infrahub/core/models.py +6 -0
  7. infrahub/core/node/__init__.py +4 -4
  8. infrahub/core/node/constraints/grouped_uniqueness.py +24 -9
  9. infrahub/core/query/ipam.py +1 -1
  10. infrahub/core/query/node.py +16 -5
  11. infrahub/core/schema/schema_branch.py +14 -5
  12. infrahub/exceptions.py +30 -2
  13. infrahub/git/base.py +80 -29
  14. infrahub/git/integrator.py +9 -31
  15. infrahub/menu/repository.py +6 -6
  16. infrahub/trigger/tasks.py +19 -18
  17. infrahub/workflows/utils.py +5 -5
  18. infrahub_sdk/client.py +6 -6
  19. infrahub_sdk/ctl/cli_commands.py +32 -37
  20. infrahub_sdk/ctl/render.py +39 -0
  21. infrahub_sdk/exceptions.py +6 -2
  22. infrahub_sdk/generator.py +1 -1
  23. infrahub_sdk/node.py +41 -12
  24. infrahub_sdk/protocols_base.py +8 -1
  25. infrahub_sdk/pytest_plugin/items/jinja2_transform.py +22 -26
  26. infrahub_sdk/store.py +351 -75
  27. infrahub_sdk/template/__init__.py +209 -0
  28. infrahub_sdk/template/exceptions.py +38 -0
  29. infrahub_sdk/template/filters.py +151 -0
  30. infrahub_sdk/template/models.py +10 -0
  31. infrahub_sdk/utils.py +7 -0
  32. {infrahub_server-1.2.1.dist-info → infrahub_server-1.2.3.dist-info}/METADATA +2 -1
  33. {infrahub_server-1.2.1.dist-info → infrahub_server-1.2.3.dist-info}/RECORD +39 -36
  34. infrahub_testcontainers/container.py +2 -0
  35. infrahub_testcontainers/docker-compose.test.yml +1 -0
  36. infrahub_testcontainers/haproxy.cfg +3 -3
  37. infrahub/support/__init__.py +0 -0
  38. infrahub/support/macro.py +0 -69
  39. {infrahub_server-1.2.1.dist-info → infrahub_server-1.2.3.dist-info}/LICENSE.txt +0 -0
  40. {infrahub_server-1.2.1.dist-info → infrahub_server-1.2.3.dist-info}/WHEEL +0 -0
  41. {infrahub_server-1.2.1.dist-info → infrahub_server-1.2.3.dist-info}/entry_points.txt +0 -0
infrahub_sdk/node.py CHANGED
@@ -15,7 +15,7 @@ from .exceptions import (
15
15
  )
16
16
  from .graphql import Mutation, Query
17
17
  from .schema import GenericSchemaAPI, RelationshipCardinality, RelationshipKind
18
- from .utils import compare_lists, get_flat_value
18
+ from .utils import compare_lists, generate_short_id, get_flat_value
19
19
  from .uuidt import UUIDT
20
20
 
21
21
  if TYPE_CHECKING:
@@ -43,6 +43,20 @@ ARTIFACT_DEFINITION_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE = (
43
43
  "calling generate is only supported for CoreArtifactDefinition nodes"
44
44
  )
45
45
 
46
+ HFID_STR_SEPARATOR = "__"
47
+
48
+
49
+ def parse_human_friendly_id(hfid: str | list[str]) -> tuple[str | None, list[str]]:
50
+ """Parse a human friendly ID into a kind and an identifier."""
51
+ if isinstance(hfid, str):
52
+ hfid_parts = hfid.split(HFID_STR_SEPARATOR)
53
+ if len(hfid_parts) == 1:
54
+ return None, hfid_parts
55
+ return hfid_parts[0], hfid_parts[1:]
56
+ if isinstance(hfid, list):
57
+ return None, hfid
58
+ raise ValueError(f"Invalid human friendly ID: {hfid}")
59
+
46
60
 
47
61
  class Attribute:
48
62
  """Represents an attribute of a Node, including its schema, value, and properties."""
@@ -340,10 +354,10 @@ class RelatedNode(RelatedNodeBase):
340
354
  return self._peer # type: ignore[return-value]
341
355
 
342
356
  if self.id and self.typename:
343
- return self._client.store.get(key=self.id, kind=self.typename) # type: ignore[return-value]
357
+ return self._client.store.get(key=self.id, kind=self.typename, branch=self._branch) # type: ignore[return-value]
344
358
 
345
359
  if self.hfid_str:
346
- return self._client.store.get_by_hfid(key=self.hfid_str) # type: ignore[return-value]
360
+ return self._client.store.get(key=self.hfid_str, branch=self._branch) # type: ignore[return-value]
347
361
 
348
362
  raise ValueError("Node must have at least one identifier (ID or HFID) to query it.")
349
363
 
@@ -387,10 +401,10 @@ class RelatedNodeSync(RelatedNodeBase):
387
401
  return self._peer # type: ignore[return-value]
388
402
 
389
403
  if self.id and self.typename:
390
- return self._client.store.get(key=self.id, kind=self.typename) # type: ignore[return-value]
404
+ return self._client.store.get(key=self.id, kind=self.typename, branch=self._branch) # type: ignore[return-value]
391
405
 
392
406
  if self.hfid_str:
393
- return self._client.store.get_by_hfid(key=self.hfid_str) # type: ignore[return-value]
407
+ return self._client.store.get(key=self.hfid_str, branch=self._branch) # type: ignore[return-value]
394
408
 
395
409
  raise ValueError("Node must have at least one identifier (ID or HFID) to query it.")
396
410
 
@@ -678,6 +692,11 @@ class InfrahubNodeBase:
678
692
  self._branch = branch
679
693
  self._existing: bool = True
680
694
 
695
+ # Generate a unique ID only to be used inside the SDK
696
+ # The format if this ID is purposely different from the ID used by the API
697
+ # This is done to avoid confusion and potential conflicts between the IDs
698
+ self._internal_id = generate_short_id()
699
+
681
700
  self.id = data.get("id", None) if isinstance(data, dict) else None
682
701
  self.display_label: str | None = data.get("display_label", None) if isinstance(data, dict) else None
683
702
  self.typename: str | None = data.get("__typename", schema.kind) if isinstance(data, dict) else schema.kind
@@ -694,6 +713,9 @@ class InfrahubNodeBase:
694
713
  self._init_attributes(data)
695
714
  self._init_relationships(data)
696
715
 
716
+ def get_branch(self) -> str:
717
+ return self._branch
718
+
697
719
  def get_path_value(self, path: str) -> Any:
698
720
  path_parts = path.split("__")
699
721
  return_value = None
@@ -794,6 +816,11 @@ class InfrahubNodeBase:
794
816
  def get_kind(self) -> str:
795
817
  return self._schema.kind
796
818
 
819
+ def get_all_kinds(self) -> list[str]:
820
+ if hasattr(self._schema, "inherit_from"):
821
+ return [self._schema.kind] + self._schema.inherit_from
822
+ return [self._schema.kind]
823
+
797
824
  def is_ip_prefix(self) -> bool:
798
825
  builtin_ipprefix_kind = "BuiltinIPPrefix"
799
826
  return self.get_kind() == builtin_ipprefix_kind or builtin_ipprefix_kind in self._schema.inherit_from # type: ignore[union-attr]
@@ -1201,7 +1228,7 @@ class InfrahubNode(InfrahubNodeBase):
1201
1228
  else:
1202
1229
  await self._client.group_context.add_related_nodes(ids=[self.id], update_group_context=update_group_context)
1203
1230
 
1204
- self._client.store.set(key=self.id, node=self)
1231
+ self._client.store.set(node=self)
1205
1232
 
1206
1233
  async def generate_query_data(
1207
1234
  self,
@@ -1418,8 +1445,10 @@ class InfrahubNode(InfrahubNodeBase):
1418
1445
  ) -> None:
1419
1446
  mutation_query = self._generate_mutation_query()
1420
1447
 
1448
+ # Upserting means we may want to create, meaning payload contains all mandatory fields required for a creation,
1449
+ # so hfid is just redondant information. Currently, upsert mutation has performance overhead if `hfid` is filled.
1421
1450
  if allow_upsert:
1422
- input_data = self._generate_input_data(exclude_hfid=False, request_context=request_context)
1451
+ input_data = self._generate_input_data(exclude_hfid=True, request_context=request_context)
1423
1452
  mutation_name = f"{self._schema.kind}Upsert"
1424
1453
  tracker = f"mutation-{str(self._schema.kind).lower()}-upsert"
1425
1454
  else:
@@ -1477,15 +1506,15 @@ class InfrahubNode(InfrahubNodeBase):
1477
1506
  for rel_name in self._relationships:
1478
1507
  rel = getattr(self, rel_name)
1479
1508
  if rel and isinstance(rel, RelatedNode):
1480
- relation = node_data["node"].get(rel_name)
1481
- if relation.get("node", None):
1509
+ relation = node_data["node"].get(rel_name, None)
1510
+ if relation and relation.get("node", None):
1482
1511
  related_node = await InfrahubNode.from_graphql(
1483
1512
  client=self._client, branch=branch, data=relation, timeout=timeout
1484
1513
  )
1485
1514
  related_nodes.append(related_node)
1486
1515
  elif rel and isinstance(rel, RelationshipManager):
1487
- peers = node_data["node"].get(rel_name)
1488
- if peers:
1516
+ peers = node_data["node"].get(rel_name, None)
1517
+ if peers and peers["edges"]:
1489
1518
  for peer in peers["edges"]:
1490
1519
  related_node = await InfrahubNode.from_graphql(
1491
1520
  client=self._client, branch=branch, data=peer, timeout=timeout
@@ -1724,7 +1753,7 @@ class InfrahubNodeSync(InfrahubNodeBase):
1724
1753
  else:
1725
1754
  self._client.group_context.add_related_nodes(ids=[self.id], update_group_context=update_group_context)
1726
1755
 
1727
- self._client.store.set(key=self.id, node=self)
1756
+ self._client.store.set(node=self)
1728
1757
 
1729
1758
  def generate_query_data(
1730
1759
  self,
@@ -144,7 +144,8 @@ class AnyAttributeOptional(Attribute):
144
144
  @runtime_checkable
145
145
  class CoreNodeBase(Protocol):
146
146
  _schema: MainSchemaTypes
147
- id: str
147
+ _internal_id: str
148
+ id: str # NOTE this is incorrect, should be str | None
148
149
  display_label: str | None
149
150
 
150
151
  @property
@@ -153,10 +154,16 @@ class CoreNodeBase(Protocol):
153
154
  @property
154
155
  def hfid_str(self) -> str | None: ...
155
156
 
157
+ def get_human_friendly_id(self) -> list[str] | None: ...
158
+
156
159
  def get_human_friendly_id_as_string(self, include_kind: bool = False) -> str | None: ...
157
160
 
158
161
  def get_kind(self) -> str: ...
159
162
 
163
+ def get_all_kinds(self) -> list[str]: ...
164
+
165
+ def get_branch(self) -> str: ...
166
+
160
167
  def is_ip_prefix(self) -> bool: ...
161
168
 
162
169
  def is_ip_address(self) -> bool: ...
@@ -1,51 +1,47 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import difflib
5
+ from pathlib import Path
4
6
  from typing import TYPE_CHECKING, Any
5
7
 
6
8
  import jinja2
7
9
  import ujson
8
10
  from httpx import HTTPStatusError
9
- from rich.console import Console
10
- from rich.traceback import Traceback
11
11
 
12
- from ...jinja2 import identify_faulty_jinja_code
13
- from ..exceptions import Jinja2TransformError, Jinja2TransformUndefinedError, OutputMatchError
12
+ from ...template import Jinja2Template
13
+ from ...template.exceptions import JinjaTemplateError
14
+ from ..exceptions import OutputMatchError
14
15
  from ..models import InfrahubInputOutputTest, InfrahubTestExpectedResult
15
16
  from .base import InfrahubItem
16
17
 
17
18
  if TYPE_CHECKING:
18
- from pathlib import Path
19
-
20
19
  from pytest import ExceptionInfo
21
20
 
22
21
 
23
22
  class InfrahubJinja2Item(InfrahubItem):
23
+ def _get_jinja2(self) -> Jinja2Template:
24
+ return Jinja2Template(
25
+ template=Path(self.resource_config.template_path), # type: ignore[attr-defined]
26
+ template_directory=Path(self.session.infrahub_config_path.parent), # type: ignore[attr-defined]
27
+ )
28
+
24
29
  def get_jinja2_environment(self) -> jinja2.Environment:
25
- loader = jinja2.FileSystemLoader(self.session.infrahub_config_path.parent) # type: ignore[attr-defined]
26
- return jinja2.Environment(loader=loader, trim_blocks=True, lstrip_blocks=True)
30
+ jinja2_template = self._get_jinja2()
31
+ return jinja2_template.get_environment()
27
32
 
28
33
  def get_jinja2_template(self) -> jinja2.Template:
29
- return self.get_jinja2_environment().get_template(str(self.resource_config.template_path)) # type: ignore[attr-defined]
34
+ jinja2_template = self._get_jinja2()
35
+ return jinja2_template.get_template()
30
36
 
31
37
  def render_jinja2_template(self, variables: dict[str, Any]) -> str | None:
38
+ jinja2_template = self._get_jinja2()
39
+
32
40
  try:
33
- return self.get_jinja2_template().render(**variables)
34
- except jinja2.UndefinedError as exc:
35
- traceback = Traceback(show_locals=False)
36
- errors = identify_faulty_jinja_code(traceback=traceback)
37
- console = Console()
38
- with console.capture() as capture:
39
- console.print(f"An error occurred while rendering Jinja2 transform:{self.name!r}\n", soft_wrap=True)
40
- console.print(f"{exc.message}\n", soft_wrap=True)
41
- for frame, syntax in errors:
42
- console.print(f"{frame.filename} on line {frame.lineno}\n", soft_wrap=True)
43
- console.print(syntax, soft_wrap=True)
44
- str_output = capture.get()
41
+ return asyncio.run(jinja2_template.render(variables=variables))
42
+ except JinjaTemplateError as exc:
45
43
  if self.test.expect == InfrahubTestExpectedResult.PASS:
46
- raise Jinja2TransformUndefinedError(
47
- name=self.name, message=str_output, rtb=traceback, errors=errors
48
- ) from exc
44
+ raise exc
49
45
  return None
50
46
 
51
47
  def get_result_differences(self, computed: Any) -> str | None:
@@ -99,8 +95,8 @@ class InfrahubJinja2TransformUnitRenderItem(InfrahubJinja2Item):
99
95
  raise OutputMatchError(name=self.name, differences=differences)
100
96
 
101
97
  def repr_failure(self, excinfo: ExceptionInfo, style: str | None = None) -> str:
102
- if isinstance(excinfo.value, (Jinja2TransformUndefinedError, Jinja2TransformError)):
103
- return excinfo.value.message
98
+ if isinstance(excinfo.value, (JinjaTemplateError)):
99
+ return str(excinfo.value.message)
104
100
 
105
101
  return super().repr_failure(excinfo, style=style)
106
102