infrahub-server 1.3.0b5__py3-none-any.whl → 1.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. infrahub/actions/constants.py +36 -79
  2. infrahub/actions/schema.py +2 -0
  3. infrahub/cli/db.py +7 -5
  4. infrahub/cli/upgrade.py +6 -1
  5. infrahub/core/attribute.py +5 -0
  6. infrahub/core/constraint/node/runner.py +3 -1
  7. infrahub/core/convert_object_type/conversion.py +2 -0
  8. infrahub/core/diff/coordinator.py +8 -1
  9. infrahub/core/diff/query/delete_query.py +8 -4
  10. infrahub/core/diff/query/field_specifiers.py +1 -1
  11. infrahub/core/diff/query/merge.py +2 -2
  12. infrahub/core/diff/repository/repository.py +4 -0
  13. infrahub/core/graph/__init__.py +1 -1
  14. infrahub/core/migrations/graph/__init__.py +2 -0
  15. infrahub/core/migrations/graph/m012_convert_account_generic.py +1 -1
  16. infrahub/core/migrations/graph/m015_diff_format_update.py +1 -2
  17. infrahub/core/migrations/graph/m016_diff_delete_bug_fix.py +1 -2
  18. infrahub/core/migrations/graph/m023_deduplicate_cardinality_one_relationships.py +2 -2
  19. infrahub/core/migrations/graph/m028_delete_diffs.py +1 -2
  20. infrahub/core/migrations/graph/m029_duplicates_cleanup.py +2 -2
  21. infrahub/core/migrations/graph/m031_check_number_attributes.py +102 -0
  22. infrahub/core/migrations/query/attribute_rename.py +1 -1
  23. infrahub/core/node/__init__.py +70 -37
  24. infrahub/core/path.py +14 -0
  25. infrahub/core/query/delete.py +3 -3
  26. infrahub/core/relationship/constraints/count.py +10 -9
  27. infrahub/core/relationship/constraints/interface.py +2 -1
  28. infrahub/core/relationship/constraints/peer_kind.py +2 -1
  29. infrahub/core/relationship/constraints/peer_parent.py +56 -0
  30. infrahub/core/relationship/constraints/peer_relatives.py +1 -1
  31. infrahub/core/relationship/constraints/profiles_kind.py +1 -1
  32. infrahub/core/schema/attribute_parameters.py +12 -5
  33. infrahub/core/schema/basenode_schema.py +107 -1
  34. infrahub/core/schema/definitions/internal.py +8 -1
  35. infrahub/core/schema/generated/relationship_schema.py +6 -1
  36. infrahub/core/schema/schema_branch.py +53 -13
  37. infrahub/core/validators/__init__.py +2 -1
  38. infrahub/core/validators/attribute/min_max.py +7 -2
  39. infrahub/core/validators/relationship/peer.py +174 -4
  40. infrahub/database/__init__.py +0 -1
  41. infrahub/dependencies/builder/constraint/grouped/node_runner.py +2 -0
  42. infrahub/dependencies/builder/constraint/relationship_manager/peer_parent.py +8 -0
  43. infrahub/dependencies/builder/constraint/schema/aggregated.py +2 -0
  44. infrahub/dependencies/builder/constraint/schema/relationship_peer.py +8 -0
  45. infrahub/dependencies/registry.py +2 -0
  46. infrahub/git/tasks.py +1 -0
  47. infrahub/graphql/app.py +5 -1
  48. infrahub/graphql/mutations/convert_object_type.py +16 -7
  49. infrahub/graphql/mutations/relationship.py +32 -0
  50. infrahub/graphql/queries/convert_object_type_mapping.py +3 -5
  51. infrahub/message_bus/operations/refresh/registry.py +3 -6
  52. infrahub/pools/models.py +14 -0
  53. infrahub/pools/tasks.py +71 -1
  54. infrahub/services/adapters/message_bus/nats.py +5 -1
  55. infrahub/services/scheduler.py +5 -1
  56. infrahub_sdk/ctl/generator.py +4 -4
  57. infrahub_sdk/ctl/repository.py +1 -1
  58. infrahub_sdk/node/__init__.py +2 -0
  59. infrahub_sdk/node/node.py +166 -93
  60. infrahub_sdk/pytest_plugin/items/python_transform.py +2 -1
  61. infrahub_sdk/query_groups.py +4 -3
  62. infrahub_sdk/utils.py +7 -20
  63. infrahub_sdk/yaml.py +6 -5
  64. {infrahub_server-1.3.0b5.dist-info → infrahub_server-1.3.1.dist-info}/METADATA +2 -2
  65. {infrahub_server-1.3.0b5.dist-info → infrahub_server-1.3.1.dist-info}/RECORD +68 -63
  66. {infrahub_server-1.3.0b5.dist-info → infrahub_server-1.3.1.dist-info}/LICENSE.txt +0 -0
  67. {infrahub_server-1.3.0b5.dist-info → infrahub_server-1.3.1.dist-info}/WHEEL +0 -0
  68. {infrahub_server-1.3.0b5.dist-info → infrahub_server-1.3.1.dist-info}/entry_points.txt +0 -0
