diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 345a4976..b5501ac6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,9 @@ jobs: markdown-lint: - if: needs.files-changed.outputs.documentation == 'true' + if: | + needs.files-changed.outputs.documentation == 'true' || + needs.files-changed.outputs.github_workflows == 'true' needs: ["files-changed"] runs-on: "ubuntu-latest" timeout-minutes: 5 @@ -94,7 +96,7 @@ jobs: - name: "Check out repository code" uses: "actions/checkout@v4" - name: "Linting: markdownlint" - uses: DavidAnson/markdownlint-cli2-action@v19 + uses: DavidAnson/markdownlint-cli2-action@v20 with: config: .markdownlint.yaml globs: | diff --git a/.markdownlint.yaml b/.markdownlint.yaml index 9b33d226..f0087fa9 100644 --- a/.markdownlint.yaml +++ b/.markdownlint.yaml @@ -11,3 +11,5 @@ MD034: false # no-bare-urls MD041: false # allow 1st line to not be a top-level heading (required for Towncrier) MD045: false # no alt text around images MD047: false # single trailing newline +MD059: # Link descriptions that are prohibited + prohibited_texts: [] diff --git a/changelog/+543efbd1.added.md b/changelog/+543efbd1.added.md new file mode 100644 index 00000000..f728f279 --- /dev/null +++ b/changelog/+543efbd1.added.md @@ -0,0 +1 @@ +Added NumberPool as a new attribute kind, for support in Infrahub 1.3 diff --git a/docs/docs/python-sdk/topics/object_file.mdx b/docs/docs/python-sdk/topics/object_file.mdx index fd747bfa..7488c02f 100644 --- a/docs/docs/python-sdk/topics/object_file.mdx +++ b/docs/docs/python-sdk/topics/object_file.mdx @@ -38,6 +38,8 @@ Multiple object files can be loaded at once by specifying the path to multiple f The `object load` command will create/update the objects using an `Upsert` operation. All objects previously loaded will NOT be deleted in the Infrahub instance. Also, if some objects present in different files are identical and dependent on each other, the `object load` command will NOT calculate the dependencies between the objects and as such it's the responsibility of the users to execute the command in the right order. +> Object files can also be loaded into Infrahub when using external Git repositories. To see how to do this, please refer to the [.infrahub.yml](https://docs.infrahub.app/topics/infrahub-yml#objects) documentation. + ### Validate the format of object files The object file can be validated using the `infrahubctl object validate` command. diff --git a/infrahub_sdk/client.py b/infrahub_sdk/client.py index bfea914c..dc1f539f 100644 --- a/infrahub_sdk/client.py +++ b/infrahub_sdk/client.py @@ -172,11 +172,18 @@ def start_tracking( params: dict[str, Any] | None = None, delete_unused_nodes: bool = False, group_type: str | None = None, + group_params: dict[str, Any] | None = None, + branch: str | None = None, ) -> Self: self.mode = InfrahubClientMode.TRACKING identifier = identifier or self.identifier or "python-sdk" self.set_context_properties( - identifier=identifier, params=params, delete_unused_nodes=delete_unused_nodes, group_type=group_type + identifier=identifier, + params=params, + delete_unused_nodes=delete_unused_nodes, + group_type=group_type, + group_params=group_params, + branch=branch, ) return self @@ -187,14 +194,22 @@ def set_context_properties( delete_unused_nodes: bool = True, reset: bool = True, group_type: str | None = None, + group_params: dict[str, Any] | None = None, + branch: str | None = None, ) -> None: if reset: if isinstance(self, InfrahubClient): self.group_context = InfrahubGroupContext(self) elif isinstance(self, InfrahubClientSync): self.group_context = InfrahubGroupContextSync(self) + self.group_context.set_properties( - identifier=identifier, params=params, delete_unused_nodes=delete_unused_nodes, group_type=group_type + identifier=identifier, + params=params, + delete_unused_nodes=delete_unused_nodes, + group_type=group_type, + group_params=group_params, + branch=branch, ) def _graphql_url( diff --git a/infrahub_sdk/node/__init__.py b/infrahub_sdk/node/__init__.py new file mode 100644 index 00000000..a2d71a87 --- /dev/null +++ b/infrahub_sdk/node/__init__.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from .constants import ( + ARTIFACT_DEFINITION_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE, + ARTIFACT_FETCH_FEATURE_NOT_SUPPORTED_MESSAGE, + ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE, + HFID_STR_SEPARATOR, + IP_TYPES, + PROPERTIES_FLAG, + PROPERTIES_OBJECT, + SAFE_VALUE, +) +from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync +from .parsers import parse_human_friendly_id +from .property import NodeProperty +from .related_node import RelatedNode, RelatedNodeBase, RelatedNodeSync +from .relationship import RelationshipManager, RelationshipManagerBase, RelationshipManagerSync + +__all__ = [ + "ARTIFACT_DEFINITION_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE", + "ARTIFACT_FETCH_FEATURE_NOT_SUPPORTED_MESSAGE", + "ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE", + "HFID_STR_SEPARATOR", + "IP_TYPES", + "PROPERTIES_FLAG", + "PROPERTIES_OBJECT", + "SAFE_VALUE", + "InfrahubNode", + "InfrahubNodeBase", + "InfrahubNodeSync", + "NodeProperty", + "RelatedNode", + "RelatedNodeBase", + "RelatedNodeSync", + "RelationshipManager", + "RelationshipManagerBase", + "RelationshipManagerSync", + "parse_human_friendly_id", +] diff --git a/infrahub_sdk/node/attribute.py b/infrahub_sdk/node/attribute.py new file mode 100644 index 00000000..5ddc5cbe --- /dev/null +++ b/infrahub_sdk/node/attribute.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import ipaddress +from typing import TYPE_CHECKING, Any, Callable, get_args + +from ..protocols_base import CoreNodeBase +from ..uuidt import UUIDT +from .constants import IP_TYPES, PROPERTIES_FLAG, PROPERTIES_OBJECT, SAFE_VALUE +from .property import NodeProperty + +if TYPE_CHECKING: + from ..schema import AttributeSchemaAPI + + +class Attribute: + """Represents an attribute of a Node, including its schema, value, and properties.""" + + def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict): + """ + Args: + name (str): The name of the attribute. + schema (AttributeSchema): The schema defining the attribute. + data (Union[Any, dict]): The data for the attribute, either in raw form or as a dictionary. + """ + self.name = name + self._schema = schema + + if not isinstance(data, dict) or "value" not in data.keys(): + data = {"value": data} + + self._properties_flag = PROPERTIES_FLAG + self._properties_object = PROPERTIES_OBJECT + self._properties = self._properties_flag + self._properties_object + + self._read_only = ["updated_at", "is_inherited"] + + self.id: str | None = data.get("id", None) + + self._value: Any | None = data.get("value", None) + self.value_has_been_mutated = False + self.is_default: bool | None = data.get("is_default", None) + self.is_from_profile: bool | None = data.get("is_from_profile", None) + + if self._value: + value_mapper: dict[str, Callable] = { + "IPHost": ipaddress.ip_interface, + "IPNetwork": ipaddress.ip_network, + } + mapper = value_mapper.get(schema.kind, lambda value: value) + self._value = mapper(data.get("value")) + + self.is_inherited: bool | None = data.get("is_inherited", None) + self.updated_at: str | None = data.get("updated_at", None) + + self.is_visible: bool | None = data.get("is_visible", None) + self.is_protected: bool | None = data.get("is_protected", None) + + self.source: NodeProperty | None = None + self.owner: NodeProperty | None = None + + for prop_name in self._properties_object: + if data.get(prop_name): + setattr(self, prop_name, NodeProperty(data=data.get(prop_name))) # type: ignore[arg-type] + + @property + def value(self) -> Any: + return self._value + + @value.setter + def value(self, value: Any) -> None: + self._value = value + self.value_has_been_mutated = True + + def _generate_input_data(self) -> dict | None: + data: dict[str, Any] = {} + variables: dict[str, Any] = {} + + if self.value is None: + return data + + if isinstance(self.value, str): + if SAFE_VALUE.match(self.value): + data["value"] = self.value + else: + var_name = f"value_{UUIDT.new().hex}" + variables[var_name] = self.value + data["value"] = f"${var_name}" + elif isinstance(self.value, get_args(IP_TYPES)): + data["value"] = self.value.with_prefixlen + elif isinstance(self.value, CoreNodeBase) and self.value.is_resource_pool(): + data["from_pool"] = {"id": self.value.id} + else: + data["value"] = self.value + + for prop_name in self._properties_flag: + if getattr(self, prop_name) is not None: + data[prop_name] = getattr(self, prop_name) + + for prop_name in self._properties_object: + if getattr(self, prop_name) is not None: + data[prop_name] = getattr(self, prop_name)._generate_input_data() + + return {"data": data, "variables": variables} + + def _generate_query_data(self, property: bool = False) -> dict | None: + data: dict[str, Any] = {"value": None} + + if property: + data.update({"is_default": None, "is_from_profile": None}) + + for prop_name in self._properties_flag: + data[prop_name] = None + for prop_name in self._properties_object: + data[prop_name] = {"id": None, "display_label": None, "__typename": None} + + return data + + def _generate_mutation_query(self) -> dict[str, Any]: + if isinstance(self.value, CoreNodeBase) and self.value.is_resource_pool(): + # If it points to a pool, ask for the value of the pool allocated resource + return {self.name: {"value": None}} + return {} diff --git a/infrahub_sdk/node/constants.py b/infrahub_sdk/node/constants.py new file mode 100644 index 00000000..7f5217d8 --- /dev/null +++ b/infrahub_sdk/node/constants.py @@ -0,0 +1,21 @@ +import ipaddress +import re +from typing import Union + +PROPERTIES_FLAG = ["is_visible", "is_protected"] +PROPERTIES_OBJECT = ["source", "owner"] +SAFE_VALUE = re.compile(r"(^[\. /:a-zA-Z0-9_-]+$)|(^$)") + +IP_TYPES = Union[ipaddress.IPv4Interface, ipaddress.IPv6Interface, ipaddress.IPv4Network, ipaddress.IPv6Network] + +ARTIFACT_FETCH_FEATURE_NOT_SUPPORTED_MESSAGE = ( + "calling artifact_fetch is only supported for nodes that are Artifact Definition target" +) +ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE = ( + "calling artifact_generate is only supported for nodes that are Artifact Definition targets" +) +ARTIFACT_DEFINITION_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE = ( + "calling generate is only supported for CoreArtifactDefinition nodes" +) + +HFID_STR_SEPARATOR = "__" diff --git a/infrahub_sdk/node.py b/infrahub_sdk/node/node.py similarity index 69% rename from infrahub_sdk/node.py rename to infrahub_sdk/node/node.py index 6d8160f7..5cb6a99a 100644 --- a/infrahub_sdk/node.py +++ b/infrahub_sdk/node/node.py @@ -1,709 +1,75 @@ from __future__ import annotations -import ipaddress -import re -from collections.abc import Iterable from copy import copy -from typing import TYPE_CHECKING, Any, Callable, Union, get_args +from typing import TYPE_CHECKING, Any -from .constants import InfrahubClientMode -from .exceptions import ( - Error, +from ..constants import InfrahubClientMode +from ..exceptions import ( FeatureNotSupportedError, NodeNotFoundError, - UninitializedError, ) -from .graphql import Mutation, Query -from .schema import GenericSchemaAPI, RelationshipCardinality, RelationshipKind -from .utils import compare_lists, generate_short_id, get_flat_value -from .uuidt import UUIDT +from ..graphql import Mutation, Query +from ..schema import GenericSchemaAPI, RelationshipCardinality, RelationshipKind +from ..utils import compare_lists, generate_short_id, get_flat_value +from .attribute import Attribute +from .constants import ( + ARTIFACT_DEFINITION_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE, + ARTIFACT_FETCH_FEATURE_NOT_SUPPORTED_MESSAGE, + ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE, + PROPERTIES_OBJECT, +) +from .related_node import RelatedNode, RelatedNodeBase, RelatedNodeSync +from .relationship import RelationshipManager, RelationshipManagerBase, RelationshipManagerSync if TYPE_CHECKING: from typing_extensions import Self - from .client import InfrahubClient, InfrahubClientSync - from .context import RequestContext - from .schema import AttributeSchemaAPI, MainSchemaTypesAPI, RelationshipSchemaAPI - from .types import Order - - -PROPERTIES_FLAG = ["is_visible", "is_protected"] -PROPERTIES_OBJECT = ["source", "owner"] -SAFE_VALUE = re.compile(r"(^[\. /:a-zA-Z0-9_-]+$)|(^$)") - -IP_TYPES = Union[ipaddress.IPv4Interface, ipaddress.IPv6Interface, ipaddress.IPv4Network, ipaddress.IPv6Network] - -ARTIFACT_FETCH_FEATURE_NOT_SUPPORTED_MESSAGE = ( - "calling artifact_fetch is only supported for nodes that are Artifact Definition target" -) -ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE = ( - "calling artifact_generate is only supported for nodes that are Artifact Definition targets" -) -ARTIFACT_DEFINITION_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE = ( - "calling generate is only supported for CoreArtifactDefinition nodes" -) - -HFID_STR_SEPARATOR = "__" - - -def parse_human_friendly_id(hfid: str | list[str]) -> tuple[str | None, list[str]]: - """Parse a human friendly ID into a kind and an identifier.""" - if isinstance(hfid, str): - hfid_parts = hfid.split(HFID_STR_SEPARATOR) - if len(hfid_parts) == 1: - return None, hfid_parts - return hfid_parts[0], hfid_parts[1:] - if isinstance(hfid, list): - return None, hfid - raise ValueError(f"Invalid human friendly ID: {hfid}") - - -class Attribute: - """Represents an attribute of a Node, including its schema, value, and properties.""" - - def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict): - """ - Args: - name (str): The name of the attribute. - schema (AttributeSchema): The schema defining the attribute. - data (Union[Any, dict]): The data for the attribute, either in raw form or as a dictionary. - """ - self.name = name - self._schema = schema - - if not isinstance(data, dict) or "value" not in data.keys(): - data = {"value": data} + from ..client import InfrahubClient, InfrahubClientSync + from ..context import RequestContext + from ..schema import MainSchemaTypesAPI + from ..types import Order - self._properties_flag = PROPERTIES_FLAG - self._properties_object = PROPERTIES_OBJECT - self._properties = self._properties_flag + self._properties_object - self._read_only = ["updated_at", "is_inherited"] - - self.id: str | None = data.get("id", None) - - self._value: Any | None = data.get("value", None) - self.value_has_been_mutated = False - self.is_default: bool | None = data.get("is_default", None) - self.is_from_profile: bool | None = data.get("is_from_profile", None) - - if self._value: - value_mapper: dict[str, Callable] = { - "IPHost": ipaddress.ip_interface, - "IPNetwork": ipaddress.ip_network, - } - mapper = value_mapper.get(schema.kind, lambda value: value) - self._value = mapper(data.get("value")) - - self.is_inherited: bool | None = data.get("is_inherited", None) - self.updated_at: str | None = data.get("updated_at", None) - - self.is_visible: bool | None = data.get("is_visible", None) - self.is_protected: bool | None = data.get("is_protected", None) - - self.source: NodeProperty | None = None - self.owner: NodeProperty | None = None - - for prop_name in self._properties_object: - if data.get(prop_name): - setattr(self, prop_name, NodeProperty(data=data.get(prop_name))) # type: ignore[arg-type] +def generate_relationship_property(node: InfrahubNode | InfrahubNodeSync, name: str) -> property: + """Generates a property that stores values under a private non-public name. - @property - def value(self) -> Any: - return self._value + Args: + node (Union[InfrahubNode, InfrahubNodeSync]): The node instance. + name (str): The name of the relationship property. - @value.setter - def value(self, value: Any) -> None: - self._value = value - self.value_has_been_mutated = True + Returns: + A property object for managing the relationship. - def _generate_input_data(self) -> dict | None: - data: dict[str, Any] = {} - variables: dict[str, Any] = {} + """ + internal_name = "_" + name.lower() + external_name = name - if self.value is None: - return data + def prop_getter(self: InfrahubNodeBase) -> Any: + return getattr(self, internal_name) - if isinstance(self.value, str): - if SAFE_VALUE.match(self.value): - data["value"] = self.value - else: - var_name = f"value_{UUIDT.new().hex}" - variables[var_name] = self.value - data["value"] = f"${var_name}" - elif isinstance(self.value, get_args(IP_TYPES)): - data["value"] = self.value.with_prefixlen - elif isinstance(self.value, InfrahubNodeBase) and self.value.is_resource_pool(): - data["from_pool"] = {"id": self.value.id} + def prop_setter(self: InfrahubNodeBase, value: Any) -> None: + if isinstance(value, RelatedNodeBase) or value is None: + setattr(self, internal_name, value) else: - data["value"] = self.value - - for prop_name in self._properties_flag: - if getattr(self, prop_name) is not None: - data[prop_name] = getattr(self, prop_name) - - for prop_name in self._properties_object: - if getattr(self, prop_name) is not None: - data[prop_name] = getattr(self, prop_name)._generate_input_data() - - return {"data": data, "variables": variables} - - def _generate_query_data(self, property: bool = False) -> dict | None: - data: dict[str, Any] = {"value": None} - - if property: - data.update({"is_default": None, "is_from_profile": None}) - - for prop_name in self._properties_flag: - data[prop_name] = None - for prop_name in self._properties_object: - data[prop_name] = {"id": None, "display_label": None, "__typename": None} - - return data - - def _generate_mutation_query(self) -> dict[str, Any]: - if isinstance(self.value, InfrahubNodeBase) and self.value.is_resource_pool(): - # If it points to a pool, ask for the value of the pool allocated resource - return {self.name: {"value": None}} - return {} - - -class RelatedNodeBase: - """Base class for representing a related node in a relationship.""" - - def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, name: str | None = None): - """ - Args: - branch (str): The branch where the related node resides. - schema (RelationshipSchema): The schema of the relationship. - data (Union[Any, dict]): Data representing the related node. - name (Optional[str]): The name of the related node. - """ - self.schema = schema - self.name = name - - self._branch = branch - - self._properties_flag = PROPERTIES_FLAG - self._properties_object = PROPERTIES_OBJECT - self._properties = self._properties_flag + self._properties_object - - self._peer = None - self._id: str | None = None - self._hfid: list[str] | None = None - self._display_label: str | None = None - self._typename: str | None = None - - if isinstance(data, (InfrahubNode, InfrahubNodeSync)): - self._peer = data - for prop in self._properties: - setattr(self, prop, None) - elif isinstance(data, list): - data = {"hfid": data} - elif not isinstance(data, dict): - data = {"id": data} - - if isinstance(data, dict): - # To support both with and without pagination, we split data into node_data and properties_data - # We should probably clean that once we'll remove the code without pagination. - node_data = data.get("node", data) - properties_data = data.get("properties", data) - - if node_data: - self._id = node_data.get("id", None) - self._hfid = node_data.get("hfid", None) - self._kind = node_data.get("kind", None) - self._display_label = node_data.get("display_label", None) - self._typename = node_data.get("__typename", None) - - self.updated_at: str | None = data.get("updated_at", data.get("_relation__updated_at", None)) - - # FIXME, we won't need that once we are only supporting paginated results - if self._typename and self._typename.startswith("Related"): - self._typename = self._typename[7:] - - for prop in self._properties: - prop_data = properties_data.get(prop, properties_data.get(f"_relation__{prop}", None)) - if prop_data and isinstance(prop_data, dict) and "id" in prop_data: - setattr(self, prop, prop_data["id"]) - elif prop_data and isinstance(prop_data, (str, bool)): - setattr(self, prop, prop_data) - else: - setattr(self, prop, None) - - @property - def id(self) -> str | None: - if self._peer: - return self._peer.id - return self._id - - @property - def hfid(self) -> list[Any] | None: - if self._peer: - return self._peer.hfid - return self._hfid - - @property - def hfid_str(self) -> str | None: - if self._peer and self.hfid: - return self._peer.get_human_friendly_id_as_string(include_kind=True) - return None - - @property - def is_resource_pool(self) -> bool: - if self._peer: - return self._peer.is_resource_pool() - return False - - @property - def initialized(self) -> bool: - return bool(self.id) or bool(self.hfid) - - @property - def display_label(self) -> str | None: - if self._peer: - return self._peer.display_label - return self._display_label - - @property - def typename(self) -> str | None: - if self._peer: - return self._peer.typename - return self._typename - - def _generate_input_data(self, allocate_from_pool: bool = False) -> dict[str, Any]: - data: dict[str, Any] = {} - - if self.is_resource_pool and allocate_from_pool: - return {"from_pool": {"id": self.id}} - - if self.id is not None: - data["id"] = self.id - elif self.hfid is not None: - data["hfid"] = self.hfid - if self._kind is not None: - data["kind"] = self._kind - - for prop_name in self._properties: - if getattr(self, prop_name) is not None: - data[f"_relation__{prop_name}"] = getattr(self, prop_name) - - return data - - def _generate_mutation_query(self) -> dict[str, Any]: - if self.name and self.is_resource_pool: - # If a related node points to a pool, ask for the ID of the pool allocated resource - return {self.name: {"node": {"id": None, "display_label": None, "__typename": None}}} - return {} - - @classmethod - def _generate_query_data(cls, peer_data: dict[str, Any] | None = None, property: bool = False) -> dict: - """Generates the basic structure of a GraphQL query for a single relationship. - - Args: - peer_data (dict[str, Union[Any, Dict]], optional): Additional data to be included in the query for the node. - This is used to add extra fields when prefetching related node data. - - Returns: - Dict: A dictionary representing the basic structure of a GraphQL query, including the node's ID, display label, - and typename. The method also includes additional properties and any peer_data provided. - """ - data: dict[str, Any] = {"node": {"id": None, "hfid": None, "display_label": None, "__typename": None}} - properties: dict[str, Any] = {} - - if property: - for prop_name in PROPERTIES_FLAG: - properties[prop_name] = None - for prop_name in PROPERTIES_OBJECT: - properties[prop_name] = {"id": None, "display_label": None, "__typename": None} - - if properties: - data["properties"] = properties - if peer_data: - data["node"].update(peer_data) - - return data - - -class RelatedNode(RelatedNodeBase): - """Represents a RelatedNodeBase in an asynchronous context.""" - - def __init__( - self, - client: InfrahubClient, - branch: str, - schema: RelationshipSchemaAPI, - data: Any | dict, - name: str | None = None, - ): - """ - Args: - client (InfrahubClient): The client used to interact with the backend asynchronously. - branch (str): The branch where the related node resides. - schema (RelationshipSchema): The schema of the relationship. - data (Union[Any, dict]): Data representing the related node. - name (Optional[str]): The name of the related node. - """ - self._client = client - super().__init__(branch=branch, schema=schema, data=data, name=name) - - async def fetch(self, timeout: int | None = None) -> None: - if not self.id or not self.typename: - raise Error("Unable to fetch the peer, id and/or typename are not defined") - - self._peer = await self._client.get( - kind=self.typename, id=self.id, populate_store=True, branch=self._branch, timeout=timeout - ) - - @property - def peer(self) -> InfrahubNode: - return self.get() - - def get(self) -> InfrahubNode: - if self._peer: - return self._peer # type: ignore[return-value] - - if self.id and self.typename: - return self._client.store.get(key=self.id, kind=self.typename, branch=self._branch) # type: ignore[return-value] - - if self.hfid_str: - return self._client.store.get(key=self.hfid_str, branch=self._branch) # type: ignore[return-value] - - raise ValueError("Node must have at least one identifier (ID or HFID) to query it.") - - -class RelatedNodeSync(RelatedNodeBase): - """Represents a related node in a synchronous context.""" - - def __init__( - self, - client: InfrahubClientSync, - branch: str, - schema: RelationshipSchemaAPI, - data: Any | dict, - name: str | None = None, - ): - """ - Args: - client (InfrahubClientSync): The client used to interact with the backend synchronously. - branch (str): The branch where the related node resides. - schema (RelationshipSchema): The schema of the relationship. - data (Union[Any, dict]): Data representing the related node. - name (Optional[str]): The name of the related node. - """ - self._client = client - super().__init__(branch=branch, schema=schema, data=data, name=name) - - def fetch(self, timeout: int | None = None) -> None: - if not self.id or not self.typename: - raise Error("Unable to fetch the peer, id and/or typename are not defined") - - self._peer = self._client.get( - kind=self.typename, id=self.id, populate_store=True, branch=self._branch, timeout=timeout - ) - - @property - def peer(self) -> InfrahubNodeSync: - return self.get() - - def get(self) -> InfrahubNodeSync: - if self._peer: - return self._peer # type: ignore[return-value] - - if self.id and self.typename: - return self._client.store.get(key=self.id, kind=self.typename, branch=self._branch) # type: ignore[return-value] - - if self.hfid_str: - return self._client.store.get(key=self.hfid_str, branch=self._branch) # type: ignore[return-value] - - raise ValueError("Node must have at least one identifier (ID or HFID) to query it.") - - -class RelationshipManagerBase: - """Base class for RelationshipManager and RelationshipManagerSync""" - - def __init__(self, name: str, branch: str, schema: RelationshipSchemaAPI): - """ - Args: - name (str): The name of the relationship. - branch (str): The branch where the relationship resides. - schema (RelationshipSchema): The schema of the relationship. - """ - self.initialized: bool = False - self._has_update: bool = False - self.name = name - self.schema = schema - self.branch = branch - - self._properties_flag = PROPERTIES_FLAG - self._properties_object = PROPERTIES_OBJECT - self._properties = self._properties_flag + self._properties_object - - self.peers: list[RelatedNode | RelatedNodeSync] = [] - - @property - def peer_ids(self) -> list[str]: - return [peer.id for peer in self.peers if peer.id] - - @property - def peer_hfids(self) -> list[list[Any]]: - return [peer.hfid for peer in self.peers if peer.hfid] - - @property - def peer_hfids_str(self) -> list[str]: - return [peer.hfid_str for peer in self.peers if peer.hfid_str] - - @property - def has_update(self) -> bool: - return self._has_update - - def _generate_input_data(self, allocate_from_pool: bool = False) -> list[dict]: - return [peer._generate_input_data(allocate_from_pool=allocate_from_pool) for peer in self.peers] - - def _generate_mutation_query(self) -> dict[str, Any]: - # Does nothing for now - return {} - - @classmethod - def _generate_query_data(cls, peer_data: dict[str, Any] | None = None, property: bool = False) -> dict: - """Generates the basic structure of a GraphQL query for relationships with multiple nodes. - - Args: - peer_data (dict[str, Union[Any, Dict]], optional): Additional data to be included in the query for each node. - This is used to add extra fields when prefetching related node data in many-to-many relationships. - - Returns: - Dict: A dictionary representing the basic structure of a GraphQL query for multiple related nodes. - It includes count, edges, and node information (ID, display label, and typename), along with additional properties - and any peer_data provided. - """ - data: dict[str, Any] = { - "count": None, - "edges": {"node": {"id": None, "hfid": None, "display_label": None, "__typename": None}}, - } - - properties: dict[str, Any] = {} - if property: - for prop_name in PROPERTIES_FLAG: - properties[prop_name] = None - for prop_name in PROPERTIES_OBJECT: - properties[prop_name] = {"id": None, "display_label": None, "__typename": None} - - if properties: - data["edges"]["properties"] = properties - if peer_data: - data["edges"]["node"].update(peer_data) - - return data - - -class RelationshipManager(RelationshipManagerBase): - """Manages relationships of a node in an asynchronous context.""" - - def __init__( - self, - name: str, - client: InfrahubClient, - node: InfrahubNode, - branch: str, - schema: RelationshipSchemaAPI, - data: Any | dict, - ): - """ - Args: - name (str): The name of the relationship. - client (InfrahubClient): The client used to interact with the backend. - node (InfrahubNode): The node to which the relationship belongs. - branch (str): The branch where the relationship resides. - schema (RelationshipSchema): The schema of the relationship. - data (Union[Any, dict]): Initial data for the relationships. - """ - self.client = client - self.node = node - - super().__init__(name=name, schema=schema, branch=branch) - - self.initialized = data is not None - self._has_update = False - - if data is None: - return - - if isinstance(data, list): - for item in data: - self.peers.append( - RelatedNode(name=name, client=self.client, branch=self.branch, schema=schema, data=item) + schema = [rel for rel in self._schema.relationships if rel.name == external_name][0] + if isinstance(node, InfrahubNode): + setattr( + self, + internal_name, + RelatedNode( + name=external_name, branch=node._branch, client=node._client, schema=schema, data=value + ), ) - elif isinstance(data, dict) and "edges" in data: - for item in data["edges"]: - self.peers.append( - RelatedNode(name=name, client=self.client, branch=self.branch, schema=schema, data=item) + else: + setattr( + self, + internal_name, + RelatedNodeSync( + name=external_name, branch=node._branch, client=node._client, schema=schema, data=value + ), ) - else: - raise ValueError(f"Unexpected format for {name} found a {type(data)}, {data}") - - def __getitem__(self, item: int) -> RelatedNode: - return self.peers[item] # type: ignore[return-value] - - async def fetch(self) -> None: - if not self.initialized: - exclude = self.node._schema.relationship_names + self.node._schema.attribute_names - exclude.remove(self.schema.name) - node = await self.client.get( - kind=self.node._schema.kind, - id=self.node.id, - branch=self.branch, - include=[self.schema.name], - exclude=exclude, - ) - rm = getattr(node, self.schema.name) - self.peers = rm.peers - self.initialized = True - - for peer in self.peers: - await peer.fetch() # type: ignore[misc] - - def add(self, data: str | RelatedNode | dict) -> None: - """Add a new peer to this relationship.""" - if not self.initialized: - raise UninitializedError("Must call fetch() on RelationshipManager before editing members") - new_node = RelatedNode(schema=self.schema, client=self.client, branch=self.branch, data=data) - - if (new_node.id and new_node.id not in self.peer_ids) or ( - new_node.hfid and new_node.hfid not in self.peer_hfids - ): - self.peers.append(new_node) - self._has_update = True - - def extend(self, data: Iterable[str | RelatedNode | dict]) -> None: - """Add new peers to this relationship.""" - for d in data: - self.add(d) - - def remove(self, data: str | RelatedNode | dict) -> None: - if not self.initialized: - raise UninitializedError("Must call fetch() on RelationshipManager before editing members") - node_to_remove = RelatedNode(schema=self.schema, client=self.client, branch=self.branch, data=data) - - if node_to_remove.id and node_to_remove.id in self.peer_ids: - idx = self.peer_ids.index(node_to_remove.id) - if self.peers[idx].id != node_to_remove.id: - raise IndexError(f"Unexpected situation, the node with the index {idx} should be {node_to_remove.id}") - - self.peers.pop(idx) - self._has_update = True - elif node_to_remove.hfid and node_to_remove.hfid in self.peer_hfids: - idx = self.peer_hfids.index(node_to_remove.hfid) - if self.peers[idx].hfid != node_to_remove.hfid: - raise IndexError(f"Unexpected situation, the node with the index {idx} should be {node_to_remove.hfid}") - - self.peers.pop(idx) - self._has_update = True - - -class RelationshipManagerSync(RelationshipManagerBase): - """Manages relationships of a node in a synchronous context.""" - - def __init__( - self, - name: str, - client: InfrahubClientSync, - node: InfrahubNodeSync, - branch: str, - schema: RelationshipSchemaAPI, - data: Any | dict, - ): - """ - Args: - name (str): The name of the relationship. - client (InfrahubClientSync): The client used to interact with the backend synchronously. - node (InfrahubNodeSync): The node to which the relationship belongs. - branch (str): The branch where the relationship resides. - schema (RelationshipSchema): The schema of the relationship. - data (Union[Any, dict]): Initial data for the relationships. - """ - self.client = client - self.node = node - - super().__init__(name=name, schema=schema, branch=branch) - - self.initialized = data is not None - self._has_update = False - - if data is None: - return - - if isinstance(data, list): - for item in data: - self.peers.append( - RelatedNodeSync(name=name, client=self.client, branch=self.branch, schema=schema, data=item) - ) - elif isinstance(data, dict) and "edges" in data: - for item in data["edges"]: - self.peers.append( - RelatedNodeSync(name=name, client=self.client, branch=self.branch, schema=schema, data=item) - ) - else: - raise ValueError(f"Unexpected format for {name} found a {type(data)}, {data}") - - def __getitem__(self, item: int) -> RelatedNodeSync: - return self.peers[item] # type: ignore[return-value] - - def fetch(self) -> None: - if not self.initialized: - exclude = self.node._schema.relationship_names + self.node._schema.attribute_names - exclude.remove(self.schema.name) - node = self.client.get( - kind=self.node._schema.kind, - id=self.node.id, - branch=self.branch, - include=[self.schema.name], - exclude=exclude, - ) - rm = getattr(node, self.schema.name) - self.peers = rm.peers - self.initialized = True - - for peer in self.peers: - peer.fetch() - - def add(self, data: str | RelatedNodeSync | dict) -> None: - """Add a new peer to this relationship.""" - if not self.initialized: - raise UninitializedError("Must call fetch() on RelationshipManager before editing members") - new_node = RelatedNodeSync(schema=self.schema, client=self.client, branch=self.branch, data=data) - - if (new_node.id and new_node.id not in self.peer_ids) or ( - new_node.hfid and new_node.hfid not in self.peer_hfids - ): - self.peers.append(new_node) - self._has_update = True - - def extend(self, data: Iterable[str | RelatedNodeSync | dict]) -> None: - """Add new peers to this relationship.""" - for d in data: - self.add(d) - - def remove(self, data: str | RelatedNodeSync | dict) -> None: - if not self.initialized: - raise UninitializedError("Must call fetch() on RelationshipManager before editing members") - node_to_remove = RelatedNodeSync(schema=self.schema, client=self.client, branch=self.branch, data=data) - - if node_to_remove.id and node_to_remove.id in self.peer_ids: - idx = self.peer_ids.index(node_to_remove.id) - if self.peers[idx].id != node_to_remove.id: - raise IndexError(f"Unexpected situation, the node with the index {idx} should be {node_to_remove.id}") - self.peers.pop(idx) - self._has_update = True - - elif node_to_remove.hfid and node_to_remove.hfid in self.peer_hfids: - idx = self.peer_hfids.index(node_to_remove.hfid) - if self.peers[idx].hfid != node_to_remove.hfid: - raise IndexError(f"Unexpected situation, the node with the index {idx} should be {node_to_remove.hfid}") - - self.peers.pop(idx) - self._has_update = True + return property(prop_getter, prop_setter) class InfrahubNodeBase: @@ -2179,68 +1545,3 @@ def get_pool_resources_utilization(self) -> list[dict[str, Any]]: if response[graphql_query_name].get("count", 0): return [edge["node"] for edge in response[graphql_query_name]["edges"]] return [] - - -class NodeProperty: - """Represents a property of a node, typically used for metadata like display labels.""" - - def __init__(self, data: dict | str): - """ - Args: - data (Union[dict, str]): Data representing the node property. - """ - self.id = None - self.display_label = None - self.typename = None - - if isinstance(data, str): - self.id = data - elif isinstance(data, dict): - self.id = data.get("id", None) - self.display_label = data.get("display_label", None) - self.typename = data.get("__typename", None) - - def _generate_input_data(self) -> str | None: - return self.id - - -def generate_relationship_property(node: InfrahubNode | InfrahubNodeSync, name: str) -> property: - """Generates a property that stores values under a private non-public name. - - Args: - node (Union[InfrahubNode, InfrahubNodeSync]): The node instance. - name (str): The name of the relationship property. - - Returns: - A property object for managing the relationship. - - """ - internal_name = "_" + name.lower() - external_name = name - - def prop_getter(self: InfrahubNodeBase) -> Any: - return getattr(self, internal_name) - - def prop_setter(self: InfrahubNodeBase, value: Any) -> None: - if isinstance(value, RelatedNodeBase) or value is None: - setattr(self, internal_name, value) - else: - schema = [rel for rel in self._schema.relationships if rel.name == external_name][0] - if isinstance(node, InfrahubNode): - setattr( - self, - internal_name, - RelatedNode( - name=external_name, branch=node._branch, client=node._client, schema=schema, data=value - ), - ) - else: - setattr( - self, - internal_name, - RelatedNodeSync( - name=external_name, branch=node._branch, client=node._client, schema=schema, data=value - ), - ) - - return property(prop_getter, prop_setter) diff --git a/infrahub_sdk/node/parsers.py b/infrahub_sdk/node/parsers.py new file mode 100644 index 00000000..0ae830f1 --- /dev/null +++ b/infrahub_sdk/node/parsers.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from .constants import HFID_STR_SEPARATOR + + +def parse_human_friendly_id(hfid: str | list[str]) -> tuple[str | None, list[str]]: + """Parse a human friendly ID into a kind and an identifier.""" + if isinstance(hfid, str): + hfid_parts = hfid.split(HFID_STR_SEPARATOR) + if len(hfid_parts) == 1: + return None, hfid_parts + return hfid_parts[0], hfid_parts[1:] + if isinstance(hfid, list): + return None, hfid + raise ValueError(f"Invalid human friendly ID: {hfid}") diff --git a/infrahub_sdk/node/property.py b/infrahub_sdk/node/property.py new file mode 100644 index 00000000..8de0ab43 --- /dev/null +++ b/infrahub_sdk/node/property.py @@ -0,0 +1,24 @@ +from __future__ import annotations + + +class NodeProperty: + """Represents a property of a node, typically used for metadata like display labels.""" + + def __init__(self, data: dict | str): + """ + Args: + data (Union[dict, str]): Data representing the node property. + """ + self.id = None + self.display_label = None + self.typename = None + + if isinstance(data, str): + self.id = data + elif isinstance(data, dict): + self.id = data.get("id", None) + self.display_label = data.get("display_label", None) + self.typename = data.get("__typename", None) + + def _generate_input_data(self) -> str | None: + return self.id diff --git a/infrahub_sdk/node/related_node.py b/infrahub_sdk/node/related_node.py new file mode 100644 index 00000000..60d46ca9 --- /dev/null +++ b/infrahub_sdk/node/related_node.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..exceptions import ( + Error, +) +from ..protocols_base import CoreNodeBase +from .constants import PROPERTIES_FLAG, PROPERTIES_OBJECT + +if TYPE_CHECKING: + from ..client import InfrahubClient, InfrahubClientSync + from ..schema import RelationshipSchemaAPI + from .node import InfrahubNode, InfrahubNodeSync + + +class RelatedNodeBase: + """Base class for representing a related node in a relationship.""" + + def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, name: str | None = None): + """ + Args: + branch (str): The branch where the related node resides. + schema (RelationshipSchema): The schema of the relationship. + data (Union[Any, dict]): Data representing the related node. + name (Optional[str]): The name of the related node. + """ + self.schema = schema + self.name = name + + self._branch = branch + + self._properties_flag = PROPERTIES_FLAG + self._properties_object = PROPERTIES_OBJECT + self._properties = self._properties_flag + self._properties_object + + self._peer = None + self._id: str | None = None + self._hfid: list[str] | None = None + self._display_label: str | None = None + self._typename: str | None = None + + if isinstance(data, (CoreNodeBase)): + self._peer = data + for prop in self._properties: + setattr(self, prop, None) + + elif isinstance(data, list): + data = {"hfid": data} + elif not isinstance(data, dict): + data = {"id": data} + + if isinstance(data, dict): + # To support both with and without pagination, we split data into node_data and properties_data + # We should probably clean that once we'll remove the code without pagination. + node_data = data.get("node", data) + properties_data = data.get("properties", data) + + if node_data: + self._id = node_data.get("id", None) + self._hfid = node_data.get("hfid", None) + self._kind = node_data.get("kind", None) + self._display_label = node_data.get("display_label", None) + self._typename = node_data.get("__typename", None) + + self.updated_at: str | None = data.get("updated_at", data.get("_relation__updated_at", None)) + + # FIXME, we won't need that once we are only supporting paginated results + if self._typename and self._typename.startswith("Related"): + self._typename = self._typename[7:] + + for prop in self._properties: + prop_data = properties_data.get(prop, properties_data.get(f"_relation__{prop}", None)) + if prop_data and isinstance(prop_data, dict) and "id" in prop_data: + setattr(self, prop, prop_data["id"]) + elif prop_data and isinstance(prop_data, (str, bool)): + setattr(self, prop, prop_data) + else: + setattr(self, prop, None) + + @property + def id(self) -> str | None: + if self._peer: + return self._peer.id + return self._id + + @property + def hfid(self) -> list[Any] | None: + if self._peer: + return self._peer.hfid + return self._hfid + + @property + def hfid_str(self) -> str | None: + if self._peer and self.hfid: + return self._peer.get_human_friendly_id_as_string(include_kind=True) + return None + + @property + def is_resource_pool(self) -> bool: + if self._peer: + return self._peer.is_resource_pool() + return False + + @property + def initialized(self) -> bool: + return bool(self.id) or bool(self.hfid) + + @property + def display_label(self) -> str | None: + if self._peer: + return self._peer.display_label + return self._display_label + + @property + def typename(self) -> str | None: + if self._peer: + return self._peer.typename + return self._typename + + def _generate_input_data(self, allocate_from_pool: bool = False) -> dict[str, Any]: + data: dict[str, Any] = {} + + if self.is_resource_pool and allocate_from_pool: + return {"from_pool": {"id": self.id}} + + if self.id is not None: + data["id"] = self.id + elif self.hfid is not None: + data["hfid"] = self.hfid + if self._kind is not None: + data["kind"] = self._kind + + for prop_name in self._properties: + if getattr(self, prop_name) is not None: + data[f"_relation__{prop_name}"] = getattr(self, prop_name) + + return data + + def _generate_mutation_query(self) -> dict[str, Any]: + if self.name and self.is_resource_pool: + # If a related node points to a pool, ask for the ID of the pool allocated resource + return {self.name: {"node": {"id": None, "display_label": None, "__typename": None}}} + return {} + + @classmethod + def _generate_query_data(cls, peer_data: dict[str, Any] | None = None, property: bool = False) -> dict: + """Generates the basic structure of a GraphQL query for a single relationship. + + Args: + peer_data (dict[str, Union[Any, Dict]], optional): Additional data to be included in the query for the node. + This is used to add extra fields when prefetching related node data. + + Returns: + Dict: A dictionary representing the basic structure of a GraphQL query, including the node's ID, display label, + and typename. The method also includes additional properties and any peer_data provided. + """ + data: dict[str, Any] = {"node": {"id": None, "hfid": None, "display_label": None, "__typename": None}} + properties: dict[str, Any] = {} + + if property: + for prop_name in PROPERTIES_FLAG: + properties[prop_name] = None + for prop_name in PROPERTIES_OBJECT: + properties[prop_name] = {"id": None, "display_label": None, "__typename": None} + + if properties: + data["properties"] = properties + if peer_data: + data["node"].update(peer_data) + + return data + + +class RelatedNode(RelatedNodeBase): + """Represents a RelatedNodeBase in an asynchronous context.""" + + def __init__( + self, + client: InfrahubClient, + branch: str, + schema: RelationshipSchemaAPI, + data: Any | dict, + name: str | None = None, + ): + """ + Args: + client (InfrahubClient): The client used to interact with the backend asynchronously. + branch (str): The branch where the related node resides. + schema (RelationshipSchema): The schema of the relationship. + data (Union[Any, dict]): Data representing the related node. + name (Optional[str]): The name of the related node. + """ + self._client = client + super().__init__(branch=branch, schema=schema, data=data, name=name) + + async def fetch(self, timeout: int | None = None) -> None: + if not self.id or not self.typename: + raise Error("Unable to fetch the peer, id and/or typename are not defined") + + self._peer = await self._client.get( + kind=self.typename, id=self.id, populate_store=True, branch=self._branch, timeout=timeout + ) + + @property + def peer(self) -> InfrahubNode: + return self.get() + + def get(self) -> InfrahubNode: + if self._peer: + return self._peer # type: ignore[return-value] + + if self.id and self.typename: + return self._client.store.get(key=self.id, kind=self.typename, branch=self._branch) # type: ignore[return-value] + + if self.hfid_str: + return self._client.store.get(key=self.hfid_str, branch=self._branch) # type: ignore[return-value] + + raise ValueError("Node must have at least one identifier (ID or HFID) to query it.") + + +class RelatedNodeSync(RelatedNodeBase): + """Represents a related node in a synchronous context.""" + + def __init__( + self, + client: InfrahubClientSync, + branch: str, + schema: RelationshipSchemaAPI, + data: Any | dict, + name: str | None = None, + ): + """ + Args: + client (InfrahubClientSync): The client used to interact with the backend synchronously. + branch (str): The branch where the related node resides. + schema (RelationshipSchema): The schema of the relationship. + data (Union[Any, dict]): Data representing the related node. + name (Optional[str]): The name of the related node. + """ + self._client = client + super().__init__(branch=branch, schema=schema, data=data, name=name) + + def fetch(self, timeout: int | None = None) -> None: + if not self.id or not self.typename: + raise Error("Unable to fetch the peer, id and/or typename are not defined") + + self._peer = self._client.get( + kind=self.typename, id=self.id, populate_store=True, branch=self._branch, timeout=timeout + ) + + @property + def peer(self) -> InfrahubNodeSync: + return self.get() + + def get(self) -> InfrahubNodeSync: + if self._peer: + return self._peer # type: ignore[return-value] + + if self.id and self.typename: + return self._client.store.get(key=self.id, kind=self.typename, branch=self._branch) # type: ignore[return-value] + + if self.hfid_str: + return self._client.store.get(key=self.hfid_str, branch=self._branch) # type: ignore[return-value] + + raise ValueError("Node must have at least one identifier (ID or HFID) to query it.") diff --git a/infrahub_sdk/node/relationship.py b/infrahub_sdk/node/relationship.py new file mode 100644 index 00000000..c527dc50 --- /dev/null +++ b/infrahub_sdk/node/relationship.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any + +from ..exceptions import ( + UninitializedError, +) +from .constants import PROPERTIES_FLAG, PROPERTIES_OBJECT +from .related_node import RelatedNode, RelatedNodeSync + +if TYPE_CHECKING: + from ..client import InfrahubClient, InfrahubClientSync + from ..schema import RelationshipSchemaAPI + from .node import InfrahubNode, InfrahubNodeSync + + +class RelationshipManagerBase: + """Base class for RelationshipManager and RelationshipManagerSync""" + + def __init__(self, name: str, branch: str, schema: RelationshipSchemaAPI): + """ + Args: + name (str): The name of the relationship. + branch (str): The branch where the relationship resides. + schema (RelationshipSchema): The schema of the relationship. + """ + self.initialized: bool = False + self._has_update: bool = False + self.name = name + self.schema = schema + self.branch = branch + + self._properties_flag = PROPERTIES_FLAG + self._properties_object = PROPERTIES_OBJECT + self._properties = self._properties_flag + self._properties_object + + self.peers: list[RelatedNode | RelatedNodeSync] = [] + + @property + def peer_ids(self) -> list[str]: + return [peer.id for peer in self.peers if peer.id] + + @property + def peer_hfids(self) -> list[list[Any]]: + return [peer.hfid for peer in self.peers if peer.hfid] + + @property + def peer_hfids_str(self) -> list[str]: + return [peer.hfid_str for peer in self.peers if peer.hfid_str] + + @property + def has_update(self) -> bool: + return self._has_update + + def _generate_input_data(self, allocate_from_pool: bool = False) -> list[dict]: + return [peer._generate_input_data(allocate_from_pool=allocate_from_pool) for peer in self.peers] + + def _generate_mutation_query(self) -> dict[str, Any]: + # Does nothing for now + return {} + + @classmethod + def _generate_query_data(cls, peer_data: dict[str, Any] | None = None, property: bool = False) -> dict: + """Generates the basic structure of a GraphQL query for relationships with multiple nodes. + + Args: + peer_data (dict[str, Union[Any, Dict]], optional): Additional data to be included in the query for each node. + This is used to add extra fields when prefetching related node data in many-to-many relationships. + + Returns: + Dict: A dictionary representing the basic structure of a GraphQL query for multiple related nodes. + It includes count, edges, and node information (ID, display label, and typename), along with additional properties + and any peer_data provided. + """ + data: dict[str, Any] = { + "count": None, + "edges": {"node": {"id": None, "hfid": None, "display_label": None, "__typename": None}}, + } + + properties: dict[str, Any] = {} + if property: + for prop_name in PROPERTIES_FLAG: + properties[prop_name] = None + for prop_name in PROPERTIES_OBJECT: + properties[prop_name] = {"id": None, "display_label": None, "__typename": None} + + if properties: + data["edges"]["properties"] = properties + if peer_data: + data["edges"]["node"].update(peer_data) + + return data + + +class RelationshipManager(RelationshipManagerBase): + """Manages relationships of a node in an asynchronous context.""" + + def __init__( + self, + name: str, + client: InfrahubClient, + node: InfrahubNode, + branch: str, + schema: RelationshipSchemaAPI, + data: Any | dict, + ): + """ + Args: + name (str): The name of the relationship. + client (InfrahubClient): The client used to interact with the backend. + node (InfrahubNode): The node to which the relationship belongs. + branch (str): The branch where the relationship resides. + schema (RelationshipSchema): The schema of the relationship. + data (Union[Any, dict]): Initial data for the relationships. + """ + self.client = client + self.node = node + + super().__init__(name=name, schema=schema, branch=branch) + + self.initialized = data is not None + self._has_update = False + + if data is None: + return + + if isinstance(data, list): + for item in data: + self.peers.append( + RelatedNode(name=name, client=self.client, branch=self.branch, schema=schema, data=item) + ) + elif isinstance(data, dict) and "edges" in data: + for item in data["edges"]: + self.peers.append( + RelatedNode(name=name, client=self.client, branch=self.branch, schema=schema, data=item) + ) + else: + raise ValueError(f"Unexpected format for {name} found a {type(data)}, {data}") + + def __getitem__(self, item: int) -> RelatedNode: + return self.peers[item] # type: ignore[return-value] + + async def fetch(self) -> None: + if not self.initialized: + exclude = self.node._schema.relationship_names + self.node._schema.attribute_names + exclude.remove(self.schema.name) + node = await self.client.get( + kind=self.node._schema.kind, + id=self.node.id, + branch=self.branch, + include=[self.schema.name], + exclude=exclude, + ) + rm = getattr(node, self.schema.name) + self.peers = rm.peers + self.initialized = True + + for peer in self.peers: + await peer.fetch() # type: ignore[misc] + + def add(self, data: str | RelatedNode | dict) -> None: + """Add a new peer to this relationship.""" + if not self.initialized: + raise UninitializedError("Must call fetch() on RelationshipManager before editing members") + new_node = RelatedNode(schema=self.schema, client=self.client, branch=self.branch, data=data) + + if (new_node.id and new_node.id not in self.peer_ids) or ( + new_node.hfid and new_node.hfid not in self.peer_hfids + ): + self.peers.append(new_node) + self._has_update = True + + def extend(self, data: Iterable[str | RelatedNode | dict]) -> None: + """Add new peers to this relationship.""" + for d in data: + self.add(d) + + def remove(self, data: str | RelatedNode | dict) -> None: + if not self.initialized: + raise UninitializedError("Must call fetch() on RelationshipManager before editing members") + node_to_remove = RelatedNode(schema=self.schema, client=self.client, branch=self.branch, data=data) + + if node_to_remove.id and node_to_remove.id in self.peer_ids: + idx = self.peer_ids.index(node_to_remove.id) + if self.peers[idx].id != node_to_remove.id: + raise IndexError(f"Unexpected situation, the node with the index {idx} should be {node_to_remove.id}") + + self.peers.pop(idx) + self._has_update = True + + elif node_to_remove.hfid and node_to_remove.hfid in self.peer_hfids: + idx = self.peer_hfids.index(node_to_remove.hfid) + if self.peers[idx].hfid != node_to_remove.hfid: + raise IndexError(f"Unexpected situation, the node with the index {idx} should be {node_to_remove.hfid}") + + self.peers.pop(idx) + self._has_update = True + + +class RelationshipManagerSync(RelationshipManagerBase): + """Manages relationships of a node in a synchronous context.""" + + def __init__( + self, + name: str, + client: InfrahubClientSync, + node: InfrahubNodeSync, + branch: str, + schema: RelationshipSchemaAPI, + data: Any | dict, + ): + """ + Args: + name (str): The name of the relationship. + client (InfrahubClientSync): The client used to interact with the backend synchronously. + node (InfrahubNodeSync): The node to which the relationship belongs. + branch (str): The branch where the relationship resides. + schema (RelationshipSchema): The schema of the relationship. + data (Union[Any, dict]): Initial data for the relationships. + """ + self.client = client + self.node = node + + super().__init__(name=name, schema=schema, branch=branch) + + self.initialized = data is not None + self._has_update = False + + if data is None: + return + + if isinstance(data, list): + for item in data: + self.peers.append( + RelatedNodeSync(name=name, client=self.client, branch=self.branch, schema=schema, data=item) + ) + elif isinstance(data, dict) and "edges" in data: + for item in data["edges"]: + self.peers.append( + RelatedNodeSync(name=name, client=self.client, branch=self.branch, schema=schema, data=item) + ) + else: + raise ValueError(f"Unexpected format for {name} found a {type(data)}, {data}") + + def __getitem__(self, item: int) -> RelatedNodeSync: + return self.peers[item] # type: ignore[return-value] + + def fetch(self) -> None: + if not self.initialized: + exclude = self.node._schema.relationship_names + self.node._schema.attribute_names + exclude.remove(self.schema.name) + node = self.client.get( + kind=self.node._schema.kind, + id=self.node.id, + branch=self.branch, + include=[self.schema.name], + exclude=exclude, + ) + rm = getattr(node, self.schema.name) + self.peers = rm.peers + self.initialized = True + + for peer in self.peers: + peer.fetch() + + def add(self, data: str | RelatedNodeSync | dict) -> None: + """Add a new peer to this relationship.""" + if not self.initialized: + raise UninitializedError("Must call fetch() on RelationshipManager before editing members") + new_node = RelatedNodeSync(schema=self.schema, client=self.client, branch=self.branch, data=data) + + if (new_node.id and new_node.id not in self.peer_ids) or ( + new_node.hfid and new_node.hfid not in self.peer_hfids + ): + self.peers.append(new_node) + self._has_update = True + + def extend(self, data: Iterable[str | RelatedNodeSync | dict]) -> None: + """Add new peers to this relationship.""" + for d in data: + self.add(d) + + def remove(self, data: str | RelatedNodeSync | dict) -> None: + if not self.initialized: + raise UninitializedError("Must call fetch() on RelationshipManager before editing members") + node_to_remove = RelatedNodeSync(schema=self.schema, client=self.client, branch=self.branch, data=data) + + if node_to_remove.id and node_to_remove.id in self.peer_ids: + idx = self.peer_ids.index(node_to_remove.id) + if self.peers[idx].id != node_to_remove.id: + raise IndexError(f"Unexpected situation, the node with the index {idx} should be {node_to_remove.id}") + self.peers.pop(idx) + self._has_update = True + + elif node_to_remove.hfid and node_to_remove.hfid in self.peer_hfids: + idx = self.peer_hfids.index(node_to_remove.hfid) + if self.peers[idx].hfid != node_to_remove.hfid: + raise IndexError(f"Unexpected situation, the node with the index {idx} should be {node_to_remove.hfid}") + + self.peers.pop(idx) + self._has_update = True diff --git a/infrahub_sdk/protocols.py b/infrahub_sdk/protocols.py index 7a69b5f8..d002e433 100644 --- a/infrahub_sdk/protocols.py +++ b/infrahub_sdk/protocols.py @@ -68,6 +68,12 @@ class BuiltinIPPrefix(CoreNode): children: RelationshipManager +class CoreAction(CoreNode): + name: String + description: StringOptional + triggers: RelationshipManager + + class CoreArtifactTarget(CoreNode): artifacts: RelationshipManager @@ -154,6 +160,10 @@ class CoreMenu(CoreNode): children: RelationshipManager +class CoreNodeTriggerMatch(CoreNode): + trigger: RelatedNode + + class CoreObjectComponentTemplate(CoreNode): template_name: String @@ -195,6 +205,14 @@ class CoreTransformation(CoreNode): tags: RelationshipManager +class CoreTriggerRule(CoreNode): + name: String + description: StringOptional + active: Boolean + branch_scope: Dropdown + action: RelatedNode + + class CoreValidator(CoreNode): label: StringOptional state: Enum @@ -328,6 +346,10 @@ class CoreFileThread(CoreThread): repository: RelatedNode +class CoreGeneratorAction(CoreAction): + generator: RelatedNode + + class CoreGeneratorCheck(CoreCheck): instance: String @@ -382,6 +404,16 @@ class CoreGraphQLQueryGroup(CoreGroup): query: RelatedNode +class CoreGroupAction(CoreAction): + add_members: Boolean + group: RelatedNode + + +class CoreGroupTriggerRule(CoreTriggerRule): + members_added: Boolean + group: RelatedNode + + class CoreIPAddressPool(CoreResourcePool, LineageSource): default_address_type: String default_prefix_length: IntegerOptional @@ -401,6 +433,25 @@ class CoreMenuItem(CoreMenu): pass +class CoreNodeTriggerAttributeMatch(CoreNodeTriggerMatch): + attribute_name: String + value: StringOptional + value_previous: StringOptional + value_match: Dropdown + + +class CoreNodeTriggerRelationshipMatch(CoreNodeTriggerMatch): + relationship_name: String + added: Boolean + peer: StringOptional + + +class CoreNodeTriggerRule(CoreTriggerRule): + node_kind: String + mutation_action: Enum + matches: RelationshipManager + + class CoreNumberPool(CoreResourcePool, LineageSource): node: String node_attribute: String @@ -448,6 +499,11 @@ class CoreRepository(LineageOwner, LineageSource, CoreGenericRepository, CoreTas commit: StringOptional +class CoreRepositoryGroup(CoreGroup): + content: Dropdown + repository: RelatedNode + + class CoreRepositoryValidator(CoreValidator): repository: RelatedNode @@ -545,6 +601,12 @@ class BuiltinIPPrefixSync(CoreNodeSync): children: RelationshipManagerSync +class CoreActionSync(CoreNodeSync): + name: String + description: StringOptional + triggers: RelationshipManagerSync + + class CoreArtifactTargetSync(CoreNodeSync): artifacts: RelationshipManagerSync @@ -631,6 +693,10 @@ class CoreMenuSync(CoreNodeSync): children: RelationshipManagerSync +class CoreNodeTriggerMatchSync(CoreNodeSync): + trigger: RelatedNodeSync + + class CoreObjectComponentTemplateSync(CoreNodeSync): template_name: String @@ -672,6 +738,14 @@ class CoreTransformationSync(CoreNodeSync): tags: RelationshipManagerSync +class CoreTriggerRuleSync(CoreNodeSync): + name: String + description: StringOptional + active: Boolean + branch_scope: Dropdown + action: RelatedNodeSync + + class CoreValidatorSync(CoreNodeSync): label: StringOptional state: Enum @@ -805,6 +879,10 @@ class CoreFileThreadSync(CoreThreadSync): repository: RelatedNodeSync +class CoreGeneratorActionSync(CoreActionSync): + generator: RelatedNodeSync + + class CoreGeneratorCheckSync(CoreCheckSync): instance: String @@ -859,6 +937,16 @@ class CoreGraphQLQueryGroupSync(CoreGroupSync): query: RelatedNodeSync +class CoreGroupActionSync(CoreActionSync): + add_members: Boolean + group: RelatedNodeSync + + +class CoreGroupTriggerRuleSync(CoreTriggerRuleSync): + members_added: Boolean + group: RelatedNodeSync + + class CoreIPAddressPoolSync(CoreResourcePoolSync, LineageSourceSync): default_address_type: String default_prefix_length: IntegerOptional @@ -878,6 +966,25 @@ class CoreMenuItemSync(CoreMenuSync): pass +class CoreNodeTriggerAttributeMatchSync(CoreNodeTriggerMatchSync): + attribute_name: String + value: StringOptional + value_previous: StringOptional + value_match: Dropdown + + +class CoreNodeTriggerRelationshipMatchSync(CoreNodeTriggerMatchSync): + relationship_name: String + added: Boolean + peer: StringOptional + + +class CoreNodeTriggerRuleSync(CoreTriggerRuleSync): + node_kind: String + mutation_action: Enum + matches: RelationshipManagerSync + + class CoreNumberPoolSync(CoreResourcePoolSync, LineageSourceSync): node: String node_attribute: String @@ -925,6 +1032,11 @@ class CoreRepositorySync(LineageOwnerSync, LineageSourceSync, CoreGenericReposit commit: StringOptional +class CoreRepositoryGroupSync(CoreGroupSync): + content: Dropdown + repository: RelatedNodeSync + + class CoreRepositoryValidatorSync(CoreValidatorSync): repository: RelatedNodeSync diff --git a/infrahub_sdk/protocols_base.py b/infrahub_sdk/protocols_base.py index 2d533ac7..a47d95ef 100644 --- a/infrahub_sdk/protocols_base.py +++ b/infrahub_sdk/protocols_base.py @@ -10,11 +10,42 @@ @runtime_checkable -class RelatedNode(Protocol): ... +class RelatedNodeBase(Protocol): + @property + def id(self) -> str | None: ... + + @property + def hfid(self) -> list[Any] | None: ... + + @property + def hfid_str(self) -> str | None: ... + + @property + def is_resource_pool(self) -> bool: ... + + @property + def initialized(self) -> bool: ... + + @property + def display_label(self) -> str | None: ... + + @property + def typename(self) -> str | None: ... + + def _generate_input_data(self, allocate_from_pool: bool = False) -> dict[str, Any]: ... + + def _generate_mutation_query(self) -> dict[str, Any]: ... + + @classmethod + def _generate_query_data(cls, peer_data: dict[str, Any] | None = None, property: bool = False) -> dict: ... + + +@runtime_checkable +class RelatedNode(RelatedNodeBase, Protocol): ... @runtime_checkable -class RelatedNodeSync(Protocol): ... +class RelatedNodeSync(RelatedNodeBase, Protocol): ... @runtime_checkable @@ -147,6 +178,7 @@ class CoreNodeBase(Protocol): _internal_id: str id: str # NOTE this is incorrect, should be str | None display_label: str | None + typename: str | None @property def hfid(self) -> list[str] | None: ... diff --git a/infrahub_sdk/query_groups.py b/infrahub_sdk/query_groups.py index 4bb732b2..94b16a10 100644 --- a/infrahub_sdk/query_groups.py +++ b/infrahub_sdk/query_groups.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from .constants import InfrahubClientMode from .exceptions import NodeNotFoundError @@ -25,6 +25,7 @@ def __init__(self) -> None: self.params: dict[str, str] = {} self.delete_unused_nodes: bool = False self.group_type: str = "CoreStandardGroup" + self.group_params: dict[str, Any] = {} def set_properties( self, @@ -32,6 +33,8 @@ def set_properties( params: dict[str, str] | None = None, delete_unused_nodes: bool = False, group_type: str | None = None, + group_params: dict[str, Any] | None = None, + branch: str | None = None, ) -> None: """Setter method to set the values of identifier and params. @@ -43,6 +46,8 @@ def set_properties( self.params = params or {} self.delete_unused_nodes = delete_unused_nodes self.group_type = group_type or self.group_type + self.group_params = group_params or {} + self.branch = branch def _get_params_as_str(self) -> str: """Convert the params in dict format, into a string""" @@ -87,7 +92,9 @@ def __init__(self, client: InfrahubClient) -> None: async def get_group(self, store_peers: bool = False) -> InfrahubNode | None: group_name = self._generate_group_name() try: - group = await self.client.get(kind=self.group_type, name__value=group_name, include=["members"]) + group = await self.client.get( + kind=self.group_type, name__value=group_name, include=["members"], branch=self.branch + ) except NodeNotFoundError: return None @@ -151,6 +158,8 @@ async def update_group(self) -> None: name=group_name, description=description, members=members, + branch=self.branch, + **self.group_params, ) await group.save(allow_upsert=True, update_group_context=False) @@ -243,6 +252,8 @@ def update_group(self) -> None: name=group_name, description=description, members=members, + branch=self.branch, + **self.group_params, ) group.save(allow_upsert=True, update_group_context=False) diff --git a/infrahub_sdk/schema/main.py b/infrahub_sdk/schema/main.py index e2985d7b..ba18cf49 100644 --- a/infrahub_sdk/schema/main.py +++ b/infrahub_sdk/schema/main.py @@ -49,6 +49,7 @@ class AttributeKind(str, Enum): TEXTAREA = "TextArea" DATETIME = "DateTime" NUMBER = "Number" + NUMBERPOOL = "NumberPool" DROPDOWN = "Dropdown" EMAIL = "Email" PASSWORD = "Password" # noqa: S105 diff --git a/infrahub_sdk/schema/repository.py b/infrahub_sdk/schema/repository.py index b5c58d2f..69d63832 100644 --- a/infrahub_sdk/schema/repository.py +++ b/infrahub_sdk/schema/repository.py @@ -147,6 +147,18 @@ def load_query(self, relative_path: str = ".") -> str: return file.read() +class InfrahubObjectConfig(InfrahubRepositoryConfigElement): + model_config = ConfigDict(extra="forbid") + name: str = Field(..., description="The name associated to the object file") + file_path: Path = Field(..., description="The file within the repository containing object data.") + + +class InfrahubMenuConfig(InfrahubRepositoryConfigElement): + model_config = ConfigDict(extra="forbid") + name: str = Field(..., description="The name of the menu") + file_path: Path = Field(..., description="The file within the repository containing menu data.") + + RESOURCE_MAP: dict[Any, str] = { InfrahubJinja2TransformConfig: "jinja2_transforms", InfrahubCheckDefinitionConfig: "check_definitions", @@ -154,6 +166,8 @@ def load_query(self, relative_path: str = ".") -> str: InfrahubPythonTransformConfig: "python_transforms", InfrahubGeneratorDefinitionConfig: "generator_definitions", InfrahubRepositoryGraphQLConfig: "queries", + InfrahubObjectConfig: "objects", + InfrahubMenuConfig: "menus", } @@ -176,6 +190,8 @@ class InfrahubRepositoryConfig(BaseModel): default_factory=list, description="Generator definitions" ) queries: list[InfrahubRepositoryGraphQLConfig] = Field(default_factory=list, description="GraphQL Queries") + objects: list[Path] = Field(default_factory=list, description="Objects") + menus: list[Path] = Field(default_factory=list, description="Menus") @field_validator( "check_definitions", diff --git a/infrahub_sdk/spec/object.py b/infrahub_sdk/spec/object.py index acbf1551..23a11c10 100644 --- a/infrahub_sdk/spec/object.py +++ b/infrahub_sdk/spec/object.py @@ -459,7 +459,7 @@ async def create_node( await node.save(allow_upsert=True) display_label = node.get_human_friendly_id_as_string() or f"{node.get_kind()} : {node.id}" - client.log.info(f"Node: {display_label}") + client.log.info(f"Created node: {display_label}") for rel in remaining_rels: context = {} diff --git a/infrahub_sdk/store.py b/infrahub_sdk/store.py index 99659fc0..6420495b 100644 --- a/infrahub_sdk/store.py +++ b/infrahub_sdk/store.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Literal, overload from .exceptions import NodeInvalidError, NodeNotFoundError -from .node import parse_human_friendly_id +from .node.parsers import parse_human_friendly_id if TYPE_CHECKING: from .client import SchemaType, SchemaTypeSync diff --git a/infrahub_sdk/testing/schemas/car_person.py b/infrahub_sdk/testing/schemas/car_person.py index 2da7ab4d..3a1c3dc3 100644 --- a/infrahub_sdk/testing/schemas/car_person.py +++ b/infrahub_sdk/testing/schemas/car_person.py @@ -48,6 +48,7 @@ def schema_person_base(self) -> NodeSchema: namespace=NAMESPACE, include_in_menu=True, label="Person", + default_filter="name__value", human_friendly_id=["name__value"], attributes=[ Attr(name="name", kind=AttributeKind.TEXT, unique=True), diff --git a/tests/unit/sdk/test_node.py b/tests/unit/sdk/test_node.py index fbe381bf..fdaa83bd 100644 --- a/tests/unit/sdk/test_node.py +++ b/tests/unit/sdk/test_node.py @@ -7,7 +7,6 @@ from infrahub_sdk.exceptions import NodeNotFoundError from infrahub_sdk.node import ( - SAFE_VALUE, InfrahubNode, InfrahubNodeBase, InfrahubNodeSync, @@ -15,6 +14,7 @@ RelationshipManagerBase, parse_human_friendly_id, ) +from infrahub_sdk.node.constants import SAFE_VALUE from infrahub_sdk.schema import GenericSchema, NodeSchemaAPI if TYPE_CHECKING: