From a76a8d92baafd337c564efd5edd37153defe9984 Mon Sep 17 00:00:00 2001 From: Pol Michel Date: Tue, 28 Apr 2026 09:05:15 +0200 Subject: [PATCH 1/2] AI generation SDK API google docstrings on node package --- infrahub_sdk/node/attribute.py | 34 +- infrahub_sdk/node/metadata.py | 38 +- infrahub_sdk/node/node.py | 552 +++++++++++++++++++++++++++++- infrahub_sdk/node/parsers.py | 17 +- infrahub_sdk/node/property.py | 18 +- infrahub_sdk/node/related_node.py | 171 ++++++++- infrahub_sdk/node/relationship.py | 166 ++++++++- 7 files changed, 954 insertions(+), 42 deletions(-) diff --git a/infrahub_sdk/node/attribute.py b/infrahub_sdk/node/attribute.py index 54dd99aa..4c1978ea 100644 --- a/infrahub_sdk/node/attribute.py +++ b/infrahub_sdk/node/attribute.py @@ -41,14 +41,40 @@ def add_properties(self, properties_flag: dict[str, Any], properties_object: dic class Attribute: - """Represents an attribute of a Node, including its schema, value, and properties.""" + """Represents an attribute of a Node, including its schema, value, and properties. + + An ``Attribute`` wraps a single attribute on an :class:`InfrahubNode`. It tracks the + current value, the metadata properties (``source``, ``owner``, ``is_protected``, ...), + and whether the value has been mutated since the node was loaded. Mutation tracking is + used by ``InfrahubNode.update()`` to send only the changed fields to the API. + + Attributes: + name (str): The name of the attribute. + id (str | None): The unique identifier of the attribute, when known. + value (Any): The current attribute value. Setting this marks the attribute as mutated. + value_has_been_mutated (bool): True when ``value`` has been assigned after construction. + is_default (bool | None): True when the value comes from the schema default. + is_from_profile (bool | None): True when the value is inherited from a profile. + is_inherited (bool | None): True when the attribute is inherited from a generic. + is_protected (bool | None): True when the attribute is protected from modification. + updated_at (str | None): ISO-8601 timestamp of the most recent update. + source (NodeProperty | None): The node that supplied this attribute value. + owner (NodeProperty | None): The node that owns this attribute. + updated_by (NodeProperty | None): The account that performed the most recent update. + """ def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict) -> None: - """ + """Build an ``Attribute`` from raw GraphQL data. + + IP-typed attributes (``IPHost``, ``IPNetwork``) are parsed via the standard + ``ipaddress`` module so the in-memory value is a network/interface object. + 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. + schema (AttributeSchemaAPI): The schema defining the attribute. + data (Any | dict): The data for the attribute. Either a scalar value, a dict + with a ``value`` key (and optional metadata properties), or a dict with a + ``from_pool`` key to allocate the value from a resource pool when saving. """ self.name = name self._schema = schema diff --git a/infrahub_sdk/node/metadata.py b/infrahub_sdk/node/metadata.py index 1fe236d8..932d7b54 100644 --- a/infrahub_sdk/node/metadata.py +++ b/infrahub_sdk/node/metadata.py @@ -4,12 +4,26 @@ class NodeMetadata: - """Represents metadata about a node (created_at, created_by, updated_at, updated_by).""" + """Represents metadata about a node (created_at, created_by, updated_at, updated_by). + + Populated from the ``node_metadata`` GraphQL block when ``include_metadata=True`` + is passed to a query. The ``*_by`` fields point to the user who created or last + updated the node, exposed as :class:`NodeProperty` references. + + Attributes: + created_at (str | None): ISO-8601 timestamp of node creation. + created_by (NodeProperty | None): The account that created the node. + updated_at (str | None): ISO-8601 timestamp of the most recent update. + updated_by (NodeProperty | None): The account that performed the most recent update. + """ def __init__(self, data: dict | None = None) -> None: - """ + """Build a ``NodeMetadata`` from raw GraphQL data. + Args: - data: Data containing the metadata fields from the GraphQL response. + data (dict | None): Mapping with ``created_at``, ``created_by``, ``updated_at``, + and ``updated_by`` keys as returned by the GraphQL API. When ``None`` or + empty, all fields default to ``None``. """ self.created_at: str | None = None self.created_by: NodeProperty | None = None @@ -42,12 +56,24 @@ def _generate_query_data(cls) -> dict: class RelationshipMetadata: - """Represents metadata about a relationship edge (updated_at, updated_by).""" + """Represents metadata about a relationship edge (updated_at, updated_by). + + Populated from the ``relationship_metadata`` GraphQL block when ``include_metadata=True`` + is passed to a query. Unlike :class:`NodeMetadata`, this only carries update info because + the creation timestamp of an edge is not tracked separately from its peer node. + + Attributes: + updated_at (str | None): ISO-8601 timestamp of the most recent edge update. + updated_by (NodeProperty | None): The account that performed the most recent edge update. + """ def __init__(self, data: dict | None = None) -> None: - """ + """Build a ``RelationshipMetadata`` from raw GraphQL data. + Args: - data: Data containing the metadata fields from the GraphQL response. + data (dict | None): Mapping with ``updated_at`` and ``updated_by`` keys as + returned by the GraphQL API. When ``None`` or empty, all fields default + to ``None``. """ self.updated_at: str | None = None self.updated_by: NodeProperty | None = None diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index 24185886..9cf59acc 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -38,7 +38,19 @@ class InfrahubNodeBase: - """Base class for InfrahubNode and InfrahubNodeSync""" + """Base class for :class:`InfrahubNode` and :class:`InfrahubNodeSync`. + + Owns the schema-driven state shared between the async and sync clients: attributes, + relationships, identity (``id``, ``hfid``), node metadata, and the helpers that turn + the in-memory state into GraphQL query and mutation payloads. This class is not + meant to be instantiated directly; use :class:`InfrahubNode` or + :class:`InfrahubNodeSync` instead. + + Attributes: + id (str | None): The unique identifier of the node, when known. + display_label (str | None): Human-readable label of the node. + typename (str | None): The GraphQL ``__typename`` of the node. + """ def __init__(self, schema: MainSchemaTypesAPI, branch: str, data: dict | None = None) -> None: """ @@ -81,9 +93,28 @@ def __init__(self, schema: MainSchemaTypesAPI, branch: str, data: dict | None = self._init_relationships(data) def get_branch(self) -> str: + """Return the branch this node is bound to. + + Returns: + str: The name of the branch. + """ return self._branch def get_path_value(self, path: str) -> Any: + """Resolve a value addressed by a dunder-separated path on this node. + + The path can target an attribute (``name__value``, ``name__source``), a + cardinality-one related node (``parent``), an attribute of that related node + (``parent__name__value``), or a property of one of its attributes + (``parent__name__source``). + + Args: + path (str): A path with components separated by ``__``. + + Returns: + Any: The resolved value, or ``None`` when any path component cannot be + resolved (for example, an unfetched related node not present in the store). + """ path_parts = path.split("__") return_value = None @@ -121,6 +152,16 @@ def get_path_value(self, path: str) -> Any: return return_value def get_human_friendly_id(self) -> list[str] | None: + """Compute the human-friendly ID for this node from its schema. + + The HFID is composed of the values addressed by the schema's + ``human_friendly_id`` paths. When any component cannot be resolved, the HFID is + considered invalid and ``None`` is returned. + + Returns: + list[str] | None: The HFID as a list of stringified components, or ``None`` + when the schema does not define an HFID or a component is missing. + """ if not hasattr(self._schema, "human_friendly_id"): return None @@ -134,6 +175,16 @@ def get_human_friendly_id(self) -> list[str] | None: return [str(hfid) for hfid in hfid_components] def get_human_friendly_id_as_string(self, include_kind: bool = False) -> str | None: + """Return the human-friendly ID joined into a single string. + + Args: + include_kind (bool, optional): When ``True``, the node kind is prepended as + the first component of the resulting string. Defaults to ``False``. + + Returns: + str | None: The HFID joined with the HFID separator, or ``None`` when no + HFID is available. + """ hfid = self.get_human_friendly_id() if not hfid: return None @@ -143,14 +194,31 @@ def get_human_friendly_id_as_string(self, include_kind: bool = False) -> str | N @property def hfid(self) -> list[str] | None: + """Return the human-friendly ID of this node as a list of components. + + Returns: + list[str] | None: The HFID components, or ``None`` when unavailable. + """ return self.get_human_friendly_id() @property def hfid_str(self) -> str | None: + """Return the human-friendly ID of this node as a string, including the kind prefix. + + Returns: + str | None: The HFID as ``Kind__part1__part2``, or ``None`` when unavailable. + """ return self.get_human_friendly_id_as_string(include_kind=True) def get_node_metadata(self) -> NodeMetadata | None: - """Returns the node metadata (created_at, created_by, updated_at, updated_by) if fetched.""" + """Return the node metadata (``created_at``, ``created_by``, ``updated_at``, ``updated_by``). + + The metadata is populated only when the parent query was executed with + ``include_metadata=True``. + + Returns: + NodeMetadata | None: The node metadata if fetched, otherwise ``None``. + """ return self._metadata def _init_attributes(self, data: dict | None = None) -> None: @@ -190,26 +258,56 @@ def __repr__(self) -> str: return f"{self._schema.kind} ({self.id}) " def get_kind(self) -> str: + """Return the schema kind of this node. + + Returns: + str: The schema kind (for example ``"CoreAccount"``). + """ return self._schema.kind def get_all_kinds(self) -> list[str]: + """Return this node's kind plus all generic kinds it inherits from. + + Returns: + list[str]: The node's own kind followed by the inherited kinds, in the order + declared on the schema. + """ if inherit_from := getattr(self._schema, "inherit_from", None): return [self._schema.kind, *inherit_from] return [self._schema.kind] def is_ip_prefix(self) -> bool: + """Return whether this node represents an IP prefix. + + Returns: + bool: ``True`` when the node kind is ``BuiltinIPPrefix`` or inherits from it. + """ builtin_ipprefix_kind = "BuiltinIPPrefix" return self.get_kind() == builtin_ipprefix_kind or builtin_ipprefix_kind in self._schema.inherit_from # type: ignore[union-attr] def is_ip_address(self) -> bool: + """Return whether this node represents an IP address. + + Returns: + bool: ``True`` when the node kind is ``BuiltinIPAddress`` or inherits from it. + """ builtin_ipaddress_kind = "BuiltinIPAddress" return self.get_kind() == builtin_ipaddress_kind or builtin_ipaddress_kind in self._schema.inherit_from # type: ignore[union-attr] def is_resource_pool(self) -> bool: + """Return whether this node is a resource pool. + + Returns: + bool: ``True`` when the node inherits from ``CoreResourcePool``. + """ return hasattr(self._schema, "inherit_from") and "CoreResourcePool" in self._schema.inherit_from # type: ignore[union-attr] def is_file_object(self) -> bool: - """Check if this node inherits from CoreFileObject and supports file uploads.""" + """Return whether this node inherits from ``CoreFileObject`` and supports file uploads. + + Returns: + bool: ``True`` when file upload/download operations are supported on this node. + """ return self._file_object_support def upload_from_path(self, path: Path) -> None: @@ -275,6 +373,12 @@ def _get_file_for_upload_sync(self) -> PreparedFile: return FileHandlerBase.prepare_upload_sync(content=self._file_content, name=self._file_name) def get_raw_graphql_data(self) -> dict | None: + """Return the raw GraphQL payload used to build this node. + + Returns: + dict | None: The original GraphQL data, or ``None`` when the node was + constructed without payload (for example, a brand-new node). + """ return self._data def _generate_input_data( # noqa: C901 @@ -499,6 +603,31 @@ def generate_query_data_init( order: Order | None = None, include_metadata: bool = False, ) -> dict[str, Any | dict]: + """Build the top-level ``count``/``edges`` skeleton of a GraphQL query for this kind. + + The returned dict is the outer structure consumed by + :meth:`generate_query_data`; it carries the ``@filters`` block and the empty + ``edges.node`` placeholder that will later be filled by the caller. + + Args: + filters (dict[str, Any], optional): Filters to apply to the query. + offset (int, optional): Pagination offset. + limit (int, optional): Pagination limit. + include (list[str], optional): Attributes or relationships to include. + exclude (list[str], optional): Attributes or relationships to exclude. + partial_match (bool, optional): When ``True``, allow partial matches on filter + criteria. Defaults to ``False``. + order (Order, optional): Ordering options to apply to the query. + include_metadata (bool, optional): When ``True``, include ``node_metadata`` in + the result. Defaults to ``False``. + + Returns: + dict[str, Any | dict]: The query skeleton ready to be combined with node-level + attributes and relationships. + + Raises: + ValueError: If the same name appears in both ``include`` and ``exclude``. + """ data: dict[str, Any] = { "count": None, "edges": {"node": {"id": None, "hfid": None, "display_label": None, "__typename": None}}, @@ -586,7 +715,17 @@ def _build_rel_query_data( class InfrahubNode(InfrahubNodeBase): - """Represents a Infrahub node in an asynchronous context.""" + """Asynchronous Infrahub node bound to an :class:`InfrahubClient`. + + Provides full CRUD against the backend (:meth:`save`, :meth:`create`, :meth:`update`, + :meth:`delete`) along with relationship traversal (:meth:`get_flat_value`, + :meth:`extract`), feature-gated artifact and resource-pool helpers + (:meth:`artifact_generate`, :meth:`get_pool_allocated_resources`), and file upload + or download for nodes inheriting from ``CoreFileObject``. + + Attributes and relationships defined on the schema are exposed as instance + attributes via attribute-style access (``node.name.value``, ``node.parent``). + """ def __init__( self, @@ -631,6 +770,26 @@ async def from_graphql( schema: MainSchemaTypesAPI | None = None, timeout: int | None = None, ) -> Self: + """Build an :class:`InfrahubNode` from a raw GraphQL response. + + When no ``schema`` is provided, the node kind is read from ``__typename`` in the + payload and the schema is fetched from the client. + + Args: + client (InfrahubClient): The client used to interact with the backend. + branch (str): The branch the node belongs to. + data (dict): The GraphQL payload describing the node. + schema (MainSchemaTypesAPI, optional): Pre-fetched schema for the node kind. + Skips the schema lookup when provided. + timeout (int, optional): Overrides the default timeout used when fetching the + schema. Specified in seconds. + + Returns: + InfrahubNode: The hydrated node instance. + + Raises: + ValueError: If ``__typename`` is missing from ``data`` and no ``schema`` was provided. + """ if not schema: node_kind = data.get("__typename") or data.get("node", {}).get("__typename", None) if not node_kind: @@ -720,6 +879,18 @@ def __setattr__(self, name: str, value: Any) -> None: super().__setattr__(name, value) async def generate(self, nodes: list[str] | None = None) -> None: + """Trigger artifact generation for this artifact definition. + + Only available on nodes whose kind is ``CoreArtifactDefinition``. + + Args: + nodes (list[str], optional): The IDs of target nodes to generate artifacts + for. When omitted, generation runs for all targets matched by the + definition. + + Raises: + FeatureNotSupportedError: If this node is not a ``CoreArtifactDefinition``. + """ self._validate_artifact_definition_support(ARTIFACT_DEFINITION_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE) nodes = nodes or [] @@ -728,6 +899,17 @@ async def generate(self, nodes: list[str] | None = None) -> None: resp.raise_for_status() async def artifact_generate(self, name: str) -> None: + """Regenerate a named artifact targeting this node. + + Looks up the ``CoreArtifact`` named ``name`` for this node, then calls + :meth:`generate` on the related definition with this artifact's ID. + + Args: + name (str): The name of the artifact to regenerate. + + Raises: + FeatureNotSupportedError: If this node does not inherit from ``CoreArtifactTarget``. + """ self._validate_artifact_support(ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE) artifact = await self._client.get(kind="CoreArtifact", name__value=name, object__ids=[self.id]) @@ -735,6 +917,18 @@ async def artifact_generate(self, name: str) -> None: await artifact._get_relationship_one(name="definition").peer.generate([artifact.id]) async def artifact_fetch(self, name: str) -> str | dict[str, Any]: + """Fetch the stored content of a named artifact for this node. + + Args: + name (str): The name of the artifact to fetch. + + Returns: + str | dict[str, Any]: The artifact content. Returns a parsed object for + JSON-typed artifacts and a string for text-typed artifacts. + + Raises: + FeatureNotSupportedError: If this node does not inherit from ``CoreArtifactTarget``. + """ self._validate_artifact_support(ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE) artifact = await self._client.get(kind="CoreArtifact", name__value=name, object__ids=[self.id]) @@ -776,6 +970,14 @@ async def download_file(self, dest: Path | None = None) -> bytes | int: return await self._file_handler.download(node_id=self.id, branch=self._branch, dest=dest) async def delete(self, timeout: int | None = None, request_context: RequestContext | None = None) -> None: + """Delete this node on the backend. + + Args: + timeout (int, optional): Overrides the default timeout used when querying the + GraphQL API. Specified in seconds. + request_context (RequestContext, optional): Request-level context passed through + to the mutation. When omitted, the client's request context is used. + """ input_data = {"data": {"id": self.id}} if context_data := self._get_request_context(request_context=request_context): input_data["context"] = context_data @@ -800,6 +1002,24 @@ async def save( timeout: int | None = None, request_context: RequestContext | None = None, ) -> None: + """Persist this node to the backend, creating or updating it as appropriate. + + New nodes are created (or upserted when ``allow_upsert`` is set), and existing + nodes are updated with only the modified fields. After a successful save, the + node is added to the client store and, when applicable, to the active group + context for tracking. + + Args: + allow_upsert (bool, optional): When ``True``, an existing node is upserted + instead of failing with a duplicate. Defaults to ``False``. + update_group_context (bool, optional): Whether to update the group context + with this node. When ``None`` and the client is in tracking mode, defaults + to ``True``. + timeout (int, optional): Overrides the default timeout used when querying the + GraphQL API. Specified in seconds. + request_context (RequestContext, optional): Request-level context passed through + to the mutation. When omitted, the client's request context is used. + """ if self._existing is False or allow_upsert is True: await self.create(allow_upsert=allow_upsert, timeout=timeout, request_context=request_context) else: @@ -887,6 +1107,35 @@ async def generate_query_data( order: Order | None = None, include_metadata: bool = False, ) -> dict[str, Any | dict]: + """Generate the full GraphQL query payload for this node kind. + + The returned dict combines :meth:`generate_query_data_init` with + :meth:`generate_query_data_node`. When the node is a generic and ``fragment`` is + ``True``, ``...on Kind`` fragments are added for every implementing kind so the + relevant attributes are returned alongside the generic fields. + + Args: + filters (dict[str, Any], optional): Filters to apply to the query. + offset (int, optional): Pagination offset. + limit (int, optional): Pagination limit. + include (list[str], optional): Attributes or relationships to include. + exclude (list[str], optional): Attributes or relationships to exclude. + fragment (bool, optional): When ``True`` and the schema is a generic, emit + ``...on Kind`` fragments for each implementing kind. Defaults to ``False``. + prefetch_relationships (bool, optional): When ``True``, pre-fetch related node + data instead of returning only their identifiers. Defaults to ``False``. + partial_match (bool, optional): When ``True``, allow partial matches on filter + criteria. Defaults to ``False``. + property (bool, optional): When ``True``, include attribute and relationship + properties (``source``, ``owner``, ``is_protected``, ...). Defaults to ``False``. + order (Order, optional): Ordering options to apply to the query. + include_metadata (bool, optional): When ``True``, include ``node_metadata`` and + ``relationship_metadata`` in the result. Defaults to ``False``. + + Returns: + dict[str, Any | dict]: A query payload keyed by the node kind, ready to be + rendered as GraphQL. + """ data = self.generate_query_data_init( filters=filters, offset=offset, @@ -1028,6 +1277,15 @@ async def generate_query_data_node( return data async def add_relationships(self, relation_to_update: str, related_nodes: list[str]) -> None: + """Add peers to a cardinality-many relationship through a dedicated mutation. + + Unlike :meth:`save`, this method targets a single relationship and only adds + peers, leaving every other field untouched. + + Args: + relation_to_update (str): The name of the relationship to update. + related_nodes (list[str]): The IDs of the peers to add. + """ query = self._relationship_mutation( action="Add", relation_to_update=relation_to_update, related_nodes=related_nodes ) @@ -1035,6 +1293,15 @@ async def add_relationships(self, relation_to_update: str, related_nodes: list[s await self._client.execute_graphql(query=query, branch_name=self._branch, tracker=tracker) async def remove_relationships(self, relation_to_update: str, related_nodes: list[str]) -> None: + """Remove peers from a cardinality-many relationship through a dedicated mutation. + + Unlike :meth:`save`, this method targets a single relationship and only removes + the listed peers, leaving every other field untouched. + + Args: + relation_to_update (str): The name of the relationship to update. + related_nodes (list[str]): The IDs of the peers to remove. + """ query = self._relationship_mutation( action="Remove", relation_to_update=relation_to_update, related_nodes=related_nodes ) @@ -1088,6 +1355,26 @@ async def _process_mutation_result( async def create( self, allow_upsert: bool = False, timeout: int | None = None, request_context: RequestContext | None = None ) -> None: + """Create this node on the backend. + + For nodes inheriting from ``CoreFileObject``, the file content set with + :meth:`upload_from_path` or :meth:`upload_from_bytes` is uploaded as part of the + mutation and cleared from the node afterward. + + Prefer :meth:`save` over calling ``create()`` directly so existing-vs-new logic + is handled for you. + + Args: + allow_upsert (bool, optional): When ``True``, the operation upserts instead of + erroring on a duplicate. Defaults to ``False``. + timeout (int, optional): Overrides the default timeout used when querying the + GraphQL API. Specified in seconds. + request_context (RequestContext, optional): Request-level context passed through + to the mutation. When omitted, the client's request context is used. + + Raises: + ValueError: If this is a file-object node and no file content has been set. + """ if self._file_object_support and self._file_content is None: raise ValueError( f"Cannot create {self._schema.kind} without file content. Use upload_from_path() or upload_from_bytes() to provide " @@ -1144,6 +1431,23 @@ async def create( async def update( self, do_full_update: bool = False, timeout: int | None = None, request_context: RequestContext | None = None ) -> None: + """Update this node on the backend. + + By default only the modified attributes and relationships are sent so the server + can compute a minimal diff. Setting ``do_full_update`` re-sends every field even + when unchanged, which is useful when forcing relationship reconciliation. + + Prefer :meth:`save` over calling ``update()`` directly so existing-vs-new logic + is handled for you. + + Args: + do_full_update (bool, optional): When ``True``, send every field even when + unmodified. Defaults to ``False``. + timeout (int, optional): Overrides the default timeout used when querying the + GraphQL API. Specified in seconds. + request_context (RequestContext, optional): Request-level context passed through + to the mutation. When omitted, the client's request context is used. + """ input_data = self._generate_input_data(exclude_unmodified=not do_full_update, request_context=request_context) mutation_query = self._generate_mutation_query() mutation_name = f"{self._schema.kind}Update" @@ -1353,7 +1657,24 @@ def _get_relationship_one(self, name: str) -> RelatedNode: raise ResourceNotDefinedError(message=f"The node doesn't have a cardinality=one relationship for {name}") async def get_flat_value(self, key: str, separator: str = "__") -> Any: - """Query recursively a value defined in a flat notation (string), on a hierarchy of objects + """Resolve a value addressed by a flat key over this node and its related nodes. + + Walks attributes on this node, descending through cardinality-one relationships + (which are fetched on demand) until the final component is reached. Each + relationship hop incurs a backend call, so this is intended for ad-hoc lookups + rather than bulk traversal. + + Args: + key (str): The flat key to resolve (for example ``"name__value"`` or + ``"site__name__value"``). + separator (str, optional): Component separator in ``key``. Defaults to ``"__"``. + + Returns: + Any: The resolved value. + + Raises: + ValueError: If a component does not match an attribute or relationship, or if + a relationship hop targets a non cardinality-one relationship. Examples: name__value @@ -1385,7 +1706,17 @@ async def get_flat_value(self, key: str, separator: str = "__") -> Any: return await related_node.peer.get_flat_value(key=remaining, separator=separator) async def extract(self, params: dict[str, str]) -> dict[str, Any]: - """Extract some data points defined in a flat notation.""" + """Extract several values addressed by flat keys into a labeled dict. + + Each value in ``params`` is resolved with :meth:`get_flat_value`, and the + corresponding key is preserved as the output label. + + Args: + params (dict[str, str]): A mapping of output label to flat key to resolve. + + Returns: + dict[str, Any]: The resolved values keyed by their output label. + """ result: dict[str, Any] = {} for key, value in params.items(): result[key] = await self.get_flat_value(key=value) @@ -1403,7 +1734,18 @@ def __dir__(self) -> Iterable[str]: class InfrahubNodeSync(InfrahubNodeBase): - """Represents a Infrahub node in a synchronous context.""" + """Synchronous Infrahub node bound to an :class:`InfrahubClientSync`. + + Synchronous counterpart of :class:`InfrahubNode`. Provides full CRUD against the + backend (:meth:`save`, :meth:`create`, :meth:`update`, :meth:`delete`) along with + relationship traversal (:meth:`get_flat_value`, :meth:`extract`), feature-gated + artifact and resource-pool helpers (:meth:`artifact_generate`, + :meth:`get_pool_allocated_resources`), and file upload or download for nodes + inheriting from ``CoreFileObject``. + + Attributes and relationships defined on the schema are exposed as instance + attributes via attribute-style access (``node.name.value``, ``node.parent``). + """ def __init__( self, @@ -1448,6 +1790,26 @@ def from_graphql( schema: MainSchemaTypesAPI | None = None, timeout: int | None = None, ) -> Self: + """Build an :class:`InfrahubNodeSync` from a raw GraphQL response. + + When no ``schema`` is provided, the node kind is read from ``__typename`` in the + payload and the schema is fetched from the client. + + Args: + client (InfrahubClientSync): The client used to interact with the backend. + branch (str): The branch the node belongs to. + data (dict): The GraphQL payload describing the node. + schema (MainSchemaTypesAPI, optional): Pre-fetched schema for the node kind. + Skips the schema lookup when provided. + timeout (int, optional): Overrides the default timeout used when fetching the + schema. Specified in seconds. + + Returns: + InfrahubNodeSync: The hydrated node instance. + + Raises: + ValueError: If ``__typename`` is missing from ``data`` and no ``schema`` was provided. + """ if not schema: node_kind = data.get("__typename") or data.get("node", {}).get("__typename", None) if not node_kind: @@ -1538,6 +1900,18 @@ def __setattr__(self, name: str, value: Any) -> None: super().__setattr__(name, value) def generate(self, nodes: list[str] | None = None) -> None: + """Trigger artifact generation for this artifact definition. + + Only available on nodes whose kind is ``CoreArtifactDefinition``. + + Args: + nodes (list[str], optional): The IDs of target nodes to generate artifacts + for. When omitted, generation runs for all targets matched by the + definition. + + Raises: + FeatureNotSupportedError: If this node is not a ``CoreArtifactDefinition``. + """ self._validate_artifact_definition_support(ARTIFACT_DEFINITION_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE) nodes = nodes or [] payload = {"nodes": nodes} @@ -1545,12 +1919,35 @@ def generate(self, nodes: list[str] | None = None) -> None: resp.raise_for_status() def artifact_generate(self, name: str) -> None: + """Regenerate a named artifact targeting this node. + + Looks up the ``CoreArtifact`` named ``name`` for this node, then calls + :meth:`generate` on the related definition with this artifact's ID. + + Args: + name (str): The name of the artifact to regenerate. + + Raises: + FeatureNotSupportedError: If this node does not inherit from ``CoreArtifactTarget``. + """ self._validate_artifact_support(ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE) artifact = self._client.get(kind="CoreArtifact", name__value=name, object__ids=[self.id]) artifact._get_relationship_one(name="definition").fetch() artifact._get_relationship_one(name="definition").peer.generate([artifact.id]) def artifact_fetch(self, name: str) -> str | dict[str, Any]: + """Fetch the stored content of a named artifact for this node. + + Args: + name (str): The name of the artifact to fetch. + + Returns: + str | dict[str, Any]: The artifact content. Returns a parsed object for + JSON-typed artifacts and a string for text-typed artifacts. + + Raises: + FeatureNotSupportedError: If this node does not inherit from ``CoreArtifactTarget``. + """ self._validate_artifact_support(ARTIFACT_FETCH_FEATURE_NOT_SUPPORTED_MESSAGE) artifact = self._client.get(kind="CoreArtifact", name__value=name, object__ids=[self.id]) return self._client.object_store.get(identifier=artifact._get_attribute(name="storage_id").value) @@ -1591,6 +1988,14 @@ def download_file(self, dest: Path | None = None) -> bytes | int: return self._file_handler.download(node_id=self.id, branch=self._branch, dest=dest) def delete(self, timeout: int | None = None, request_context: RequestContext | None = None) -> None: + """Delete this node on the backend. + + Args: + timeout (int, optional): Overrides the default timeout used when querying the + GraphQL API. Specified in seconds. + request_context (RequestContext, optional): Request-level context passed through + to the mutation. When omitted, the client's request context is used. + """ input_data = {"data": {"id": self.id}} if context_data := self._get_request_context(request_context=request_context): input_data["context"] = context_data @@ -1615,6 +2020,24 @@ def save( timeout: int | None = None, request_context: RequestContext | None = None, ) -> None: + """Persist this node to the backend, creating or updating it as appropriate. + + New nodes are created (or upserted when ``allow_upsert`` is set), and existing + nodes are updated with only the modified fields. After a successful save, the + node is added to the client store and, when applicable, to the active group + context for tracking. + + Args: + allow_upsert (bool, optional): When ``True``, an existing node is upserted + instead of failing with a duplicate. Defaults to ``False``. + update_group_context (bool, optional): Whether to update the group context + with this node. When ``None`` and the client is in tracking mode, defaults + to ``True``. + timeout (int, optional): Overrides the default timeout used when querying the + GraphQL API. Specified in seconds. + request_context (RequestContext, optional): Request-level context passed through + to the mutation. When omitted, the client's request context is used. + """ if self._existing is False or allow_upsert is True: self.create(allow_upsert=allow_upsert, timeout=timeout, request_context=request_context) else: @@ -1698,6 +2121,35 @@ def generate_query_data( order: Order | None = None, include_metadata: bool = False, ) -> dict[str, Any | dict]: + """Generate the full GraphQL query payload for this node kind. + + The returned dict combines :meth:`generate_query_data_init` with + :meth:`generate_query_data_node`. When the node is a generic and ``fragment`` is + ``True``, ``...on Kind`` fragments are added for every implementing kind so the + relevant attributes are returned alongside the generic fields. + + Args: + filters (dict[str, Any], optional): Filters to apply to the query. + offset (int, optional): Pagination offset. + limit (int, optional): Pagination limit. + include (list[str], optional): Attributes or relationships to include. + exclude (list[str], optional): Attributes or relationships to exclude. + fragment (bool, optional): When ``True`` and the schema is a generic, emit + ``...on Kind`` fragments for each implementing kind. Defaults to ``False``. + prefetch_relationships (bool, optional): When ``True``, pre-fetch related node + data instead of returning only their identifiers. Defaults to ``False``. + partial_match (bool, optional): When ``True``, allow partial matches on filter + criteria. Defaults to ``False``. + property (bool, optional): When ``True``, include attribute and relationship + properties (``source``, ``owner``, ``is_protected``, ...). Defaults to ``False``. + order (Order, optional): Ordering options to apply to the query. + include_metadata (bool, optional): When ``True``, include ``node_metadata`` and + ``relationship_metadata`` in the result. Defaults to ``False``. + + Returns: + dict[str, Any | dict]: A query payload keyed by the node kind, ready to be + rendered as GraphQL. + """ data = self.generate_query_data_init( filters=filters, offset=offset, @@ -1842,6 +2294,15 @@ def add_relationships( relation_to_update: str, related_nodes: list[str], ) -> None: + """Add peers to a cardinality-many relationship through a dedicated mutation. + + Unlike :meth:`save`, this method targets a single relationship and only adds + peers, leaving every other field untouched. + + Args: + relation_to_update (str): The name of the relationship to update. + related_nodes (list[str]): The IDs of the peers to add. + """ query = self._relationship_mutation( action="Add", relation_to_update=relation_to_update, related_nodes=related_nodes ) @@ -1849,6 +2310,15 @@ def add_relationships( self._client.execute_graphql(query=query, branch_name=self._branch, tracker=tracker) def remove_relationships(self, relation_to_update: str, related_nodes: list[str]) -> None: + """Remove peers from a cardinality-many relationship through a dedicated mutation. + + Unlike :meth:`save`, this method targets a single relationship and only removes + the listed peers, leaving every other field untouched. + + Args: + relation_to_update (str): The name of the relationship to update. + related_nodes (list[str]): The IDs of the peers to remove. + """ query = self._relationship_mutation( action="Remove", relation_to_update=relation_to_update, related_nodes=related_nodes ) @@ -1902,6 +2372,26 @@ def _process_mutation_result( def create( self, allow_upsert: bool = False, timeout: int | None = None, request_context: RequestContext | None = None ) -> None: + """Create this node on the backend. + + For nodes inheriting from ``CoreFileObject``, the file content set with + :meth:`upload_from_path` or :meth:`upload_from_bytes` is uploaded as part of the + mutation and cleared from the node afterward. + + Prefer :meth:`save` over calling ``create()`` directly so existing-vs-new logic + is handled for you. + + Args: + allow_upsert (bool, optional): When ``True``, the operation upserts instead of + erroring on a duplicate. Defaults to ``False``. + timeout (int, optional): Overrides the default timeout used when querying the + GraphQL API. Specified in seconds. + request_context (RequestContext, optional): Request-level context passed through + to the mutation. When omitted, the client's request context is used. + + Raises: + ValueError: If this is a file-object node and no file content has been set. + """ if self._file_object_support and self._file_content is None: raise ValueError( f"Cannot create {self._schema.kind} without file content. Use upload_from_path() or upload_from_bytes() to provide " @@ -1956,6 +2446,23 @@ def create( def update( self, do_full_update: bool = False, timeout: int | None = None, request_context: RequestContext | None = None ) -> None: + """Update this node on the backend. + + By default only the modified attributes and relationships are sent so the server + can compute a minimal diff. Setting ``do_full_update`` re-sends every field even + when unchanged, which is useful when forcing relationship reconciliation. + + Prefer :meth:`save` over calling ``update()`` directly so existing-vs-new logic + is handled for you. + + Args: + do_full_update (bool, optional): When ``True``, send every field even when + unmodified. Defaults to ``False``. + timeout (int, optional): Overrides the default timeout used when querying the + GraphQL API. Specified in seconds. + request_context (RequestContext, optional): Request-level context passed through + to the mutation. When omitted, the client's request context is used. + """ input_data = self._generate_input_data(exclude_unmodified=not do_full_update, request_context=request_context) mutation_query = self._generate_mutation_query() mutation_name = f"{self._schema.kind}Update" @@ -2165,7 +2672,24 @@ def _get_relationship_one(self, name: str) -> RelatedNode | RelatedNodeSync: raise ResourceNotDefinedError(message=f"The node doesn't have a cardinality=one relationship for {name}") def get_flat_value(self, key: str, separator: str = "__") -> Any: - """Query recursively a value defined in a flat notation (string), on a hierarchy of objects + """Resolve a value addressed by a flat key over this node and its related nodes. + + Walks attributes on this node, descending through cardinality-one relationships + (which are fetched on demand) until the final component is reached. Each + relationship hop incurs a backend call, so this is intended for ad-hoc lookups + rather than bulk traversal. + + Args: + key (str): The flat key to resolve (for example ``"name__value"`` or + ``"site__name__value"``). + separator (str, optional): Component separator in ``key``. Defaults to ``"__"``. + + Returns: + Any: The resolved value. + + Raises: + ValueError: If a component does not match an attribute or relationship, or if + a relationship hop targets a non cardinality-one relationship. Examples: name__value @@ -2197,7 +2721,17 @@ def get_flat_value(self, key: str, separator: str = "__") -> Any: return related_node.peer.get_flat_value(key=remaining, separator=separator) def extract(self, params: dict[str, str]) -> dict[str, Any]: - """Extract some data points defined in a flat notation.""" + """Extract several values addressed by flat keys into a labeled dict. + + Each value in ``params`` is resolved with :meth:`get_flat_value`, and the + corresponding key is preserved as the output label. + + Args: + params (dict[str, str]): A mapping of output label to flat key to resolve. + + Returns: + dict[str, Any]: The resolved values keyed by their output label. + """ result: dict[str, Any] = {} for key, value in params.items(): result[key] = self.get_flat_value(key=value) diff --git a/infrahub_sdk/node/parsers.py b/infrahub_sdk/node/parsers.py index c5d2fbbd..4d5b6581 100644 --- a/infrahub_sdk/node/parsers.py +++ b/infrahub_sdk/node/parsers.py @@ -4,7 +4,22 @@ 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.""" + """Parse a human-friendly ID into a kind and an identifier. + + Accepts the HFID either as a separator-joined string (``"Kind__part1__part2"``) or + as a list of components. When a string is provided, the first component is treated as + the node kind only when more than one component is present. + + Args: + hfid (str | list[str]): The HFID to parse, either as a separator-joined string or as a list of components. + + Returns: + tuple[str | None, list[str]]: A tuple of ``(kind, identifier_components)``. ``kind`` is + ``None`` when no kind prefix is present (single-component string or list input). + + Raises: + ValueError: If ``hfid`` is neither a string nor a list. + """ if isinstance(hfid, str): hfid_parts = hfid.split(HFID_STR_SEPARATOR) if len(hfid_parts) == 1: diff --git a/infrahub_sdk/node/property.py b/infrahub_sdk/node/property.py index 652aa816..7965a72d 100644 --- a/infrahub_sdk/node/property.py +++ b/infrahub_sdk/node/property.py @@ -2,12 +2,24 @@ class NodeProperty: - """Represents a property of a node, typically used for metadata like display labels.""" + """Represents a property of a node, typically used for metadata like display labels. + + A ``NodeProperty`` is a lightweight pointer to another node, used to expose attribute and + relationship metadata such as ``source``, ``owner``, ``created_by``, or ``updated_by`` + without loading the full peer node. + + Attributes: + id (str | None): The identifier of the referenced node. + display_label (str | None): A human-readable label for the referenced node. + typename (str | None): The GraphQL ``__typename`` of the referenced node. + """ def __init__(self, data: dict | str) -> None: - """ + """Build a ``NodeProperty`` from raw GraphQL data. + Args: - data (Union[dict, str]): Data representing the node property. + data (dict | str): Either a node identifier as a string, or a dict with + ``id``, ``display_label``, and ``__typename`` keys. """ self.id = None self.display_label = None diff --git a/infrahub_sdk/node/related_node.py b/infrahub_sdk/node/related_node.py index 5b46a8f7..c246b73d 100644 --- a/infrahub_sdk/node/related_node.py +++ b/infrahub_sdk/node/related_node.py @@ -15,15 +15,30 @@ class RelatedNodeBase: - """Base class for representing a related node in a relationship.""" + """Base class for representing a related node in a relationship. + + A ``RelatedNodeBase`` is the peer end of a cardinality-one relationship. It carries + the lightweight identification of the peer (``id``, ``hfid``, ``typename``, ...) along + with the relationship-edge properties (``source``, ``owner``, ``is_protected``, ...). + The full peer node is fetched lazily through :meth:`RelatedNode.fetch` / + :meth:`RelatedNodeSync.fetch`. + + Attributes: + schema (RelationshipSchemaAPI): The schema describing the relationship. + name (str | None): The name of the relationship slot on the parent node. + updated_at (str | None): ISO-8601 timestamp of the most recent edge update. + """ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, name: str | None = None) -> None: - """ + """Build a ``RelatedNodeBase`` from raw data. + 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. + schema (RelationshipSchemaAPI): The schema of the relationship. + data (Any | dict): Data representing the related node. Accepts a peer + :class:`CoreNodeBase` instance, a list (treated as an HFID), a string + (treated as an ID), or a dict in either paginated or flat GraphQL format. + name (str, optional): The name of the relationship slot on the parent node. """ self.schema = schema self.name = name @@ -90,59 +105,117 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, @property def id(self) -> str | None: + """Return the unique identifier of the related node. + + Returns: + str | None: The peer node ID, or ``None`` when neither the peer nor an ID is set. + """ if self._peer: return self._peer.id return self._id @property def hfid(self) -> list[Any] | None: + """Return the human-friendly ID of the related node. + + Returns: + list[Any] | None: The peer HFID as a list of components, or ``None`` when not set. + """ if self._peer: return self._peer.hfid return self._hfid @property def hfid_str(self) -> str | None: + """Return the human-friendly ID of the related node as a separator-joined string. + + The returned string includes the kind prefix and is therefore suitable as a key + for the client store. + + Returns: + str | None: The peer HFID joined with the HFID separator, or ``None`` when + unavailable (no resolved peer or missing HFID). + """ 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: + """Return whether the related node is a resource pool. + + Returns: + bool: ``True`` when the resolved peer inherits from ``CoreResourcePool``. + """ if self._peer: return self._peer.is_resource_pool() return False @property def initialized(self) -> bool: + """Return whether this related node has an identifier. + + Returns: + bool: ``True`` when an ID or HFID is known and the relationship can be referenced. + """ return bool(self.id) or bool(self.hfid) @property def display_label(self) -> str | None: + """Return the human-readable label of the related node. + + Returns: + str | None: The peer display label, or ``None`` when not provided. + """ if self._peer: return self._peer.display_label return self._display_label @property def typename(self) -> str | None: + """Return the GraphQL ``__typename`` of the related node. + + Returns: + str | None: The peer typename, or ``None`` when not provided. + """ if self._peer: return self._peer.typename return self._typename @property def kind(self) -> str | None: + """Return the schema kind of the related node. + + Returns: + str | None: The peer schema kind, or ``None`` when not provided. + """ if self._peer: return self._peer.get_kind() return self._kind @property def is_from_profile(self) -> bool: - """Return whether this relationship was set from a profile. Done by checking if the source is of a profile kind.""" + """Return whether this relationship was set from a profile. + + A relationship is considered profile-sourced when the typename of its ``source`` + property starts with the profile kind prefix. + + Returns: + bool: ``True`` when the relationship's source is a profile node. + """ if not self._source_typename: return False return bool(re.match(rf"^{PROFILE_KIND_PREFIX}[A-Z]", self._source_typename)) def get_relationship_metadata(self) -> RelationshipMetadata | None: - """Returns the relationship metadata (updated_at, updated_by) if fetched.""" + """Return the relationship-edge metadata (``updated_at``, ``updated_by``). + + The metadata is populated only when the parent query was executed with + ``include_metadata=True``. + + Returns: + RelationshipMetadata | None: The edge metadata if fetched, otherwise ``None``. + """ return self._relationship_metadata def _generate_input_data(self, allocate_from_pool: bool = False) -> dict[str, Any]: @@ -211,7 +284,13 @@ def _generate_query_data( class RelatedNode(RelatedNodeBase): - """Represents a RelatedNodeBase in an asynchronous context.""" + """Asynchronous related node bound to an :class:`InfrahubClient`. + + Extends :class:`RelatedNodeBase` with the ability to lazily resolve the peer node: + :meth:`fetch` retrieves the full peer from the backend, :meth:`get` returns it from + the local cache or the client store, and :attr:`peer` is a convenience accessor + around :meth:`get`. + """ def __init__( self, @@ -233,6 +312,18 @@ def __init__( super().__init__(branch=branch, schema=schema, data=data, name=name) async def fetch(self, timeout: int | None = None) -> None: + """Fetch the full peer node from the backend and cache it on this object. + + After ``fetch()`` completes, attribute and relationship access on the peer is + available via :attr:`peer` or :meth:`get`. + + Args: + timeout (int, optional): Overrides the default timeout used when querying the + GraphQL API. Specified in seconds. + + Raises: + Error: If neither ``id`` nor ``typename`` is set on this related node. + """ if not self.id or not self.typename: raise Error("Unable to fetch the peer, id and/or typename are not defined") @@ -242,9 +333,31 @@ async def fetch(self, timeout: int | None = None) -> None: @property def peer(self) -> InfrahubNode: + """Return the resolved peer node. + + This is a convenience accessor for :meth:`get`; the peer must already have been + fetched or stored in the client store. + + Returns: + InfrahubNode: The resolved peer node. + """ return self.get() def get(self) -> InfrahubNode: + """Return the resolved peer node from cache or the client store. + + Lookup order: + + 1. The peer cached locally after a successful :meth:`fetch`. + 2. The client store keyed by ``id`` and ``typename``. + 3. The client store keyed by ``hfid_str``. + + Returns: + InfrahubNode: The resolved peer node. + + Raises: + ValueError: If neither an ID nor an HFID is available to look up the peer. + """ if self._peer: return self._peer # type: ignore[return-value] @@ -258,7 +371,13 @@ def get(self) -> InfrahubNode: class RelatedNodeSync(RelatedNodeBase): - """Represents a related node in a synchronous context.""" + """Synchronous related node bound to an :class:`InfrahubClientSync`. + + Synchronous counterpart of :class:`RelatedNode`. Extends :class:`RelatedNodeBase` + with the ability to lazily resolve the peer node: :meth:`fetch` retrieves the full + peer from the backend, :meth:`get` returns it from the local cache or the client + store, and :attr:`peer` is a convenience accessor around :meth:`get`. + """ def __init__( self, @@ -280,6 +399,18 @@ def __init__( super().__init__(branch=branch, schema=schema, data=data, name=name) def fetch(self, timeout: int | None = None) -> None: + """Fetch the full peer node from the backend and cache it on this object. + + After ``fetch()`` completes, attribute and relationship access on the peer is + available via :attr:`peer` or :meth:`get`. + + Args: + timeout (int, optional): Overrides the default timeout used when querying the + GraphQL API. Specified in seconds. + + Raises: + Error: If neither ``id`` nor ``typename`` is set on this related node. + """ if not self.id or not self.typename: raise Error("Unable to fetch the peer, id and/or typename are not defined") @@ -289,9 +420,31 @@ def fetch(self, timeout: int | None = None) -> None: @property def peer(self) -> InfrahubNodeSync: + """Return the resolved peer node. + + This is a convenience accessor for :meth:`get`; the peer must already have been + fetched or stored in the client store. + + Returns: + InfrahubNodeSync: The resolved peer node. + """ return self.get() def get(self) -> InfrahubNodeSync: + """Return the resolved peer node from cache or the client store. + + Lookup order: + + 1. The peer cached locally after a successful :meth:`fetch`. + 2. The client store keyed by ``id`` and ``typename``. + 3. The client store keyed by ``hfid_str``. + + Returns: + InfrahubNodeSync: The resolved peer node. + + Raises: + ValueError: If neither an ID nor an HFID is available to look up the peer. + """ if self._peer: return self._peer # type: ignore[return-value] diff --git a/infrahub_sdk/node/relationship.py b/infrahub_sdk/node/relationship.py index dcd33c9c..1c67e9bf 100644 --- a/infrahub_sdk/node/relationship.py +++ b/infrahub_sdk/node/relationship.py @@ -20,14 +20,28 @@ class RelationshipManagerBase: - """Base class for RelationshipManager and RelationshipManagerSync""" + """Base class for :class:`RelationshipManager` and :class:`RelationshipManagerSync`. + + A ``RelationshipManagerBase`` exposes a cardinality-many relationship as a list of + peers along with helpers to add, remove, or extend the set. Relationship managers are + initialized lazily: until :meth:`fetch` (on the async/sync subclasses) is called, the + members are not loaded and editing is not allowed. + + Attributes: + name (str): The name of the relationship slot on the parent node. + schema (RelationshipSchemaAPI): The schema describing the relationship. + branch (str): The branch the relationship is bound to. + peers (list[RelatedNode | RelatedNodeSync]): The current peer set. + initialized (bool): ``True`` once the manager has been populated with data. + """ def __init__(self, name: str, branch: str, schema: RelationshipSchemaAPI) -> None: - """ + """Build the base relationship manager state. + Args: name (str): The name of the relationship. branch (str): The branch where the relationship resides. - schema (RelationshipSchema): The schema of the relationship. + schema (RelationshipSchemaAPI): The schema of the relationship. """ self.initialized: bool = False self._has_update: bool = False @@ -43,23 +57,50 @@ def __init__(self, name: str, branch: str, schema: RelationshipSchemaAPI) -> Non @property def peer_ids(self) -> list[str]: + """Return the IDs of all peers that have one. + + Returns: + list[str]: The IDs of the peers, in insertion order. + """ return [peer.id for peer in self.peers if peer.id] @property def peer_hfids(self) -> list[list[Any]]: + """Return the HFIDs of all peers that have one. + + Returns: + list[list[Any]]: The HFIDs of the peers as lists of components, in insertion order. + """ return [peer.hfid for peer in self.peers if peer.hfid] @property def peer_hfids_str(self) -> list[str]: + """Return the HFIDs of all peers as separator-joined strings. + + Returns: + list[str]: The HFIDs of the peers as ``Kind__part1__part2`` strings. + """ return [peer.hfid_str for peer in self.peers if peer.hfid_str] @property def has_update(self) -> bool: + """Return whether the peer set has been modified since initialization. + + Returns: + bool: ``True`` after a successful :meth:`add`, :meth:`extend`, or :meth:`remove`. + """ return self._has_update @property def is_from_profile(self) -> bool: - """Return whether this relationship was set from a profile. All its peers must be from a profile.""" + """Return whether this relationship was set from a profile. + + The relationship is considered profile-sourced only when every peer is itself + sourced from a profile. + + Returns: + bool: ``True`` when at least one peer exists and all peers are from a profile. + """ if not self.peers: return False all_profiles = [p.is_from_profile for p in self.peers] @@ -113,7 +154,14 @@ def _generate_query_data( class RelationshipManager(RelationshipManagerBase): - """Manages relationships of a node in an asynchronous context.""" + """Asynchronous manager for a cardinality-many relationship. + + Extends :class:`RelationshipManagerBase` with the ability to populate and edit the + peer set against an :class:`InfrahubClient`: :meth:`fetch` resolves every peer in a + parallel batch and :meth:`add`, :meth:`extend`, and :meth:`remove` mutate the peer + list in memory. Peers are exposed as :class:`RelatedNode` instances and can be + accessed by index via ``manager[i]``. + """ def __init__( self, @@ -161,6 +209,15 @@ def __getitem__(self, item: int) -> RelatedNode: return self.peers[item] # type: ignore[return-value] async def fetch(self) -> None: + """Populate the peer set and resolve every peer to a full node. + + When the manager is not yet initialized, the parent node is re-queried with this + relationship included so the peer list can be populated. The peers are then + fetched in a parallel batch grouped by kind and stored in the client store. + + Raises: + Error: If any peer is missing an ``id`` or ``typename`` and cannot be resolved. + """ if not self.initialized: exclude = self.node._schema.relationship_names + self.node._schema.attribute_names exclude.remove(self.schema.name) @@ -197,7 +254,19 @@ async def fetch(self) -> None: pass def add(self, data: str | RelatedNode | dict) -> None: - """Add a new peer to this relationship.""" + """Add a new peer to this relationship. + + The new peer is only added when its ID or HFID is not already present; duplicate + adds are silently ignored. + + Args: + data (str | RelatedNode | dict): The peer to add. Accepts an ID string, an + existing :class:`RelatedNode`, or a dict describing the peer (with ``id`` + or ``hfid`` keys, plus optional relationship properties). + + Raises: + UninitializedError: If :meth:`fetch` has not been called on this manager yet. + """ 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) @@ -209,11 +278,35 @@ def add(self, data: str | RelatedNode | dict) -> None: self._has_update = True def extend(self, data: Iterable[str | RelatedNode | dict]) -> None: - """Add new peers to this relationship.""" + """Add new peers to this relationship. + + This is a convenience wrapper that calls :meth:`add` for every item in ``data``. + Items already present (by ID or HFID) are silently ignored. + + Args: + data (Iterable[str | RelatedNode | dict]): The peers to add, in any of the + formats accepted by :meth:`add`. + + Raises: + UninitializedError: If :meth:`fetch` has not been called on this manager yet. + """ for d in data: self.add(d) def remove(self, data: str | RelatedNode | dict) -> None: + """Remove a peer from this relationship. + + The peer to remove is matched first by ID, then by HFID. When no match is found, + the call is a no-op. + + Args: + data (str | RelatedNode | dict): The peer to remove. Accepts an ID string, an + existing :class:`RelatedNode`, or a dict describing the peer. + + Raises: + UninitializedError: If :meth:`fetch` has not been called on this manager yet. + IndexError: If the internal peer index is inconsistent with the lookup result. + """ 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) @@ -236,7 +329,15 @@ def remove(self, data: str | RelatedNode | dict) -> None: class RelationshipManagerSync(RelationshipManagerBase): - """Manages relationships of a node in a synchronous context.""" + """Synchronous manager for a cardinality-many relationship. + + Synchronous counterpart of :class:`RelationshipManager`. Extends + :class:`RelationshipManagerBase` with the ability to populate and edit the peer set + against an :class:`InfrahubClientSync`: :meth:`fetch` resolves every peer in a + parallel batch and :meth:`add`, :meth:`extend`, and :meth:`remove` mutate the peer + list in memory. Peers are exposed as :class:`RelatedNodeSync` instances and can be + accessed by index via ``manager[i]``. + """ def __init__( self, @@ -284,6 +385,15 @@ def __getitem__(self, item: int) -> RelatedNodeSync: return self.peers[item] # type: ignore[return-value] def fetch(self) -> None: + """Populate the peer set and resolve every peer to a full node. + + When the manager is not yet initialized, the parent node is re-queried with this + relationship included so the peer list can be populated. The peers are then + fetched in a parallel batch grouped by kind and stored in the client store. + + Raises: + Error: If any peer is missing an ``id`` or ``typename`` and cannot be resolved. + """ if not self.initialized: exclude = self.node._schema.relationship_names + self.node._schema.attribute_names exclude.remove(self.schema.name) @@ -320,7 +430,19 @@ def fetch(self) -> None: pass def add(self, data: str | RelatedNodeSync | dict) -> None: - """Add a new peer to this relationship.""" + """Add a new peer to this relationship. + + The new peer is only added when its ID or HFID is not already present; duplicate + adds are silently ignored. + + Args: + data (str | RelatedNodeSync | dict): The peer to add. Accepts an ID string, + an existing :class:`RelatedNodeSync`, or a dict describing the peer (with + ``id`` or ``hfid`` keys, plus optional relationship properties). + + Raises: + UninitializedError: If :meth:`fetch` has not been called on this manager yet. + """ 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) @@ -332,11 +454,35 @@ def add(self, data: str | RelatedNodeSync | dict) -> None: self._has_update = True def extend(self, data: Iterable[str | RelatedNodeSync | dict]) -> None: - """Add new peers to this relationship.""" + """Add new peers to this relationship. + + This is a convenience wrapper that calls :meth:`add` for every item in ``data``. + Items already present (by ID or HFID) are silently ignored. + + Args: + data (Iterable[str | RelatedNodeSync | dict]): The peers to add, in any of the + formats accepted by :meth:`add`. + + Raises: + UninitializedError: If :meth:`fetch` has not been called on this manager yet. + """ for d in data: self.add(d) def remove(self, data: str | RelatedNodeSync | dict) -> None: + """Remove a peer from this relationship. + + The peer to remove is matched first by ID, then by HFID. When no match is found, + the call is a no-op. + + Args: + data (str | RelatedNodeSync | dict): The peer to remove. Accepts an ID string, + an existing :class:`RelatedNodeSync`, or a dict describing the peer. + + Raises: + UninitializedError: If :meth:`fetch` has not been called on this manager yet. + IndexError: If the internal peer index is inconsistent with the lookup result. + """ 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) From d284360acd5923e0aa6e8f70fde0775528ae0348 Mon Sep 17 00:00:00 2001 From: Pol Michel Date: Tue, 28 Apr 2026 09:05:39 +0200 Subject: [PATCH 2/2] run docs generate task to create related docusaurus documentation pages --- .../sdk_ref/infrahub_sdk/node/attribute.mdx | 20 + .../sdk_ref/infrahub_sdk/node/metadata.mdx | 20 + .../sdk_ref/infrahub_sdk/node/node.mdx | 613 +++++++++++++++++- .../sdk_ref/infrahub_sdk/node/parsers.mdx | 17 + .../sdk_ref/infrahub_sdk/node/property.mdx | 10 + .../infrahub_sdk/node/related_node.mdx | 174 ++++- .../infrahub_sdk/node/relationship.mdx | 165 ++++- 7 files changed, 1002 insertions(+), 17 deletions(-) diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/attribute.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/attribute.mdx index d08c7fc5..def87151 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/attribute.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/attribute.mdx @@ -11,6 +11,26 @@ sidebarTitle: attribute Represents an attribute of a Node, including its schema, value, and properties. +An ``Attribute`` wraps a single attribute on an :class:`InfrahubNode`. It tracks the +current value, the metadata properties (``source``, ``owner``, ``is_protected``, ...), +and whether the value has been mutated since the node was loaded. Mutation tracking is +used by ``InfrahubNode.update()`` to send only the changed fields to the API. + +**Attributes:** + +- `name`: The name of the attribute. +- `id`: The unique identifier of the attribute, when known. +- `value`: The current attribute value. Setting this marks the attribute as mutated. +- `value_has_been_mutated`: True when ``value`` has been assigned after construction. +- `is_default`: True when the value comes from the schema default. +- `is_from_profile`: True when the value is inherited from a profile. +- `is_inherited`: True when the attribute is inherited from a generic. +- `is_protected`: True when the attribute is protected from modification. +- `updated_at`: ISO-8601 timestamp of the most recent update. +- `source`: The node that supplied this attribute value. +- `owner`: The node that owns this attribute. +- `updated_by`: The account that performed the most recent update. + **Methods:** #### `value` diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/metadata.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/metadata.mdx index 6175236f..cb949d6b 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/metadata.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/metadata.mdx @@ -11,6 +11,26 @@ sidebarTitle: metadata Represents metadata about a node (created_at, created_by, updated_at, updated_by). +Populated from the ``node_metadata`` GraphQL block when ``include_metadata=True`` +is passed to a query. The ``*_by`` fields point to the user who created or last +updated the node, exposed as :class:`NodeProperty` references. + +**Attributes:** + +- `created_at`: ISO-8601 timestamp of node creation. +- `created_by`: The account that created the node. +- `updated_at`: ISO-8601 timestamp of the most recent update. +- `updated_by`: The account that performed the most recent update. + ### `RelationshipMetadata` Represents metadata about a relationship edge (updated_at, updated_by). + +Populated from the ``relationship_metadata`` GraphQL block when ``include_metadata=True`` +is passed to a query. Unlike :class:`NodeMetadata`, this only carries update info because +the creation timestamp of an edge is not tracked separately from its peer node. + +**Attributes:** + +- `updated_at`: ISO-8601 timestamp of the most recent edge update. +- `updated_by`: The account that performed the most recent edge update. diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/node.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/node.mdx index e23120dd..38dfc513 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/node.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/node.mdx @@ -9,7 +9,16 @@ sidebarTitle: node ### `InfrahubNode` -Represents a Infrahub node in an asynchronous context. +Asynchronous Infrahub node bound to an :class:`InfrahubClient`. + +Provides full CRUD against the backend (:meth:`save`, :meth:`create`, :meth:`update`, +:meth:`delete`) along with relationship traversal (:meth:`get_flat_value`, +:meth:`extract`), feature-gated artifact and resource-pool helpers +(:meth:`artifact_generate`, :meth:`get_pool_allocated_resources`), and file upload +or download for nodes inheriting from ``CoreFileObject``. + +Attributes and relationships defined on the schema are exposed as instance +attributes via attribute-style access (``node.name.value``, ``node.parent``). **Methods:** @@ -19,24 +28,89 @@ Represents a Infrahub node in an asynchronous context. from_graphql(cls, client: InfrahubClient, branch: str, data: dict, schema: MainSchemaTypesAPI | None = None, timeout: int | None = None) -> Self ``` +Build an :class:`InfrahubNode` from a raw GraphQL response. + +When no ``schema`` is provided, the node kind is read from ``__typename`` in the +payload and the schema is fetched from the client. + +**Args:** + +- `client`: The client used to interact with the backend. +- `branch`: The branch the node belongs to. +- `data`: The GraphQL payload describing the node. +- `schema`: Pre-fetched schema for the node kind. +Skips the schema lookup when provided. +- `timeout`: Overrides the default timeout used when fetching the +schema. Specified in seconds. + +**Returns:** + +- The hydrated node instance. + +**Raises:** + +- `ValueError`: If ``__typename`` is missing from ``data`` and no ``schema`` was provided. + #### `generate` ```python generate(self, nodes: list[str] | None = None) -> None ``` +Trigger artifact generation for this artifact definition. + +Only available on nodes whose kind is ``CoreArtifactDefinition``. + +**Args:** + +- `nodes`: The IDs of target nodes to generate artifacts +for. When omitted, generation runs for all targets matched by the +definition. + +**Raises:** + +- `FeatureNotSupportedError`: If this node is not a ``CoreArtifactDefinition``. + #### `artifact_generate` ```python artifact_generate(self, name: str) -> None ``` +Regenerate a named artifact targeting this node. + +Looks up the ``CoreArtifact`` named ``name`` for this node, then calls +:meth:`generate` on the related definition with this artifact's ID. + +**Args:** + +- `name`: The name of the artifact to regenerate. + +**Raises:** + +- `FeatureNotSupportedError`: If this node does not inherit from ``CoreArtifactTarget``. + #### `artifact_fetch` ```python artifact_fetch(self, name: str) -> str | dict[str, Any] ``` +Fetch the stored content of a named artifact for this node. + +**Args:** + +- `name`: The name of the artifact to fetch. + +**Returns:** + +- str | dict\[str, Any]: The artifact content. Returns a parsed object for +- JSON-typed artifacts and a string for text-typed artifacts. + +**Raises:** + +- `FeatureNotSupportedError`: If this node does not inherit from ``CoreArtifactTarget``. + #### `download_file` ```python @@ -81,18 +155,77 @@ The node must have been saved (have an id) before calling this method. delete(self, timeout: int | None = None, request_context: RequestContext | None = None) -> None ``` +Delete this node on the backend. + +**Args:** + +- `timeout`: Overrides the default timeout used when querying the +GraphQL API. Specified in seconds. +- `request_context`: Request-level context passed through +to the mutation. When omitted, the client's request context is used. + #### `save` ```python save(self, allow_upsert: bool = False, update_group_context: bool | None = None, timeout: int | None = None, request_context: RequestContext | None = None) -> None ``` +Persist this node to the backend, creating or updating it as appropriate. + +New nodes are created (or upserted when ``allow_upsert`` is set), and existing +nodes are updated with only the modified fields. After a successful save, the +node is added to the client store and, when applicable, to the active group +context for tracking. + +**Args:** + +- `allow_upsert`: When ``True``, an existing node is upserted +instead of failing with a duplicate. Defaults to ``False``. +- `update_group_context`: Whether to update the group context +with this node. When ``None`` and the client is in tracking mode, defaults +to ``True``. +- `timeout`: Overrides the default timeout used when querying the +GraphQL API. Specified in seconds. +- `request_context`: Request-level context passed through +to the mutation. When omitted, the client's request context is used. + #### `generate_query_data` ```python generate_query_data(self, filters: dict[str, Any] | None = None, offset: int | None = None, limit: int | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, prefetch_relationships: bool = False, partial_match: bool = False, property: bool = False, order: Order | None = None, include_metadata: bool = False) -> dict[str, Any | dict] ``` +Generate the full GraphQL query payload for this node kind. + +The returned dict combines :meth:`generate_query_data_init` with +:meth:`generate_query_data_node`. When the node is a generic and ``fragment`` is +``True``, ``...on Kind`` fragments are added for every implementing kind so the +relevant attributes are returned alongside the generic fields. + +**Args:** + +- `filters`: Filters to apply to the query. +- `offset`: Pagination offset. +- `limit`: Pagination limit. +- `include`: Attributes or relationships to include. +- `exclude`: Attributes or relationships to exclude. +- `fragment`: When ``True`` and the schema is a generic, emit +``...on Kind`` fragments for each implementing kind. Defaults to ``False``. +- `prefetch_relationships`: When ``True``, pre-fetch related node +data instead of returning only their identifiers. Defaults to ``False``. +- `partial_match`: When ``True``, allow partial matches on filter +criteria. Defaults to ``False``. +- `property`: When ``True``, include attribute and relationship +properties (``source``, ``owner``, ``is_protected``, ...). Defaults to ``False``. +- `order`: Ordering options to apply to the query. +- `include_metadata`: When ``True``, include ``node_metadata`` and +``relationship_metadata`` in the result. Defaults to ``False``. + +**Returns:** + +- dict\[str, Any | dict]: A query payload keyed by the node kind, ready to be +- rendered as GraphQL. + #### `generate_query_data_node` ```python @@ -121,24 +254,84 @@ Generate the node part of a GraphQL Query with attributes and nodes. add_relationships(self, relation_to_update: str, related_nodes: list[str]) -> None ``` +Add peers to a cardinality-many relationship through a dedicated mutation. + +Unlike :meth:`save`, this method targets a single relationship and only adds +peers, leaving every other field untouched. + +**Args:** + +- `relation_to_update`: The name of the relationship to update. +- `related_nodes`: The IDs of the peers to add. + #### `remove_relationships` ```python remove_relationships(self, relation_to_update: str, related_nodes: list[str]) -> None ``` +Remove peers from a cardinality-many relationship through a dedicated mutation. + +Unlike :meth:`save`, this method targets a single relationship and only removes +the listed peers, leaving every other field untouched. + +**Args:** + +- `relation_to_update`: The name of the relationship to update. +- `related_nodes`: The IDs of the peers to remove. + #### `create` ```python create(self, allow_upsert: bool = False, timeout: int | None = None, request_context: RequestContext | None = None) -> None ``` +Create this node on the backend. + +For nodes inheriting from ``CoreFileObject``, the file content set with +:meth:`upload_from_path` or :meth:`upload_from_bytes` is uploaded as part of the +mutation and cleared from the node afterward. + +Prefer :meth:`save` over calling ``create()`` directly so existing-vs-new logic +is handled for you. + +**Args:** + +- `allow_upsert`: When ``True``, the operation upserts instead of +erroring on a duplicate. Defaults to ``False``. +- `timeout`: Overrides the default timeout used when querying the +GraphQL API. Specified in seconds. +- `request_context`: Request-level context passed through +to the mutation. When omitted, the client's request context is used. + +**Raises:** + +- `ValueError`: If this is a file-object node and no file content has been set. + #### `update` ```python update(self, do_full_update: bool = False, timeout: int | None = None, request_context: RequestContext | None = None) -> None ``` +Update this node on the backend. + +By default only the modified attributes and relationships are sent so the server +can compute a minimal diff. Setting ``do_full_update`` re-sends every field even +when unchanged, which is useful when forcing relationship reconciliation. + +Prefer :meth:`save` over calling ``update()`` directly so existing-vs-new logic +is handled for you. + +**Args:** + +- `do_full_update`: When ``True``, send every field even when +unmodified. Defaults to ``False``. +- `timeout`: Overrides the default timeout used when querying the +GraphQL API. Specified in seconds. +- `request_context`: Request-level context passed through +to the mutation. When omitted, the client's request context is used. + #### `get_pool_allocated_resources` ```python @@ -173,7 +366,27 @@ Fetch the utilization of each resource for the pool. get_flat_value(self, key: str, separator: str = '__') -> Any ``` -Query recursively a value defined in a flat notation (string), on a hierarchy of objects +Resolve a value addressed by a flat key over this node and its related nodes. + +Walks attributes on this node, descending through cardinality-one relationships +(which are fetched on demand) until the final component is reached. Each +relationship hop incurs a backend call, so this is intended for ad-hoc lookups +rather than bulk traversal. + +**Args:** + +- `key`: The flat key to resolve (for example ``"name__value"`` or +``"site__name__value"``). +- `separator`: Component separator in ``key``. Defaults to ``"__"``. + +**Returns:** + +- The resolved value. + +**Raises:** + +- `ValueError`: If a component does not match an attribute or relationship, or if +a relationship hop targets a non cardinality-one relationship. **Examples:** @@ -186,11 +399,32 @@ module.object.value extract(self, params: dict[str, str]) -> dict[str, Any] ``` -Extract some data points defined in a flat notation. +Extract several values addressed by flat keys into a labeled dict. + +Each value in ``params`` is resolved with :meth:`get_flat_value`, and the +corresponding key is preserved as the output label. + +**Args:** + +- `params`: A mapping of output label to flat key to resolve. + +**Returns:** + +- dict\[str, Any]: The resolved values keyed by their output label. ### `InfrahubNodeSync` -Represents a Infrahub node in a synchronous context. +Synchronous Infrahub node bound to an :class:`InfrahubClientSync`. + +Synchronous counterpart of :class:`InfrahubNode`. Provides full CRUD against the +backend (:meth:`save`, :meth:`create`, :meth:`update`, :meth:`delete`) along with +relationship traversal (:meth:`get_flat_value`, :meth:`extract`), feature-gated +artifact and resource-pool helpers (:meth:`artifact_generate`, +:meth:`get_pool_allocated_resources`), and file upload or download for nodes +inheriting from ``CoreFileObject``. + +Attributes and relationships defined on the schema are exposed as instance +attributes via attribute-style access (``node.name.value``, ``node.parent``). **Methods:** @@ -200,24 +434,89 @@ Represents a Infrahub node in a synchronous context. from_graphql(cls, client: InfrahubClientSync, branch: str, data: dict, schema: MainSchemaTypesAPI | None = None, timeout: int | None = None) -> Self ``` +Build an :class:`InfrahubNodeSync` from a raw GraphQL response. + +When no ``schema`` is provided, the node kind is read from ``__typename`` in the +payload and the schema is fetched from the client. + +**Args:** + +- `client`: The client used to interact with the backend. +- `branch`: The branch the node belongs to. +- `data`: The GraphQL payload describing the node. +- `schema`: Pre-fetched schema for the node kind. +Skips the schema lookup when provided. +- `timeout`: Overrides the default timeout used when fetching the +schema. Specified in seconds. + +**Returns:** + +- The hydrated node instance. + +**Raises:** + +- `ValueError`: If ``__typename`` is missing from ``data`` and no ``schema`` was provided. + #### `generate` ```python generate(self, nodes: list[str] | None = None) -> None ``` +Trigger artifact generation for this artifact definition. + +Only available on nodes whose kind is ``CoreArtifactDefinition``. + +**Args:** + +- `nodes`: The IDs of target nodes to generate artifacts +for. When omitted, generation runs for all targets matched by the +definition. + +**Raises:** + +- `FeatureNotSupportedError`: If this node is not a ``CoreArtifactDefinition``. + #### `artifact_generate` ```python artifact_generate(self, name: str) -> None ``` +Regenerate a named artifact targeting this node. + +Looks up the ``CoreArtifact`` named ``name`` for this node, then calls +:meth:`generate` on the related definition with this artifact's ID. + +**Args:** + +- `name`: The name of the artifact to regenerate. + +**Raises:** + +- `FeatureNotSupportedError`: If this node does not inherit from ``CoreArtifactTarget``. + #### `artifact_fetch` ```python artifact_fetch(self, name: str) -> str | dict[str, Any] ``` +Fetch the stored content of a named artifact for this node. + +**Args:** + +- `name`: The name of the artifact to fetch. + +**Returns:** + +- str | dict\[str, Any]: The artifact content. Returns a parsed object for +- JSON-typed artifacts and a string for text-typed artifacts. + +**Raises:** + +- `FeatureNotSupportedError`: If this node does not inherit from ``CoreArtifactTarget``. + #### `download_file` ```python @@ -262,18 +561,77 @@ The node must have been saved (have an id) before calling this method. delete(self, timeout: int | None = None, request_context: RequestContext | None = None) -> None ``` +Delete this node on the backend. + +**Args:** + +- `timeout`: Overrides the default timeout used when querying the +GraphQL API. Specified in seconds. +- `request_context`: Request-level context passed through +to the mutation. When omitted, the client's request context is used. + #### `save` ```python save(self, allow_upsert: bool = False, update_group_context: bool | None = None, timeout: int | None = None, request_context: RequestContext | None = None) -> None ``` +Persist this node to the backend, creating or updating it as appropriate. + +New nodes are created (or upserted when ``allow_upsert`` is set), and existing +nodes are updated with only the modified fields. After a successful save, the +node is added to the client store and, when applicable, to the active group +context for tracking. + +**Args:** + +- `allow_upsert`: When ``True``, an existing node is upserted +instead of failing with a duplicate. Defaults to ``False``. +- `update_group_context`: Whether to update the group context +with this node. When ``None`` and the client is in tracking mode, defaults +to ``True``. +- `timeout`: Overrides the default timeout used when querying the +GraphQL API. Specified in seconds. +- `request_context`: Request-level context passed through +to the mutation. When omitted, the client's request context is used. + #### `generate_query_data` ```python generate_query_data(self, filters: dict[str, Any] | None = None, offset: int | None = None, limit: int | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, prefetch_relationships: bool = False, partial_match: bool = False, property: bool = False, order: Order | None = None, include_metadata: bool = False) -> dict[str, Any | dict] ``` +Generate the full GraphQL query payload for this node kind. + +The returned dict combines :meth:`generate_query_data_init` with +:meth:`generate_query_data_node`. When the node is a generic and ``fragment`` is +``True``, ``...on Kind`` fragments are added for every implementing kind so the +relevant attributes are returned alongside the generic fields. + +**Args:** + +- `filters`: Filters to apply to the query. +- `offset`: Pagination offset. +- `limit`: Pagination limit. +- `include`: Attributes or relationships to include. +- `exclude`: Attributes or relationships to exclude. +- `fragment`: When ``True`` and the schema is a generic, emit +``...on Kind`` fragments for each implementing kind. Defaults to ``False``. +- `prefetch_relationships`: When ``True``, pre-fetch related node +data instead of returning only their identifiers. Defaults to ``False``. +- `partial_match`: When ``True``, allow partial matches on filter +criteria. Defaults to ``False``. +- `property`: When ``True``, include attribute and relationship +properties (``source``, ``owner``, ``is_protected``, ...). Defaults to ``False``. +- `order`: Ordering options to apply to the query. +- `include_metadata`: When ``True``, include ``node_metadata`` and +``relationship_metadata`` in the result. Defaults to ``False``. + +**Returns:** + +- dict\[str, Any | dict]: A query payload keyed by the node kind, ready to be +- rendered as GraphQL. + #### `generate_query_data_node` ```python @@ -302,24 +660,84 @@ Generate the node part of a GraphQL Query with attributes and nodes. add_relationships(self, relation_to_update: str, related_nodes: list[str]) -> None ``` +Add peers to a cardinality-many relationship through a dedicated mutation. + +Unlike :meth:`save`, this method targets a single relationship and only adds +peers, leaving every other field untouched. + +**Args:** + +- `relation_to_update`: The name of the relationship to update. +- `related_nodes`: The IDs of the peers to add. + #### `remove_relationships` ```python remove_relationships(self, relation_to_update: str, related_nodes: list[str]) -> None ``` +Remove peers from a cardinality-many relationship through a dedicated mutation. + +Unlike :meth:`save`, this method targets a single relationship and only removes +the listed peers, leaving every other field untouched. + +**Args:** + +- `relation_to_update`: The name of the relationship to update. +- `related_nodes`: The IDs of the peers to remove. + #### `create` ```python create(self, allow_upsert: bool = False, timeout: int | None = None, request_context: RequestContext | None = None) -> None ``` +Create this node on the backend. + +For nodes inheriting from ``CoreFileObject``, the file content set with +:meth:`upload_from_path` or :meth:`upload_from_bytes` is uploaded as part of the +mutation and cleared from the node afterward. + +Prefer :meth:`save` over calling ``create()`` directly so existing-vs-new logic +is handled for you. + +**Args:** + +- `allow_upsert`: When ``True``, the operation upserts instead of +erroring on a duplicate. Defaults to ``False``. +- `timeout`: Overrides the default timeout used when querying the +GraphQL API. Specified in seconds. +- `request_context`: Request-level context passed through +to the mutation. When omitted, the client's request context is used. + +**Raises:** + +- `ValueError`: If this is a file-object node and no file content has been set. + #### `update` ```python update(self, do_full_update: bool = False, timeout: int | None = None, request_context: RequestContext | None = None) -> None ``` +Update this node on the backend. + +By default only the modified attributes and relationships are sent so the server +can compute a minimal diff. Setting ``do_full_update`` re-sends every field even +when unchanged, which is useful when forcing relationship reconciliation. + +Prefer :meth:`save` over calling ``update()`` directly so existing-vs-new logic +is handled for you. + +**Args:** + +- `do_full_update`: When ``True``, send every field even when +unmodified. Defaults to ``False``. +- `timeout`: Overrides the default timeout used when querying the +GraphQL API. Specified in seconds. +- `request_context`: Request-level context passed through +to the mutation. When omitted, the client's request context is used. + #### `get_pool_allocated_resources` ```python @@ -354,7 +772,27 @@ Fetch the utilization of each resource for the pool. get_flat_value(self, key: str, separator: str = '__') -> Any ``` -Query recursively a value defined in a flat notation (string), on a hierarchy of objects +Resolve a value addressed by a flat key over this node and its related nodes. + +Walks attributes on this node, descending through cardinality-one relationships +(which are fetched on demand) until the final component is reached. Each +relationship hop incurs a backend call, so this is intended for ad-hoc lookups +rather than bulk traversal. + +**Args:** + +- `key`: The flat key to resolve (for example ``"name__value"`` or +``"site__name__value"``). +- `separator`: Component separator in ``key``. Defaults to ``"__"``. + +**Returns:** + +- The resolved value. + +**Raises:** + +- `ValueError`: If a component does not match an attribute or relationship, or if +a relationship hop targets a non cardinality-one relationship. **Examples:** @@ -367,11 +805,34 @@ module.object.value extract(self, params: dict[str, str]) -> dict[str, Any] ``` -Extract some data points defined in a flat notation. +Extract several values addressed by flat keys into a labeled dict. + +Each value in ``params`` is resolved with :meth:`get_flat_value`, and the +corresponding key is preserved as the output label. + +**Args:** + +- `params`: A mapping of output label to flat key to resolve. + +**Returns:** + +- dict\[str, Any]: The resolved values keyed by their output label. ### `InfrahubNodeBase` -Base class for InfrahubNode and InfrahubNodeSync +Base class for :class:`InfrahubNode` and :class:`InfrahubNodeSync`. + +Owns the schema-driven state shared between the async and sync clients: attributes, +relationships, identity (``id``, ``hfid``), node metadata, and the helpers that turn +the in-memory state into GraphQL query and mutation payloads. This class is not +meant to be instantiated directly; use :class:`InfrahubNode` or +:class:`InfrahubNodeSync` instead. + +**Attributes:** + +- `id`: The unique identifier of the node, when known. +- `display_label`: Human-readable label of the node. +- `typename`: The GraphQL ``__typename`` of the node. **Methods:** @@ -381,43 +842,107 @@ Base class for InfrahubNode and InfrahubNodeSync get_branch(self) -> str ``` +Return the branch this node is bound to. + +**Returns:** + +- The name of the branch. + #### `get_path_value` ```python get_path_value(self, path: str) -> Any ``` +Resolve a value addressed by a dunder-separated path on this node. + +The path can target an attribute (``name__value``, ``name__source``), a +cardinality-one related node (``parent``), an attribute of that related node +(``parent__name__value``), or a property of one of its attributes +(``parent__name__source``). + +**Args:** + +- `path`: A path with components separated by ``__``. + +**Returns:** + +- The resolved value, or ``None`` when any path component cannot be +- resolved (for example, an unfetched related node not present in the store). + #### `get_human_friendly_id` ```python get_human_friendly_id(self) -> list[str] | None ``` +Compute the human-friendly ID for this node from its schema. + +The HFID is composed of the values addressed by the schema's +``human_friendly_id`` paths. When any component cannot be resolved, the HFID is +considered invalid and ``None`` is returned. + +**Returns:** + +- list[str] | None: The HFID as a list of stringified components, or ``None`` +- when the schema does not define an HFID or a component is missing. + #### `get_human_friendly_id_as_string` ```python get_human_friendly_id_as_string(self, include_kind: bool = False) -> str | None ``` +Return the human-friendly ID joined into a single string. + +**Args:** + +- `include_kind`: When ``True``, the node kind is prepended as +the first component of the resulting string. Defaults to ``False``. + +**Returns:** + +- str | None: The HFID joined with the HFID separator, or ``None`` when no +- HFID is available. + #### `hfid` ```python hfid(self) -> list[str] | None ``` +Return the human-friendly ID of this node as a list of components. + +**Returns:** + +- list\[str] | None: The HFID components, or ``None`` when unavailable. + #### `hfid_str` ```python hfid_str(self) -> str | None ``` +Return the human-friendly ID of this node as a string, including the kind prefix. + +**Returns:** + +- str | None: The HFID as ``Kind__part1__part2``, or ``None`` when unavailable. + #### `get_node_metadata` ```python get_node_metadata(self) -> NodeMetadata | None ``` -Returns the node metadata (created_at, created_by, updated_at, updated_by) if fetched. +Return the node metadata (``created_at``, ``created_by``, ``updated_at``, ``updated_by``). + +The metadata is populated only when the parent query was executed with +``include_metadata=True``. + +**Returns:** + +- NodeMetadata | None: The node metadata if fetched, otherwise ``None``. #### `get_kind` @@ -425,37 +950,72 @@ Returns the node metadata (created_at, created_by, updated_at, updated_by) if fe get_kind(self) -> str ``` +Return the schema kind of this node. + +**Returns:** + +- The schema kind (for example ``"CoreAccount"``). + #### `get_all_kinds` ```python get_all_kinds(self) -> list[str] ``` +Return this node's kind plus all generic kinds it inherits from. + +**Returns:** + +- list\[str]: The node's own kind followed by the inherited kinds, in the order +- declared on the schema. + #### `is_ip_prefix` ```python is_ip_prefix(self) -> bool ``` +Return whether this node represents an IP prefix. + +**Returns:** + +- ``True`` when the node kind is ``BuiltinIPPrefix`` or inherits from it. + #### `is_ip_address` ```python is_ip_address(self) -> bool ``` +Return whether this node represents an IP address. + +**Returns:** + +- ``True`` when the node kind is ``BuiltinIPAddress`` or inherits from it. + #### `is_resource_pool` ```python is_resource_pool(self) -> bool ``` +Return whether this node is a resource pool. + +**Returns:** + +- ``True`` when the node inherits from ``CoreResourcePool``. + #### `is_file_object` ```python is_file_object(self) -> bool ``` -Check if this node inherits from CoreFileObject and supports file uploads. +Return whether this node inherits from ``CoreFileObject`` and supports file uploads. + +**Returns:** + +- ``True`` when file upload/download operations are supported on this node. #### `upload_from_path` @@ -519,8 +1079,43 @@ Clear any pending file content. get_raw_graphql_data(self) -> dict | None ``` +Return the raw GraphQL payload used to build this node. + +**Returns:** + +- dict | None: The original GraphQL data, or ``None`` when the node was +- constructed without payload (for example, a brand-new node). + #### `generate_query_data_init` ```python generate_query_data_init(self, filters: dict[str, Any] | None = None, offset: int | None = None, limit: int | None = None, include: list[str] | None = None, exclude: list[str] | None = None, partial_match: bool = False, order: Order | None = None, include_metadata: bool = False) -> dict[str, Any | dict] ``` + +Build the top-level ``count``/``edges`` skeleton of a GraphQL query for this kind. + +The returned dict is the outer structure consumed by +:meth:`generate_query_data`; it carries the ``@filters`` block and the empty +``edges.node`` placeholder that will later be filled by the caller. + +**Args:** + +- `filters`: Filters to apply to the query. +- `offset`: Pagination offset. +- `limit`: Pagination limit. +- `include`: Attributes or relationships to include. +- `exclude`: Attributes or relationships to exclude. +- `partial_match`: When ``True``, allow partial matches on filter +criteria. Defaults to ``False``. +- `order`: Ordering options to apply to the query. +- `include_metadata`: When ``True``, include ``node_metadata`` in +the result. Defaults to ``False``. + +**Returns:** + +- dict[str, Any | dict]: The query skeleton ready to be combined with node-level +- attributes and relationships. + +**Raises:** + +- `ValueError`: If the same name appears in both ``include`` and ``exclude``. diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/parsers.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/parsers.mdx index f70c6788..633308d9 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/parsers.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/parsers.mdx @@ -14,3 +14,20 @@ parse_human_friendly_id(hfid: str | list[str]) -> tuple[str | None, list[str]] ``` Parse a human-friendly ID into a kind and an identifier. + +Accepts the HFID either as a separator-joined string (``"Kind__part1__part2"``) or +as a list of components. When a string is provided, the first component is treated as +the node kind only when more than one component is present. + +**Args:** + +- `hfid`: The HFID to parse, either as a separator-joined string or as a list of components. + +**Returns:** + +- tuple[str | None, list[str]]: A tuple of ``(kind, identifier_components)``. ``kind`` is +- ``None`` when no kind prefix is present (single-component string or list input). + +**Raises:** + +- `ValueError`: If ``hfid`` is neither a string nor a list. diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/property.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/property.mdx index a7400483..0f8a30be 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/property.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/property.mdx @@ -10,3 +10,13 @@ sidebarTitle: property ### `NodeProperty` Represents a property of a node, typically used for metadata like display labels. + +A ``NodeProperty`` is a lightweight pointer to another node, used to expose attribute and +relationship metadata such as ``source``, ``owner``, ``created_by``, or ``updated_by`` +without loading the full peer node. + +**Attributes:** + +- `id`: The identifier of the referenced node. +- `display_label`: A human-readable label for the referenced node. +- `typename`: The GraphQL ``__typename`` of the referenced node. diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx index edc1112c..7bc404d1 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx @@ -11,6 +11,18 @@ sidebarTitle: related_node Base class for representing a related node in a relationship. +A ``RelatedNodeBase`` is the peer end of a cardinality-one relationship. It carries +the lightweight identification of the peer (``id``, ``hfid``, ``typename``, ...) along +with the relationship-edge properties (``source``, ``owner``, ``is_protected``, ...). +The full peer node is fetched lazily through :meth:`RelatedNode.fetch` / +:meth:`RelatedNodeSync.fetch`. + +**Attributes:** + +- `schema`: The schema describing the relationship. +- `name`: The name of the relationship slot on the parent node. +- `updated_at`: ISO-8601 timestamp of the most recent edge update. + **Methods:** #### `id` @@ -19,55 +31,114 @@ Base class for representing a related node in a relationship. id(self) -> str | None ``` +Return the unique identifier of the related node. + +**Returns:** + +- str | None: The peer node ID, or ``None`` when neither the peer nor an ID is set. + #### `hfid` ```python hfid(self) -> list[Any] | None ``` +Return the human-friendly ID of the related node. + +**Returns:** + +- list\[Any] | None: The peer HFID as a list of components, or ``None`` when not set. + #### `hfid_str` ```python hfid_str(self) -> str | None ``` +Return the human-friendly ID of the related node as a separator-joined string. + +The returned string includes the kind prefix and is therefore suitable as a key +for the client store. + +**Returns:** + +- str | None: The peer HFID joined with the HFID separator, or ``None`` when +- unavailable (no resolved peer or missing HFID). + #### `is_resource_pool` ```python is_resource_pool(self) -> bool ``` +Return whether the related node is a resource pool. + +**Returns:** + +- ``True`` when the resolved peer inherits from ``CoreResourcePool``. + #### `initialized` ```python initialized(self) -> bool ``` +Return whether this related node has an identifier. + +**Returns:** + +- ``True`` when an ID or HFID is known and the relationship can be referenced. + #### `display_label` ```python display_label(self) -> str | None ``` +Return the human-readable label of the related node. + +**Returns:** + +- str | None: The peer display label, or ``None`` when not provided. + #### `typename` ```python typename(self) -> str | None ``` +Return the GraphQL ``__typename`` of the related node. + +**Returns:** + +- str | None: The peer typename, or ``None`` when not provided. + #### `kind` ```python kind(self) -> str | None ``` +Return the schema kind of the related node. + +**Returns:** + +- str | None: The peer schema kind, or ``None`` when not provided. + #### `is_from_profile` ```python is_from_profile(self) -> bool ``` -Return whether this relationship was set from a profile. Done by checking if the source is of a profile kind. +Return whether this relationship was set from a profile. + +A relationship is considered profile-sourced when the typename of its ``source`` +property starts with the profile kind prefix. + +**Returns:** + +- ``True`` when the relationship's source is a profile node. #### `get_relationship_metadata` @@ -75,11 +146,23 @@ Return whether this relationship was set from a profile. Done by checking if the get_relationship_metadata(self) -> RelationshipMetadata | None ``` -Returns the relationship metadata (updated_at, updated_by) if fetched. +Return the relationship-edge metadata (``updated_at``, ``updated_by``). + +The metadata is populated only when the parent query was executed with +``include_metadata=True``. + +**Returns:** + +- RelationshipMetadata | None: The edge metadata if fetched, otherwise ``None``. ### `RelatedNode` -Represents a RelatedNodeBase in an asynchronous context. +Asynchronous related node bound to an :class:`InfrahubClient`. + +Extends :class:`RelatedNodeBase` with the ability to lazily resolve the peer node: +:meth:`fetch` retrieves the full peer from the backend, :meth:`get` returns it from +the local cache or the client store, and :attr:`peer` is a convenience accessor +around :meth:`get`. **Methods:** @@ -89,21 +172,65 @@ Represents a RelatedNodeBase in an asynchronous context. fetch(self, timeout: int | None = None) -> None ``` +Fetch the full peer node from the backend and cache it on this object. + +After ``fetch()`` completes, attribute and relationship access on the peer is +available via :attr:`peer` or :meth:`get`. + +**Args:** + +- `timeout`: Overrides the default timeout used when querying the +GraphQL API. Specified in seconds. + +**Raises:** + +- `Error`: If neither ``id`` nor ``typename`` is set on this related node. + #### `peer` ```python peer(self) -> InfrahubNode ``` +Return the resolved peer node. + +This is a convenience accessor for :meth:`get`; the peer must already have been +fetched or stored in the client store. + +**Returns:** + +- The resolved peer node. + #### `get` ```python get(self) -> InfrahubNode ``` +Return the resolved peer node from cache or the client store. + +Lookup order: + +1. The peer cached locally after a successful :meth:`fetch`. +2. The client store keyed by ``id`` and ``typename``. +3. The client store keyed by ``hfid_str``. + +**Returns:** + +- The resolved peer node. + +**Raises:** + +- `ValueError`: If neither an ID nor an HFID is available to look up the peer. + ### `RelatedNodeSync` -Represents a related node in a synchronous context. +Synchronous related node bound to an :class:`InfrahubClientSync`. + +Synchronous counterpart of :class:`RelatedNode`. Extends :class:`RelatedNodeBase` +with the ability to lazily resolve the peer node: :meth:`fetch` retrieves the full +peer from the backend, :meth:`get` returns it from the local cache or the client +store, and :attr:`peer` is a convenience accessor around :meth:`get`. **Methods:** @@ -113,14 +240,53 @@ Represents a related node in a synchronous context. fetch(self, timeout: int | None = None) -> None ``` +Fetch the full peer node from the backend and cache it on this object. + +After ``fetch()`` completes, attribute and relationship access on the peer is +available via :attr:`peer` or :meth:`get`. + +**Args:** + +- `timeout`: Overrides the default timeout used when querying the +GraphQL API. Specified in seconds. + +**Raises:** + +- `Error`: If neither ``id`` nor ``typename`` is set on this related node. + #### `peer` ```python peer(self) -> InfrahubNodeSync ``` +Return the resolved peer node. + +This is a convenience accessor for :meth:`get`; the peer must already have been +fetched or stored in the client store. + +**Returns:** + +- The resolved peer node. + #### `get` ```python get(self) -> InfrahubNodeSync ``` + +Return the resolved peer node from cache or the client store. + +Lookup order: + +1. The peer cached locally after a successful :meth:`fetch`. +2. The client store keyed by ``id`` and ``typename``. +3. The client store keyed by ``hfid_str``. + +**Returns:** + +- The resolved peer node. + +**Raises:** + +- `ValueError`: If neither an ID nor an HFID is available to look up the peer. diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/relationship.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/relationship.mdx index 567b7c8d..1a2baf19 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/relationship.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/relationship.mdx @@ -9,7 +9,20 @@ sidebarTitle: relationship ### `RelationshipManagerBase` -Base class for RelationshipManager and RelationshipManagerSync +Base class for :class:`RelationshipManager` and :class:`RelationshipManagerSync`. + +A ``RelationshipManagerBase`` exposes a cardinality-many relationship as a list of +peers along with helpers to add, remove, or extend the set. Relationship managers are +initialized lazily: until :meth:`fetch` (on the async/sync subclasses) is called, the +members are not loaded and editing is not allowed. + +**Attributes:** + +- `name`: The name of the relationship slot on the parent node. +- `schema`: The schema describing the relationship. +- `branch`: The branch the relationship is bound to. +- `peers`: The current peer set. +- `initialized`: ``True`` once the manager has been populated with data. **Methods:** @@ -19,35 +32,72 @@ Base class for RelationshipManager and RelationshipManagerSync peer_ids(self) -> list[str] ``` +Return the IDs of all peers that have one. + +**Returns:** + +- list\[str]: The IDs of the peers, in insertion order. + #### `peer_hfids` ```python peer_hfids(self) -> list[list[Any]] ``` +Return the HFIDs of all peers that have one. + +**Returns:** + +- list\[list\[Any]]: The HFIDs of the peers as lists of components, in insertion order. + #### `peer_hfids_str` ```python peer_hfids_str(self) -> list[str] ``` +Return the HFIDs of all peers as separator-joined strings. + +**Returns:** + +- list\[str]: The HFIDs of the peers as ``Kind__part1__part2`` strings. + #### `has_update` ```python has_update(self) -> bool ``` +Return whether the peer set has been modified since initialization. + +**Returns:** + +- ``True`` after a successful :meth:`add`, :meth:`extend`, or :meth:`remove`. + #### `is_from_profile` ```python is_from_profile(self) -> bool ``` -Return whether this relationship was set from a profile. All its peers must be from a profile. +Return whether this relationship was set from a profile. + +The relationship is considered profile-sourced only when every peer is itself +sourced from a profile. + +**Returns:** + +- ``True`` when at least one peer exists and all peers are from a profile. ### `RelationshipManager` -Manages relationships of a node in an asynchronous context. +Asynchronous manager for a cardinality-many relationship. + +Extends :class:`RelationshipManagerBase` with the ability to populate and edit the +peer set against an :class:`InfrahubClient`: :meth:`fetch` resolves every peer in a +parallel batch and :meth:`add`, :meth:`extend`, and :meth:`remove` mutate the peer +list in memory. Peers are exposed as :class:`RelatedNode` instances and can be +accessed by index via ``manager[i]``. **Methods:** @@ -57,6 +107,16 @@ Manages relationships of a node in an asynchronous context. fetch(self) -> None ``` +Populate the peer set and resolve every peer to a full node. + +When the manager is not yet initialized, the parent node is re-queried with this +relationship included so the peer list can be populated. The peers are then +fetched in a parallel batch grouped by kind and stored in the client store. + +**Raises:** + +- `Error`: If any peer is missing an ``id`` or ``typename`` and cannot be resolved. + #### `add` ```python @@ -65,6 +125,19 @@ add(self, data: str | RelatedNode | dict) -> None Add a new peer to this relationship. +The new peer is only added when its ID or HFID is not already present; duplicate +adds are silently ignored. + +**Args:** + +- `data`: The peer to add. Accepts an ID string, an +existing \:class\:`RelatedNode`, or a dict describing the peer (with ``id`` +or ``hfid`` keys, plus optional relationship properties). + +**Raises:** + +- `UninitializedError`: If \:meth\:`fetch` has not been called on this manager yet. + #### `extend` ```python @@ -73,15 +146,49 @@ extend(self, data: Iterable[str | RelatedNode | dict]) -> None Add new peers to this relationship. +This is a convenience wrapper that calls :meth:`add` for every item in ``data``. +Items already present (by ID or HFID) are silently ignored. + +**Args:** + +- `data`: The peers to add, in any of the +formats accepted by \:meth\:`add`. + +**Raises:** + +- `UninitializedError`: If \:meth\:`fetch` has not been called on this manager yet. + #### `remove` ```python remove(self, data: str | RelatedNode | dict) -> None ``` +Remove a peer from this relationship. + +The peer to remove is matched first by ID, then by HFID. When no match is found, +the call is a no-op. + +**Args:** + +- `data`: The peer to remove. Accepts an ID string, an +existing \:class\:`RelatedNode`, or a dict describing the peer. + +**Raises:** + +- `UninitializedError`: If \:meth\:`fetch` has not been called on this manager yet. +- `IndexError`: If the internal peer index is inconsistent with the lookup result. + ### `RelationshipManagerSync` -Manages relationships of a node in a synchronous context. +Synchronous manager for a cardinality-many relationship. + +Synchronous counterpart of :class:`RelationshipManager`. Extends +:class:`RelationshipManagerBase` with the ability to populate and edit the peer set +against an :class:`InfrahubClientSync`: :meth:`fetch` resolves every peer in a +parallel batch and :meth:`add`, :meth:`extend`, and :meth:`remove` mutate the peer +list in memory. Peers are exposed as :class:`RelatedNodeSync` instances and can be +accessed by index via ``manager[i]``. **Methods:** @@ -91,6 +198,16 @@ Manages relationships of a node in a synchronous context. fetch(self) -> None ``` +Populate the peer set and resolve every peer to a full node. + +When the manager is not yet initialized, the parent node is re-queried with this +relationship included so the peer list can be populated. The peers are then +fetched in a parallel batch grouped by kind and stored in the client store. + +**Raises:** + +- `Error`: If any peer is missing an ``id`` or ``typename`` and cannot be resolved. + #### `add` ```python @@ -99,6 +216,19 @@ add(self, data: str | RelatedNodeSync | dict) -> None Add a new peer to this relationship. +The new peer is only added when its ID or HFID is not already present; duplicate +adds are silently ignored. + +**Args:** + +- `data`: The peer to add. Accepts an ID string, +an existing \:class\:`RelatedNodeSync`, or a dict describing the peer (with +``id`` or ``hfid`` keys, plus optional relationship properties). + +**Raises:** + +- `UninitializedError`: If \:meth\:`fetch` has not been called on this manager yet. + #### `extend` ```python @@ -107,8 +237,35 @@ extend(self, data: Iterable[str | RelatedNodeSync | dict]) -> None Add new peers to this relationship. +This is a convenience wrapper that calls :meth:`add` for every item in ``data``. +Items already present (by ID or HFID) are silently ignored. + +**Args:** + +- `data`: The peers to add, in any of the +formats accepted by \:meth\:`add`. + +**Raises:** + +- `UninitializedError`: If \:meth\:`fetch` has not been called on this manager yet. + #### `remove` ```python remove(self, data: str | RelatedNodeSync | dict) -> None ``` + +Remove a peer from this relationship. + +The peer to remove is matched first by ID, then by HFID. When no match is found, +the call is a no-op. + +**Args:** + +- `data`: The peer to remove. Accepts an ID string, +an existing \:class\:`RelatedNodeSync`, or a dict describing the peer. + +**Raises:** + +- `UninitializedError`: If \:meth\:`fetch` has not been called on this manager yet. +- `IndexError`: If the internal peer index is inconsistent with the lookup result.