infrahub/pools/tasks.py CHANGED
@@ -3,12 +3,16 @@ from __future__ import annotations
3
3
  from prefect import flow
4
4
  from prefect.logging import get_run_logger
5
5
 
6
+ from infrahub import lock
6
7
  from infrahub.context import InfrahubContext # noqa: TC001 needed for prefect flow
7
- from infrahub.core.constants import NumberPoolType
8
+ from infrahub.core.constants import InfrahubKind, NumberPoolType
8
9
  from infrahub.core.manager import NodeManager
10
+ from infrahub.core.node import Node
9
11
  from infrahub.core.protocols import CoreNumberPool
10
12
  from infrahub.core.registry import registry
11
13
  from infrahub.core.schema.attribute_parameters import NumberPoolParameters
14
+ from infrahub.exceptions import NodeNotFoundError
15
+ from infrahub.pools.models import NumberPoolLockDefinition
12
16
  from infrahub.pools.registration import get_branches_with_schema_number_pool
13
17
  from infrahub.services import InfrahubServices # noqa: TC001 needed for prefect flow
14
18
 
@@ -54,3 +58,69 @@ async def validate_schema_number_pools(
54
58
  elif not defined_on_branches:
55
59
  log.info(f"Deleting number pool (id={schema_number_pool.id}) as it is no longer defined in the schema")
56
60
  await schema_number_pool.delete(db=service.database)
61
+
62
+ existing_pool_ids = [pool.id for pool in schema_number_pools]
63
+ for registry_branch in registry.schema.get_branches():
64
+ schema_branch = service.database.schema.get_schema_branch(name=registry_branch)
65
+
66
+ for generic_name in schema_branch.generic_names:
67
+ generic_node = schema_branch.get_generic(name=generic_name, duplicate=False)
68
+ for attribute_name in generic_node.attribute_names:
69
+ attribute = generic_node.get_attribute(name=attribute_name)
70
+ if isinstance(attribute.parameters, NumberPoolParameters) and attribute.parameters.number_pool_id:
71
+ if attribute.parameters.number_pool_id not in existing_pool_ids:
72
+ await _create_number_pool(
73
+ service=service,
74
+ number_pool_id=attribute.parameters.number_pool_id,
75
+ pool_node=generic_node.kind,
76
+ pool_attribute=attribute_name,
77
+ start_range=attribute.parameters.start_range,
78
+ end_range=attribute.parameters.end_range,
79
+ )
80
+ existing_pool_ids.append(attribute.parameters.number_pool_id)
81
+
82
+ for node_name in schema_branch.node_names:
83
+ node = schema_branch.get_node(name=node_name, duplicate=False)
84
+ for attribute_name in node.attribute_names:
85
+ attribute = node.get_attribute(name=attribute_name)
86
+ if isinstance(attribute.parameters, NumberPoolParameters) and attribute.parameters.number_pool_id:
87
+ if attribute.parameters.number_pool_id not in existing_pool_ids:
88
+ await _create_number_pool(
89
+ service=service,
90
+ number_pool_id=attribute.parameters.number_pool_id,
91
+ pool_node=node.kind,
92
+ pool_attribute=attribute_name,
93
+ start_range=attribute.parameters.start_range,
94
+ end_range=attribute.parameters.end_range,
95
+ )
96
+ existing_pool_ids.append(attribute.parameters.number_pool_id)
97
+
98
+
99
+ async def _create_number_pool(
100
+ service: InfrahubServices,
101
+ number_pool_id: str,
102
+ pool_node: str,
103
+ pool_attribute: str,
104
+ start_range: int,
105
+ end_range: int,
106
+ ) -> None:
107
+ lock_definition = NumberPoolLockDefinition(pool_id=number_pool_id)
108
+ async with lock.registry.get(name=lock_definition.lock_name, namespace=lock_definition.namespace_name, local=False):
109
+ async with service.database.start_session() as dbs:
110
+ try:
111
+ await registry.manager.get_one_by_id_or_default_filter(
112
+ db=dbs, id=str(number_pool_id), kind=CoreNumberPool
113
+ )
114
+ except NodeNotFoundError:
115
+ number_pool = await Node.init(db=dbs, schema=InfrahubKind.NUMBERPOOL, branch=registry.default_branch)
116
+ await number_pool.new(
117
+ db=dbs,
118
+ id=number_pool_id,
119
+ name=f"{pool_node}.{pool_attribute} [{number_pool_id}]",
120
+ node=pool_node,
121
+ node_attribute=pool_attribute,
122
+ start_range=start_range,
123
+ end_range=end_range,
124
+ pool_type=NumberPoolType.SCHEMA.value,
125
+ )
126
+ await number_pool.save(db=dbs)
@@ -26,6 +26,8 @@ if TYPE_CHECKING:
26
26
  MessageFunction = Callable[[InfrahubMessage], Awaitable[None]]
27
27
  ResponseClass = TypeVar("ResponseClass")
28
28
 
29
+ publish_tasks = set()
30
+
29
31
 
30
32
  async def _add_request_id(message: InfrahubMessage) -> None:
31
33
  log_data = get_log_data()
@@ -223,7 +225,9 @@ class NATSMessageBus(InfrahubMessageBus):
223
225
  # Delayed retries are directly handled in the callback using Nack
224
226
  return
225
227
  # Use asyncio task for delayed publish since NATS does not support that out of the box
226
- asyncio.create_task(self._publish_with_delay(message, routing_key, delay))
228
+ task = asyncio.create_task(self._publish_with_delay(message, routing_key, delay))
229
+ publish_tasks.add(task)
230
+ task.add_done_callback(publish_tasks.discard)
227
231
  return
228
232
 
229
233
  for enricher in self.message_enrichers:
@@ -16,6 +16,8 @@ if TYPE_CHECKING:
16
16
 
17
17
  log = get_logger()
18
18
 
19
+ background_tasks = set()
20
+
19
21
 
20
22
  @dataclass
21
23
  class Schedule:
@@ -56,7 +58,9 @@ class InfrahubScheduler:
56
58
 
57
59
  async def start_schedule(self) -> None:
58
60
  for schedule in self.schedules:
59
- asyncio.create_task(self.run_schedule(schedule=schedule), name=f"scheduled_task_{schedule.name}")
61
+ task = asyncio.create_task(self.run_schedule(schedule=schedule), name=f"scheduled_task_{schedule.name}")
62
+ background_tasks.add(task)
63
+ task.add_done_callback(background_tasks.discard)
60
64
 
61
65
  async def shutdown(self) -> None:
62
66
  self.running = False
@@ -74,20 +74,20 @@ async def run(
74
74
  targets = await client.get(
75
75
  kind="CoreGroup", branch=branch, include=["members"], name__value=generator_config.targets
76
76
  )
77
- await targets.members.fetch()
77
+ await targets._get_relationship_many(name="members").fetch()
78
78
 
79
- if not targets.members.peers:
79
+ if not targets._get_relationship_many(name="members").peers:
80
80
  console.print(
81
81
  f"[red]No members found within '{generator_config.targets}', not running generator '{generator_name}'"
82
82
  )
83
83
  return
84
84
 
85
- for member in targets.members.peers:
85
+ for member in targets._get_relationship_many(name="members").peers:
86
86
  check_parameter = {}
87
87
  if identifier:
88
88
  attribute = getattr(member.peer, identifier)
89
89
  check_parameter = {identifier: attribute.value}
90
- params = {"name": member.peer.name.value}
90
+ params = {"name": member.peer._get_attribute(name="name").value}
91
91
  generator = generator_class(
92
92
  query=generator_config.query,
93
93
  client=client,
@@ -45,7 +45,7 @@ def get_repository_config(repo_config_file: Path) -> InfrahubRepositoryConfig:
45
45
 
46
46
 
47
47
  def load_repository_config_file(repo_config_file: Path) -> dict:
48
- yaml_data = read_file(file_name=repo_config_file)
48
+ yaml_data = read_file(file_path=repo_config_file)
49
49
 
50
50
  try:
51
51
  data = yaml.safe_load(yaml_data)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from .attribute import Attribute
3
4
  from .constants import (
4
5
  ARTIFACT_DEFINITION_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE,
5
6
  ARTIFACT_FETCH_FEATURE_NOT_SUPPORTED_MESSAGE,
@@ -25,6 +26,7 @@ __all__ = [
25
26
  "PROPERTIES_FLAG",
26
27
  "PROPERTIES_OBJECT",
27
28
  "SAFE_VALUE",
29
+ "Attribute",
28
30
  "InfrahubNode",
29
31
  "InfrahubNodeBase",
30
32
  "InfrahubNodeSync",
infrahub_sdk/node/node.py CHANGED
@@ -1,13 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Iterable
3
4
  from copy import copy
4
5
  from typing import TYPE_CHECKING, Any
5
6
 
6
7
  from ..constants import InfrahubClientMode
7
- from ..exceptions import (
8
- FeatureNotSupportedError,
9
- NodeNotFoundError,
10
- )
8
+ from ..exceptions import FeatureNotSupportedError, NodeNotFoundError, ResourceNotDefinedError, SchemaNotFoundError
11
9
  from ..graphql import Mutation, Query
12
10
  from ..schema import GenericSchemaAPI, RelationshipCardinality, RelationshipKind
13
11
  from ..utils import compare_lists, generate_short_id, get_flat_value
@@ -30,48 +28,6 @@ if TYPE_CHECKING:
30
28
  from ..types import Order
31
29
 
32
30
 
33
- def generate_relationship_property(node: InfrahubNode | InfrahubNodeSync, name: str) -> property:
34
- """Generates a property that stores values under a private non-public name.
35
-
36
- Args:
37
- node (Union[InfrahubNode, InfrahubNodeSync]): The node instance.
38
- name (str): The name of the relationship property.
39
-
40
- Returns:
41
- A property object for managing the relationship.
42
-
43
- """
44
- internal_name = "_" + name.lower()
45
- external_name = name
46
-
47
- def prop_getter(self: InfrahubNodeBase) -> Any:
48
- return getattr(self, internal_name)
49
-
50
- def prop_setter(self: InfrahubNodeBase, value: Any) -> None:
51
- if isinstance(value, RelatedNodeBase) or value is None:
52
- setattr(self, internal_name, value)
53
- else:
54
- schema = [rel for rel in self._schema.relationships if rel.name == external_name][0]
55
- if isinstance(node, InfrahubNode):
56
- setattr(
57
- self,
58
- internal_name,
59
- RelatedNode(
60
- name=external_name, branch=node._branch, client=node._client, schema=schema, data=value
61
- ),
62
- )
63
- else:
64
- setattr(
65
- self,
66
- internal_name,
67
- RelatedNodeSync(
68
- name=external_name, branch=node._branch, client=node._client, schema=schema, data=value
69
- ),
70
- )
71
-
72
- return property(prop_getter, prop_setter)
73
-
74
-
75
31
  class InfrahubNodeBase:
76
32
  """Base class for InfrahubNode and InfrahubNodeSync"""
77
33
 
@@ -86,6 +42,7 @@ class InfrahubNodeBase:
86
42
  self._data = data
87
43
  self._branch = branch
88
44
  self._existing: bool = True
45
+ self._attribute_data: dict[str, Attribute] = {}
89
46
 
90
47
  # Generate a unique ID only to be used inside the SDK
91
48
  # The format if this ID is purposely different from the ID used by the API
@@ -180,12 +137,18 @@ class InfrahubNodeBase:
180
137
  def _init_attributes(self, data: dict | None = None) -> None:
181
138
  for attr_schema in self._schema.attributes:
182
139
  attr_data = data.get(attr_schema.name, None) if isinstance(data, dict) else None
183
- setattr(
184
- self,
185
- attr_schema.name,
186
- Attribute(name=attr_schema.name, schema=attr_schema, data=attr_data),
140
+ self._attribute_data[attr_schema.name] = Attribute(
141
+ name=attr_schema.name, schema=attr_schema, data=attr_data
187
142
  )
188
143
 
144
+ def __setattr__(self, name: str, value: Any) -> None:
145
+ """Set values for attributes that exist or revert to normal behaviour"""
146
+ if "_attribute_data" in self.__dict__ and name in self._attribute_data:
147
+ self._attribute_data[name].value = value
148
+ return
149
+
150
+ super().__setattr__(name, value)
151
+
189
152
  def _get_request_context(self, request_context: RequestContext | None = None) -> dict[str, Any] | None:
190
153
  if request_context:
191
154
  return request_context.model_dump(exclude_none=True)
@@ -487,6 +450,12 @@ class InfrahubNodeBase:
487
450
  }}
488
451
  """
489
452
 
453
+ def _get_attribute(self, name: str) -> Attribute:
454
+ if name in self._attribute_data:
455
+ return self._attribute_data[name]
456
+
457
+ raise ResourceNotDefinedError(message=f"The node doesn't have an attribute for {name}")
458
+
490
459
 
491
460
  class InfrahubNode(InfrahubNodeBase):
492
461
  """Represents a Infrahub node in an asynchronous context."""
@@ -506,11 +475,13 @@ class InfrahubNode(InfrahubNodeBase):
506
475
  data: Optional data to initialize the node.
507
476
  """
508
477
  self._client = client
509
- self.__class__ = type(f"{schema.kind}InfrahubNode", (self.__class__,), {})
510
478
 
511
479
  if isinstance(data, dict) and isinstance(data.get("node"), dict):
512
480
  data = data.get("node")
513
481
 
482
+ self._relationship_cardinality_many_data: dict[str, RelationshipManager] = {}
483
+ self._relationship_cardinality_one_data: dict[str, RelatedNode] = {}
484
+
514
485
  super().__init__(schema=schema, branch=branch or client.default_branch, data=data)
515
486
 
516
487
  @classmethod
@@ -530,31 +501,60 @@ class InfrahubNode(InfrahubNodeBase):
530
501
 
531
502
  return cls(client=client, schema=schema, branch=branch, data=cls._strip_alias(data))
532
503
 
533
- def _init_relationships(self, data: dict | None = None) -> None:
504
+ def _init_relationships(self, data: dict | RelatedNode | None = None) -> None:
534
505
  for rel_schema in self._schema.relationships:
535
506
  rel_data = data.get(rel_schema.name, None) if isinstance(data, dict) else None
536
507
 
537
508
  if rel_schema.cardinality == "one":
538
- setattr(self, f"_{rel_schema.name}", None)
539
- setattr(
540
- self.__class__,
541
- rel_schema.name,
542
- generate_relationship_property(name=rel_schema.name, node=self),
509
+ if isinstance(rel_data, RelatedNode):
510
+ peer_id_data: dict[str, Any] = {}
511
+ if rel_data.id:
512
+ peer_id_data["id"] = rel_data.id
513
+ if rel_data.hfid:
514
+ peer_id_data["hfid"] = rel_data.hfid
515
+ if peer_id_data:
516
+ rel_data = peer_id_data
517
+ else:
518
+ rel_data = None
519
+ self._relationship_cardinality_one_data[rel_schema.name] = RelatedNode(
520
+ name=rel_schema.name, branch=self._branch, client=self._client, schema=rel_schema, data=rel_data
543
521
  )
544
- setattr(self, rel_schema.name, rel_data)
545
522
  else:
546
- setattr(
547
- self,
548
- rel_schema.name,
549
- RelationshipManager(
550
- name=rel_schema.name,
551
- client=self._client,
552
- node=self,
553
- branch=self._branch,
554
- schema=rel_schema,
555
- data=rel_data,
556
- ),
523
+ self._relationship_cardinality_many_data[rel_schema.name] = RelationshipManager(
524
+ name=rel_schema.name,
525
+ client=self._client,
526
+ node=self,
527
+ branch=self._branch,
528
+ schema=rel_schema,
529
+ data=rel_data,
530
+ )
531
+
532
+ def __getattr__(self, name: str) -> Attribute | RelationshipManager | RelatedNode:
533
+ if "_attribute_data" in self.__dict__ and name in self._attribute_data:
534
+ return self._attribute_data[name]
535
+ if "_relationship_cardinality_many_data" in self.__dict__ and name in self._relationship_cardinality_many_data:
536
+ return self._relationship_cardinality_many_data[name]
537
+ if "_relationship_cardinality_one_data" in self.__dict__ and name in self._relationship_cardinality_one_data:
538
+ return self._relationship_cardinality_one_data[name]
539
+
540
+ raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
541
+
542
+ def __setattr__(self, name: str, value: Any) -> None:
543
+ """Set values for relationship names that exist or revert to normal behaviour"""
544
+ if "_relationship_cardinality_one_data" in self.__dict__ and name in self._relationship_cardinality_one_data:
545
+ rel_schemas = [rel_schema for rel_schema in self._schema.relationships if rel_schema.name == name]
546
+ if not rel_schemas:
547
+ raise SchemaNotFoundError(
548
+ identifier=self._schema.kind,
549
+ message=f"Unable to find relationship schema for '{name}' on {self._schema.kind}",
557
550
  )
551
+ rel_schema = rel_schemas[0]
552
+ self._relationship_cardinality_one_data[name] = RelatedNode(
553
+ name=rel_schema.name, branch=self._branch, client=self._client, schema=rel_schema, data=value
554
+ )
555
+ return
556
+
557
+ super().__setattr__(name, value)
558
558
 
559
559
  async def generate(self, nodes: list[str] | None = None) -> None:
560
560
  self._validate_artifact_definition_support(ARTIFACT_DEFINITION_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE)
@@ -568,14 +568,14 @@ class InfrahubNode(InfrahubNodeBase):
568
568
  self._validate_artifact_support(ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE)
569
569
 
570
570
  artifact = await self._client.get(kind="CoreArtifact", name__value=name, object__ids=[self.id])
571
- await artifact.definition.fetch() # type: ignore[attr-defined]
572
- await artifact.definition.peer.generate([artifact.id]) # type: ignore[attr-defined]
571
+ await artifact._get_relationship_one(name="definition").fetch()
572
+ await artifact._get_relationship_one(name="definition").peer.generate([artifact.id])
573
573
 
574
574
  async def artifact_fetch(self, name: str) -> str | dict[str, Any]:
575
575
  self._validate_artifact_support(ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE)
576
576
 
577
577
  artifact = await self._client.get(kind="CoreArtifact", name__value=name, object__ids=[self.id])
578
- content = await self._client.object_store.get(identifier=artifact.storage_id.value) # type: ignore[attr-defined]
578
+ content = await self._client.object_store.get(identifier=artifact._get_attribute(name="storage_id").value)
579
579
  return content
580
580
 
581
581
  async def delete(self, timeout: int | None = None, request_context: RequestContext | None = None) -> None:
@@ -1018,6 +1018,27 @@ class InfrahubNode(InfrahubNodeBase):
1018
1018
  return [edge["node"] for edge in response[graphql_query_name]["edges"]]
1019
1019
  return []
1020
1020
 
1021
+ def _get_relationship_many(self, name: str) -> RelationshipManager:
1022
+ if name in self._relationship_cardinality_many_data:
1023
+ return self._relationship_cardinality_many_data[name]
1024
+
1025
+ raise ResourceNotDefinedError(message=f"The node doesn't have a cardinality=many relationship for {name}")
1026
+
1027
+ def _get_relationship_one(self, name: str) -> RelatedNode:
1028
+ if name in self._relationship_cardinality_one_data:
1029
+ return self._relationship_cardinality_one_data[name]
1030
+
1031
+ raise ResourceNotDefinedError(message=f"The node doesn't have a cardinality=one relationship for {name}")
1032
+
1033
+ def __dir__(self) -> Iterable[str]:
1034
+ base = list(super().__dir__())
1035
+ return sorted(
1036
+ base
1037
+ + list(self._attribute_data.keys())
1038
+ + list(self._relationship_cardinality_many_data.keys())
1039
+ + list(self._relationship_cardinality_one_data.keys())
1040
+ )
1041
+
1021
1042
 
1022
1043
  class InfrahubNodeSync(InfrahubNodeBase):
1023
1044
  """Represents a Infrahub node in a synchronous context."""
@@ -1036,12 +1057,14 @@ class InfrahubNodeSync(InfrahubNodeBase):
1036
1057
  branch (Optional[str]): The branch where the node resides.
1037
1058
  data (Optional[dict]): Optional data to initialize the node.
1038
1059
  """
1039
- self.__class__ = type(f"{schema.kind}InfrahubNodeSync", (self.__class__,), {})
1040
1060
  self._client = client
1041
1061
 
1042
1062
  if isinstance(data, dict) and isinstance(data.get("node"), dict):
1043
1063
  data = data.get("node")
1044
1064
 
1065
+ self._relationship_cardinality_many_data: dict[str, RelationshipManagerSync] = {}
1066
+ self._relationship_cardinality_one_data: dict[str, RelatedNodeSync] = {}
1067
+
1045
1068
  super().__init__(schema=schema, branch=branch or client.default_branch, data=data)
1046
1069
 
1047
1070
  @classmethod
@@ -1066,27 +1089,56 @@ class InfrahubNodeSync(InfrahubNodeBase):
1066
1089
  rel_data = data.get(rel_schema.name, None) if isinstance(data, dict) else None
1067
1090
 
1068
1091
  if rel_schema.cardinality == "one":
1069
- setattr(self, f"_{rel_schema.name}", None)
1070
- setattr(
1071
- self.__class__,
1072
- rel_schema.name,
1073
- generate_relationship_property(name=rel_schema.name, node=self),
1092
+ if isinstance(rel_data, RelatedNodeSync):
1093
+ peer_id_data: dict[str, Any] = {}
1094
+ if rel_data.id:
1095
+ peer_id_data["id"] = rel_data.id
1096
+ if rel_data.hfid:
1097
+ peer_id_data["hfid"] = rel_data.hfid
1098
+ if peer_id_data:
1099
+ rel_data = peer_id_data
1100
+ else:
1101
+ rel_data = None
1102
+ self._relationship_cardinality_one_data[rel_schema.name] = RelatedNodeSync(
1103
+ name=rel_schema.name, branch=self._branch, client=self._client, schema=rel_schema, data=rel_data
1074
1104
  )
1075
- setattr(self, rel_schema.name, rel_data)
1076
1105
  else:
1077
- setattr(
1078
- self,
1079
- rel_schema.name,
1080
- RelationshipManagerSync(
1081
- name=rel_schema.name,
1082
- client=self._client,
1083
- node=self,
1084
- branch=self._branch,
1085
- schema=rel_schema,
1086
- data=rel_data,
1087
- ),
1106
+ self._relationship_cardinality_many_data[rel_schema.name] = RelationshipManagerSync(
1107
+ name=rel_schema.name,
1108
+ client=self._client,
1109
+ node=self,
1110
+ branch=self._branch,
1111
+ schema=rel_schema,
1112
+ data=rel_data,
1088
1113
  )
1089
1114
 
1115
+ def __getattr__(self, name: str) -> Attribute | RelationshipManagerSync | RelatedNodeSync:
1116
+ if "_attribute_data" in self.__dict__ and name in self._attribute_data:
1117
+ return self._attribute_data[name]
1118
+ if "_relationship_cardinality_many_data" in self.__dict__ and name in self._relationship_cardinality_many_data:
1119
+ return self._relationship_cardinality_many_data[name]
1120
+ if "_relationship_cardinality_one_data" in self.__dict__ and name in self._relationship_cardinality_one_data:
1121
+ return self._relationship_cardinality_one_data[name]
1122
+
1123
+ raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
1124
+
1125
+ def __setattr__(self, name: str, value: Any) -> None:
1126
+ """Set values for relationship names that exist or revert to normal behaviour"""
1127
+ if "_relationship_cardinality_one_data" in self.__dict__ and name in self._relationship_cardinality_one_data:
1128
+ rel_schemas = [rel_schema for rel_schema in self._schema.relationships if rel_schema.name == name]
1129
+ if not rel_schemas:
1130
+ raise SchemaNotFoundError(
1131
+ identifier=self._schema.kind,
1132
+ message=f"Unable to find relationship schema for '{name}' on {self._schema.kind}",
1133
+ )
1134
+ rel_schema = rel_schemas[0]
1135
+ self._relationship_cardinality_one_data[name] = RelatedNodeSync(
1136
+ name=rel_schema.name, branch=self._branch, client=self._client, schema=rel_schema, data=value
1137
+ )
1138
+ return
1139
+
1140
+ super().__setattr__(name, value)
1141
+
1090
1142
  def generate(self, nodes: list[str] | None = None) -> None:
1091
1143
  self._validate_artifact_definition_support(ARTIFACT_DEFINITION_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE)
1092
1144
  nodes = nodes or []
@@ -1097,13 +1149,13 @@ class InfrahubNodeSync(InfrahubNodeBase):
1097
1149
  def artifact_generate(self, name: str) -> None:
1098
1150
  self._validate_artifact_support(ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE)
1099
1151
  artifact = self._client.get(kind="CoreArtifact", name__value=name, object__ids=[self.id])
1100
- artifact.definition.fetch() # type: ignore[attr-defined]
1101
- artifact.definition.peer.generate([artifact.id]) # type: ignore[attr-defined]
1152
+ artifact._get_relationship_one(name="definition").fetch()
1153
+ artifact._get_relationship_one(name="definition").peer.generate([artifact.id])
1102
1154
 
1103
1155
  def artifact_fetch(self, name: str) -> str | dict[str, Any]:
1104
1156
  self._validate_artifact_support(ARTIFACT_FETCH_FEATURE_NOT_SUPPORTED_MESSAGE)
1105
1157
  artifact = self._client.get(kind="CoreArtifact", name__value=name, object__ids=[self.id])
1106
- content = self._client.object_store.get(identifier=artifact.storage_id.value) # type: ignore[attr-defined]
1158
+ content = self._client.object_store.get(identifier=artifact._get_attribute(name="storage_id").value)
1107
1159
  return content
1108
1160
 
1109
1161
  def delete(self, timeout: int | None = None, request_context: RequestContext | None = None) -> None:
@@ -1545,3 +1597,24 @@ class InfrahubNodeSync(InfrahubNodeBase):
1545
1597
  if response[graphql_query_name].get("count", 0):
1546
1598
  return [edge["node"] for edge in response[graphql_query_name]["edges"]]
1547
1599
  return []
1600
+
1601
+ def _get_relationship_many(self, name: str) -> RelationshipManager | RelationshipManagerSync:
1602
+ if name in self._relationship_cardinality_many_data:
1603
+ return self._relationship_cardinality_many_data[name]
1604
+
1605
+ raise ResourceNotDefinedError(message=f"The node doesn't have a cardinality=many relationship for {name}")
1606
+
1607
+ def _get_relationship_one(self, name: str) -> RelatedNode | RelatedNodeSync:
1608
+ if name in self._relationship_cardinality_one_data:
1609
+ return self._relationship_cardinality_one_data[name]
1610
+
1611
+ raise ResourceNotDefinedError(message=f"The node doesn't have a cardinality=one relationship for {name}")
1612
+
1613
+ def __dir__(self) -> Iterable[str]:
1614
+ base = list(super().__dir__())
1615
+ return sorted(
1616
+ base
1617
+ + list(self._attribute_data.keys())
1618
+ + list(self._relationship_cardinality_many_data.keys())
1619
+ + list(self._relationship_cardinality_one_data.keys())
1620
+ )
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any
7
7
  import ujson
8
8
  from httpx import HTTPStatusError
9
9
 
10
+ from ...node import InfrahubNode
10
11
  from ..exceptions import OutputMatchError, PythonTransformDefinitionError
11
12
  from ..models import InfrahubTestExpectedResult
12
13
  from .base import InfrahubItem
@@ -41,7 +42,7 @@ class InfrahubPythonTransformItem(InfrahubItem):
41
42
  )
42
43
  client = self.session.infrahub_client # type: ignore[attr-defined]
43
44
  # TODO: Look into seeing how a transform class may use the branch, but set as a empty string for the time being to keep current behaviour
44
- self.transform_instance = transform_class(branch="", client=client)
45
+ self.transform_instance = transform_class(branch="", client=client, infrahub_node=InfrahubNode)
45
46
 
46
47
  def run_transform(self, variables: dict[str, Any]) -> Any:
47
48
  self.instantiate_transform()
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Sequence
3
4
  from typing import TYPE_CHECKING, Any
4
5
 
5
6
  from .constants import InfrahubClientMode
@@ -19,7 +20,7 @@ class InfrahubGroupContextBase:
19
20
  self.related_node_ids: list[str] = []
20
21
  self.related_group_ids: list[str] = []
21
22
  self.unused_member_ids: list[str] | None = None
22
- self.previous_members: list[RelatedNodeBase] | None = None
23
+ self.previous_members: Sequence[RelatedNodeBase] | None = None
23
24
  self.previous_children: list[RelatedNodeBase] | None = None
24
25
  self.identifier: str | None = None
25
26
  self.params: dict[str, str] = {}
@@ -101,7 +102,7 @@ class InfrahubGroupContext(InfrahubGroupContextBase):
101
102
  if not store_peers:
102
103
  return group
103
104
 
104
- self.previous_members = group.members.peers # type: ignore[attr-defined]
105
+ self.previous_members = group._get_relationship_many(name="members").peers
105
106
  return group
106
107
 
107
108
  async def delete_unused(self) -> None:
@@ -195,7 +196,7 @@ class InfrahubGroupContextSync(InfrahubGroupContextBase):
195
196
  if not store_peers:
196
197
  return group
197
198
 
198
- self.previous_members = group.members.peers # type: ignore[attr-defined]
199
+ self.previous_members = group._get_relationship_many(name="members").peers
199
200
  return group
200
201
 
201
202
  def delete_unused(self) -> None:
infrahub_sdk/utils.py CHANGED
@@ -240,21 +240,6 @@ def is_valid_url(url: str) -> bool:
240
240
  return False
241
241
 
242
242
 
243
- def find_files(extension: str | list[str], directory: str | Path = ".") -> list[Path]:
244
- files: list[Path] = []
245
-
246
- if isinstance(extension, str):
247
- extension = [extension]
248
- if isinstance(directory, str):
249
- directory = Path(directory)
250
-
251
- for ext in extension:
252
- files.extend(list(directory.glob(f"**/*.{ext}")))
253
- files.extend(list(directory.glob(f"**/.*.{ext}")))
254
-
255
- return files
256
-
257
-
258
243
  def get_branch(branch: str | None = None, directory: str | Path = ".") -> str:
259
244
  """If branch isn't provide, return the name of the local Git branch."""
260
245
  if branch:
@@ -351,14 +336,16 @@ def write_to_file(path: Path, value: Any) -> bool:
351
336
  return written is not None
352
337
 
353
338
 
354
- def read_file(file_name: Path) -> str:
355
- if not file_name.is_file():
356
- raise FileNotValidError(name=str(file_name), message=f"{file_name} is not a valid file")
339
+ def read_file(file_path: Path) -> str:
340
+ if not file_path.is_file():
341
+ raise FileNotValidError(name=str(file_path.name), message=f"{file_path.name}: not found at {file_path.parent}")
357
342
  try:
358
- with Path.open(file_name, encoding="utf-8") as fobj:
343
+ with Path.open(file_path, encoding="utf-8") as fobj:
359
344
  return fobj.read()
360
345
  except UnicodeDecodeError as exc:
361
- raise FileNotValidError(name=str(file_name), message=f"Unable to read {file_name} with utf-8 encoding") from exc
346
+ raise FileNotValidError(
347
+ name=str(file_path.name), message=f"Unable to read {file_path.name} with utf-8 encoding"
348
+ ) from exc
362
349
 
363
350
 
364
351
  def get_user_permissions(data: list[dict]) -> dict: