From 6c0bf35346c0c67deaa69f7b7ce730bc1fbb5b93 Mon Sep 17 00:00:00 2001 From: Babatunde Olusola Date: Wed, 21 Jan 2026 13:34:26 +0100 Subject: [PATCH 01/89] IHS-183: Fix typing errors for protocols (#749) * IHS-183: Fix typing errors for protocols * fix ty * fix failing tests * address comments --- .pre-commit-config.yaml | 2 +- infrahub_sdk/ctl/branch.py | 2 +- infrahub_sdk/node/related_node.py | 12 +++++++----- infrahub_sdk/protocols_base.py | 27 +++++++++++++++------------ infrahub_sdk/schema/__init__.py | 5 +++-- infrahub_sdk/testing/repository.py | 2 +- 6 files changed, 28 insertions(+), 22 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 537c7969..1c393efd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.11.9 + rev: v0.14.10 hooks: # Run the linter. - id: ruff diff --git a/infrahub_sdk/ctl/branch.py b/infrahub_sdk/ctl/branch.py index 60d67e86..d169cb91 100644 --- a/infrahub_sdk/ctl/branch.py +++ b/infrahub_sdk/ctl/branch.py @@ -293,7 +293,7 @@ async def report( git_files_changed = await check_git_files_changed(client, branch=branch_name) proposed_changes = await client.filters( - kind=CoreProposedChange, # type: ignore[type-abstract] + kind=CoreProposedChange, source_branch__value=branch_name, include=["created_by"], prefetch_relationships=True, diff --git a/infrahub_sdk/node/related_node.py b/infrahub_sdk/node/related_node.py index 5b46a8f7..67171f99 100644 --- a/infrahub_sdk/node/related_node.py +++ b/infrahub_sdk/node/related_node.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from ..exceptions import Error from ..protocols_base import CoreNodeBase @@ -11,7 +11,7 @@ if TYPE_CHECKING: from ..client import InfrahubClient, InfrahubClientSync from ..schema import RelationshipSchemaAPI - from .node import InfrahubNode, InfrahubNodeSync + from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync class RelatedNodeBase: @@ -34,7 +34,7 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, self._properties_object = PROPERTIES_OBJECT self._properties = self._properties_flag + self._properties_object - self._peer = None + self._peer: InfrahubNodeBase | CoreNodeBase | None = None self._id: str | None = None self._hfid: list[str] | None = None self._display_label: str | None = None @@ -43,8 +43,10 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, self._source_typename: str | None = None self._relationship_metadata: RelationshipMetadata | None = None - if isinstance(data, (CoreNodeBase)): - self._peer = data + # Check for InfrahubNodeBase instances using duck-typing (_schema attribute) + # to avoid circular imports, or CoreNodeBase instances + if isinstance(data, CoreNodeBase) or hasattr(data, "_schema"): + self._peer = cast("InfrahubNodeBase | CoreNodeBase", data) for prop in self._properties: setattr(self, prop, None) self._relationship_metadata = None diff --git a/infrahub_sdk/protocols_base.py b/infrahub_sdk/protocols_base.py index 8a841b5b..ba920552 100644 --- a/infrahub_sdk/protocols_base.py +++ b/infrahub_sdk/protocols_base.py @@ -171,8 +171,7 @@ class AnyAttributeOptional(Attribute): value: float | None -@runtime_checkable -class CoreNodeBase(Protocol): +class CoreNodeBase: _schema: MainSchemaTypes _internal_id: str id: str # NOTE this is incorrect, should be str | None @@ -189,23 +188,28 @@ def get_human_friendly_id(self) -> list[str] | None: ... def get_human_friendly_id_as_string(self, include_kind: bool = False) -> str | None: ... - def get_kind(self) -> str: ... + def get_kind(self) -> str: + raise NotImplementedError() - def get_all_kinds(self) -> list[str]: ... + def get_all_kinds(self) -> list[str]: + raise NotImplementedError() - def get_branch(self) -> str: ... + def get_branch(self) -> str: + raise NotImplementedError() - def is_ip_prefix(self) -> bool: ... + def is_ip_prefix(self) -> bool: + raise NotImplementedError() - def is_ip_address(self) -> bool: ... + def is_ip_address(self) -> bool: + raise NotImplementedError() - def is_resource_pool(self) -> bool: ... + def is_resource_pool(self) -> bool: + raise NotImplementedError() def get_raw_graphql_data(self) -> dict | None: ... -@runtime_checkable -class CoreNode(CoreNodeBase, Protocol): +class CoreNode(CoreNodeBase): async def save( self, allow_upsert: bool = False, @@ -229,8 +233,7 @@ async def add_relationships(self, relation_to_update: str, related_nodes: list[s async def remove_relationships(self, relation_to_update: str, related_nodes: list[str]) -> None: ... -@runtime_checkable -class CoreNodeSync(CoreNodeBase, Protocol): +class CoreNodeSync(CoreNodeBase): def save( self, allow_upsert: bool = False, diff --git a/infrahub_sdk/schema/__init__.py b/infrahub_sdk/schema/__init__.py index 3e61ad2a..febb204b 100644 --- a/infrahub_sdk/schema/__init__.py +++ b/infrahub_sdk/schema/__init__.py @@ -21,6 +21,7 @@ ValidationError, ) from ..graphql import Mutation +from ..protocols_base import CoreNodeBase from ..queries import SCHEMA_HASH_SYNC_STATUS from .main import ( AttributeSchema, @@ -207,14 +208,14 @@ def _get_schema_name(schema: type[SchemaType | SchemaTypeSync] | str) -> str: if isinstance(schema, str): return schema - if hasattr(schema, "_is_runtime_protocol") and getattr(schema, "_is_runtime_protocol", None): + if issubclass(schema, CoreNodeBase): if inspect.iscoroutinefunction(schema.save): return schema.__name__ if schema.__name__[-4:] == "Sync": return schema.__name__[:-4] return schema.__name__ - raise ValueError("schema must be a protocol or a string") + raise ValueError("schema must be a CoreNode subclass or a string") @staticmethod def _parse_schema_response(response: httpx.Response, branch: str) -> MutableMapping[str, Any]: diff --git a/infrahub_sdk/testing/repository.py b/infrahub_sdk/testing/repository.py index 9e974164..d07d2b1a 100644 --- a/infrahub_sdk/testing/repository.py +++ b/infrahub_sdk/testing/repository.py @@ -98,7 +98,7 @@ async def wait_for_sync_to_complete( ) -> bool: for _ in range(retries): repo = await client.get( - kind=CoreGenericRepository, # type: ignore[type-abstract] + kind=CoreGenericRepository, name__value=self.name, branch=branch or self.initial_branch, ) From d619c6f65275627f4cad08b2910bc8f3a0bb8e54 Mon Sep 17 00:00:00 2001 From: "infrahub-github-bot-app[bot]" <190746546+infrahub-github-bot-app[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 05:08:04 -0800 Subject: [PATCH 02/89] IHS-183: Fix typing errors for protocols (#749) (#752) * IHS-183: Fix typing errors for protocols * fix ty * fix failing tests * address comments Co-authored-by: Babatunde Olusola --- .pre-commit-config.yaml | 2 +- infrahub_sdk/ctl/branch.py | 2 +- infrahub_sdk/node/related_node.py | 12 +++++++----- infrahub_sdk/protocols_base.py | 27 +++++++++++++++------------ infrahub_sdk/schema/__init__.py | 5 +++-- infrahub_sdk/testing/repository.py | 2 +- 6 files changed, 28 insertions(+), 22 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 537c7969..1c393efd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.11.9 + rev: v0.14.10 hooks: # Run the linter. - id: ruff diff --git a/infrahub_sdk/ctl/branch.py b/infrahub_sdk/ctl/branch.py index 60d67e86..d169cb91 100644 --- a/infrahub_sdk/ctl/branch.py +++ b/infrahub_sdk/ctl/branch.py @@ -293,7 +293,7 @@ async def report( git_files_changed = await check_git_files_changed(client, branch=branch_name) proposed_changes = await client.filters( - kind=CoreProposedChange, # type: ignore[type-abstract] + kind=CoreProposedChange, source_branch__value=branch_name, include=["created_by"], prefetch_relationships=True, diff --git a/infrahub_sdk/node/related_node.py b/infrahub_sdk/node/related_node.py index 5b46a8f7..67171f99 100644 --- a/infrahub_sdk/node/related_node.py +++ b/infrahub_sdk/node/related_node.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from ..exceptions import Error from ..protocols_base import CoreNodeBase @@ -11,7 +11,7 @@ if TYPE_CHECKING: from ..client import InfrahubClient, InfrahubClientSync from ..schema import RelationshipSchemaAPI - from .node import InfrahubNode, InfrahubNodeSync + from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync class RelatedNodeBase: @@ -34,7 +34,7 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, self._properties_object = PROPERTIES_OBJECT self._properties = self._properties_flag + self._properties_object - self._peer = None + self._peer: InfrahubNodeBase | CoreNodeBase | None = None self._id: str | None = None self._hfid: list[str] | None = None self._display_label: str | None = None @@ -43,8 +43,10 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, self._source_typename: str | None = None self._relationship_metadata: RelationshipMetadata | None = None - if isinstance(data, (CoreNodeBase)): - self._peer = data + # Check for InfrahubNodeBase instances using duck-typing (_schema attribute) + # to avoid circular imports, or CoreNodeBase instances + if isinstance(data, CoreNodeBase) or hasattr(data, "_schema"): + self._peer = cast("InfrahubNodeBase | CoreNodeBase", data) for prop in self._properties: setattr(self, prop, None) self._relationship_metadata = None diff --git a/infrahub_sdk/protocols_base.py b/infrahub_sdk/protocols_base.py index 8a841b5b..ba920552 100644 --- a/infrahub_sdk/protocols_base.py +++ b/infrahub_sdk/protocols_base.py @@ -171,8 +171,7 @@ class AnyAttributeOptional(Attribute): value: float | None -@runtime_checkable -class CoreNodeBase(Protocol): +class CoreNodeBase: _schema: MainSchemaTypes _internal_id: str id: str # NOTE this is incorrect, should be str | None @@ -189,23 +188,28 @@ def get_human_friendly_id(self) -> list[str] | None: ... def get_human_friendly_id_as_string(self, include_kind: bool = False) -> str | None: ... - def get_kind(self) -> str: ... + def get_kind(self) -> str: + raise NotImplementedError() - def get_all_kinds(self) -> list[str]: ... + def get_all_kinds(self) -> list[str]: + raise NotImplementedError() - def get_branch(self) -> str: ... + def get_branch(self) -> str: + raise NotImplementedError() - def is_ip_prefix(self) -> bool: ... + def is_ip_prefix(self) -> bool: + raise NotImplementedError() - def is_ip_address(self) -> bool: ... + def is_ip_address(self) -> bool: + raise NotImplementedError() - def is_resource_pool(self) -> bool: ... + def is_resource_pool(self) -> bool: + raise NotImplementedError() def get_raw_graphql_data(self) -> dict | None: ... -@runtime_checkable -class CoreNode(CoreNodeBase, Protocol): +class CoreNode(CoreNodeBase): async def save( self, allow_upsert: bool = False, @@ -229,8 +233,7 @@ async def add_relationships(self, relation_to_update: str, related_nodes: list[s async def remove_relationships(self, relation_to_update: str, related_nodes: list[str]) -> None: ... -@runtime_checkable -class CoreNodeSync(CoreNodeBase, Protocol): +class CoreNodeSync(CoreNodeBase): def save( self, allow_upsert: bool = False, diff --git a/infrahub_sdk/schema/__init__.py b/infrahub_sdk/schema/__init__.py index 3e61ad2a..febb204b 100644 --- a/infrahub_sdk/schema/__init__.py +++ b/infrahub_sdk/schema/__init__.py @@ -21,6 +21,7 @@ ValidationError, ) from ..graphql import Mutation +from ..protocols_base import CoreNodeBase from ..queries import SCHEMA_HASH_SYNC_STATUS from .main import ( AttributeSchema, @@ -207,14 +208,14 @@ def _get_schema_name(schema: type[SchemaType | SchemaTypeSync] | str) -> str: if isinstance(schema, str): return schema - if hasattr(schema, "_is_runtime_protocol") and getattr(schema, "_is_runtime_protocol", None): + if issubclass(schema, CoreNodeBase): if inspect.iscoroutinefunction(schema.save): return schema.__name__ if schema.__name__[-4:] == "Sync": return schema.__name__[:-4] return schema.__name__ - raise ValueError("schema must be a protocol or a string") + raise ValueError("schema must be a CoreNode subclass or a string") @staticmethod def _parse_schema_response(response: httpx.Response, branch: str) -> MutableMapping[str, Any]: diff --git a/infrahub_sdk/testing/repository.py b/infrahub_sdk/testing/repository.py index 9e974164..d07d2b1a 100644 --- a/infrahub_sdk/testing/repository.py +++ b/infrahub_sdk/testing/repository.py @@ -98,7 +98,7 @@ async def wait_for_sync_to_complete( ) -> bool: for _ in range(retries): repo = await client.get( - kind=CoreGenericRepository, # type: ignore[type-abstract] + kind=CoreGenericRepository, name__value=self.name, branch=branch or self.initial_branch, ) From bb2f761749681b6e4caf38b896c643df08717496 Mon Sep 17 00:00:00 2001 From: Babatunde Olusola Date: Thu, 22 Jan 2026 10:42:13 +0100 Subject: [PATCH 03/89] Revert "IHS-183: Fix typing errors for protocols (#749)" (#760) This reverts commit 6c0bf35346c0c67deaa69f7b7ce730bc1fbb5b93. --- .pre-commit-config.yaml | 2 +- infrahub_sdk/ctl/branch.py | 2 +- infrahub_sdk/node/related_node.py | 12 +++++------- infrahub_sdk/protocols_base.py | 27 ++++++++++++--------------- infrahub_sdk/schema/__init__.py | 5 ++--- infrahub_sdk/testing/repository.py | 2 +- 6 files changed, 22 insertions(+), 28 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1c393efd..537c7969 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.14.10 + rev: v0.11.9 hooks: # Run the linter. - id: ruff diff --git a/infrahub_sdk/ctl/branch.py b/infrahub_sdk/ctl/branch.py index d169cb91..60d67e86 100644 --- a/infrahub_sdk/ctl/branch.py +++ b/infrahub_sdk/ctl/branch.py @@ -293,7 +293,7 @@ async def report( git_files_changed = await check_git_files_changed(client, branch=branch_name) proposed_changes = await client.filters( - kind=CoreProposedChange, + kind=CoreProposedChange, # type: ignore[type-abstract] source_branch__value=branch_name, include=["created_by"], prefetch_relationships=True, diff --git a/infrahub_sdk/node/related_node.py b/infrahub_sdk/node/related_node.py index 67171f99..5b46a8f7 100644 --- a/infrahub_sdk/node/related_node.py +++ b/infrahub_sdk/node/related_node.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from ..exceptions import Error from ..protocols_base import CoreNodeBase @@ -11,7 +11,7 @@ if TYPE_CHECKING: from ..client import InfrahubClient, InfrahubClientSync from ..schema import RelationshipSchemaAPI - from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync + from .node import InfrahubNode, InfrahubNodeSync class RelatedNodeBase: @@ -34,7 +34,7 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, self._properties_object = PROPERTIES_OBJECT self._properties = self._properties_flag + self._properties_object - self._peer: InfrahubNodeBase | CoreNodeBase | None = None + self._peer = None self._id: str | None = None self._hfid: list[str] | None = None self._display_label: str | None = None @@ -43,10 +43,8 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, self._source_typename: str | None = None self._relationship_metadata: RelationshipMetadata | None = None - # Check for InfrahubNodeBase instances using duck-typing (_schema attribute) - # to avoid circular imports, or CoreNodeBase instances - if isinstance(data, CoreNodeBase) or hasattr(data, "_schema"): - self._peer = cast("InfrahubNodeBase | CoreNodeBase", data) + if isinstance(data, (CoreNodeBase)): + self._peer = data for prop in self._properties: setattr(self, prop, None) self._relationship_metadata = None diff --git a/infrahub_sdk/protocols_base.py b/infrahub_sdk/protocols_base.py index ba920552..8a841b5b 100644 --- a/infrahub_sdk/protocols_base.py +++ b/infrahub_sdk/protocols_base.py @@ -171,7 +171,8 @@ class AnyAttributeOptional(Attribute): value: float | None -class CoreNodeBase: +@runtime_checkable +class CoreNodeBase(Protocol): _schema: MainSchemaTypes _internal_id: str id: str # NOTE this is incorrect, should be str | None @@ -188,28 +189,23 @@ def get_human_friendly_id(self) -> list[str] | None: ... def get_human_friendly_id_as_string(self, include_kind: bool = False) -> str | None: ... - def get_kind(self) -> str: - raise NotImplementedError() + def get_kind(self) -> str: ... - def get_all_kinds(self) -> list[str]: - raise NotImplementedError() + def get_all_kinds(self) -> list[str]: ... - def get_branch(self) -> str: - raise NotImplementedError() + def get_branch(self) -> str: ... - def is_ip_prefix(self) -> bool: - raise NotImplementedError() + def is_ip_prefix(self) -> bool: ... - def is_ip_address(self) -> bool: - raise NotImplementedError() + def is_ip_address(self) -> bool: ... - def is_resource_pool(self) -> bool: - raise NotImplementedError() + def is_resource_pool(self) -> bool: ... def get_raw_graphql_data(self) -> dict | None: ... -class CoreNode(CoreNodeBase): +@runtime_checkable +class CoreNode(CoreNodeBase, Protocol): async def save( self, allow_upsert: bool = False, @@ -233,7 +229,8 @@ async def add_relationships(self, relation_to_update: str, related_nodes: list[s async def remove_relationships(self, relation_to_update: str, related_nodes: list[str]) -> None: ... -class CoreNodeSync(CoreNodeBase): +@runtime_checkable +class CoreNodeSync(CoreNodeBase, Protocol): def save( self, allow_upsert: bool = False, diff --git a/infrahub_sdk/schema/__init__.py b/infrahub_sdk/schema/__init__.py index febb204b..3e61ad2a 100644 --- a/infrahub_sdk/schema/__init__.py +++ b/infrahub_sdk/schema/__init__.py @@ -21,7 +21,6 @@ ValidationError, ) from ..graphql import Mutation -from ..protocols_base import CoreNodeBase from ..queries import SCHEMA_HASH_SYNC_STATUS from .main import ( AttributeSchema, @@ -208,14 +207,14 @@ def _get_schema_name(schema: type[SchemaType | SchemaTypeSync] | str) -> str: if isinstance(schema, str): return schema - if issubclass(schema, CoreNodeBase): + if hasattr(schema, "_is_runtime_protocol") and getattr(schema, "_is_runtime_protocol", None): if inspect.iscoroutinefunction(schema.save): return schema.__name__ if schema.__name__[-4:] == "Sync": return schema.__name__[:-4] return schema.__name__ - raise ValueError("schema must be a CoreNode subclass or a string") + raise ValueError("schema must be a protocol or a string") @staticmethod def _parse_schema_response(response: httpx.Response, branch: str) -> MutableMapping[str, Any]: diff --git a/infrahub_sdk/testing/repository.py b/infrahub_sdk/testing/repository.py index d07d2b1a..9e974164 100644 --- a/infrahub_sdk/testing/repository.py +++ b/infrahub_sdk/testing/repository.py @@ -98,7 +98,7 @@ async def wait_for_sync_to_complete( ) -> bool: for _ in range(retries): repo = await client.get( - kind=CoreGenericRepository, + kind=CoreGenericRepository, # type: ignore[type-abstract] name__value=self.name, branch=branch or self.initial_branch, ) From 56b59aa5d3ebd47d6c432991cd06975d6a66fc87 Mon Sep 17 00:00:00 2001 From: "infrahub-github-bot-app[bot]" <190746546+infrahub-github-bot-app[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 02:15:26 -0800 Subject: [PATCH 04/89] Revert "IHS-183: Fix typing errors for protocols (#749)" (#760) (#762) This reverts commit 6c0bf35346c0c67deaa69f7b7ce730bc1fbb5b93. Co-authored-by: Babatunde Olusola --- .pre-commit-config.yaml | 2 +- infrahub_sdk/ctl/branch.py | 2 +- infrahub_sdk/node/related_node.py | 12 +++++------- infrahub_sdk/protocols_base.py | 27 ++++++++++++--------------- infrahub_sdk/schema/__init__.py | 5 ++--- infrahub_sdk/testing/repository.py | 2 +- 6 files changed, 22 insertions(+), 28 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1c393efd..537c7969 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.14.10 + rev: v0.11.9 hooks: # Run the linter. - id: ruff diff --git a/infrahub_sdk/ctl/branch.py b/infrahub_sdk/ctl/branch.py index d169cb91..60d67e86 100644 --- a/infrahub_sdk/ctl/branch.py +++ b/infrahub_sdk/ctl/branch.py @@ -293,7 +293,7 @@ async def report( git_files_changed = await check_git_files_changed(client, branch=branch_name) proposed_changes = await client.filters( - kind=CoreProposedChange, + kind=CoreProposedChange, # type: ignore[type-abstract] source_branch__value=branch_name, include=["created_by"], prefetch_relationships=True, diff --git a/infrahub_sdk/node/related_node.py b/infrahub_sdk/node/related_node.py index 67171f99..5b46a8f7 100644 --- a/infrahub_sdk/node/related_node.py +++ b/infrahub_sdk/node/related_node.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from ..exceptions import Error from ..protocols_base import CoreNodeBase @@ -11,7 +11,7 @@ if TYPE_CHECKING: from ..client import InfrahubClient, InfrahubClientSync from ..schema import RelationshipSchemaAPI - from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync + from .node import InfrahubNode, InfrahubNodeSync class RelatedNodeBase: @@ -34,7 +34,7 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, self._properties_object = PROPERTIES_OBJECT self._properties = self._properties_flag + self._properties_object - self._peer: InfrahubNodeBase | CoreNodeBase | None = None + self._peer = None self._id: str | None = None self._hfid: list[str] | None = None self._display_label: str | None = None @@ -43,10 +43,8 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, self._source_typename: str | None = None self._relationship_metadata: RelationshipMetadata | None = None - # Check for InfrahubNodeBase instances using duck-typing (_schema attribute) - # to avoid circular imports, or CoreNodeBase instances - if isinstance(data, CoreNodeBase) or hasattr(data, "_schema"): - self._peer = cast("InfrahubNodeBase | CoreNodeBase", data) + if isinstance(data, (CoreNodeBase)): + self._peer = data for prop in self._properties: setattr(self, prop, None) self._relationship_metadata = None diff --git a/infrahub_sdk/protocols_base.py b/infrahub_sdk/protocols_base.py index ba920552..8a841b5b 100644 --- a/infrahub_sdk/protocols_base.py +++ b/infrahub_sdk/protocols_base.py @@ -171,7 +171,8 @@ class AnyAttributeOptional(Attribute): value: float | None -class CoreNodeBase: +@runtime_checkable +class CoreNodeBase(Protocol): _schema: MainSchemaTypes _internal_id: str id: str # NOTE this is incorrect, should be str | None @@ -188,28 +189,23 @@ def get_human_friendly_id(self) -> list[str] | None: ... def get_human_friendly_id_as_string(self, include_kind: bool = False) -> str | None: ... - def get_kind(self) -> str: - raise NotImplementedError() + def get_kind(self) -> str: ... - def get_all_kinds(self) -> list[str]: - raise NotImplementedError() + def get_all_kinds(self) -> list[str]: ... - def get_branch(self) -> str: - raise NotImplementedError() + def get_branch(self) -> str: ... - def is_ip_prefix(self) -> bool: - raise NotImplementedError() + def is_ip_prefix(self) -> bool: ... - def is_ip_address(self) -> bool: - raise NotImplementedError() + def is_ip_address(self) -> bool: ... - def is_resource_pool(self) -> bool: - raise NotImplementedError() + def is_resource_pool(self) -> bool: ... def get_raw_graphql_data(self) -> dict | None: ... -class CoreNode(CoreNodeBase): +@runtime_checkable +class CoreNode(CoreNodeBase, Protocol): async def save( self, allow_upsert: bool = False, @@ -233,7 +229,8 @@ async def add_relationships(self, relation_to_update: str, related_nodes: list[s async def remove_relationships(self, relation_to_update: str, related_nodes: list[str]) -> None: ... -class CoreNodeSync(CoreNodeBase): +@runtime_checkable +class CoreNodeSync(CoreNodeBase, Protocol): def save( self, allow_upsert: bool = False, diff --git a/infrahub_sdk/schema/__init__.py b/infrahub_sdk/schema/__init__.py index febb204b..3e61ad2a 100644 --- a/infrahub_sdk/schema/__init__.py +++ b/infrahub_sdk/schema/__init__.py @@ -21,7 +21,6 @@ ValidationError, ) from ..graphql import Mutation -from ..protocols_base import CoreNodeBase from ..queries import SCHEMA_HASH_SYNC_STATUS from .main import ( AttributeSchema, @@ -208,14 +207,14 @@ def _get_schema_name(schema: type[SchemaType | SchemaTypeSync] | str) -> str: if isinstance(schema, str): return schema - if issubclass(schema, CoreNodeBase): + if hasattr(schema, "_is_runtime_protocol") and getattr(schema, "_is_runtime_protocol", None): if inspect.iscoroutinefunction(schema.save): return schema.__name__ if schema.__name__[-4:] == "Sync": return schema.__name__[:-4] return schema.__name__ - raise ValueError("schema must be a CoreNode subclass or a string") + raise ValueError("schema must be a protocol or a string") @staticmethod def _parse_schema_response(response: httpx.Response, branch: str) -> MutableMapping[str, Any]: diff --git a/infrahub_sdk/testing/repository.py b/infrahub_sdk/testing/repository.py index d07d2b1a..9e974164 100644 --- a/infrahub_sdk/testing/repository.py +++ b/infrahub_sdk/testing/repository.py @@ -98,7 +98,7 @@ async def wait_for_sync_to_complete( ) -> bool: for _ in range(retries): repo = await client.get( - kind=CoreGenericRepository, + kind=CoreGenericRepository, # type: ignore[type-abstract] name__value=self.name, branch=branch or self.initial_branch, ) From 4b786ff32959e4993659e38e379169fae7d90318 Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Wed, 4 Feb 2026 00:04:32 +0100 Subject: [PATCH 05/89] Upgrade infrahub-testcontainers to 1.7 (#793) * Upgrade infrahub-testcontainers to 1.7 * Remove XFAIL markers on tests requiring Infrahub 1.7 --- pyproject.toml | 2 +- tests/integration/test_infrahub_client.py | 1 - .../integration/test_infrahub_client_sync.py | 1 - uv.lock | 41 ++++--------------- 4 files changed, 10 insertions(+), 35 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4277c5ea..e4e9fa0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ all = [ [dependency-groups] # Core optional dependencies tests = [ - "infrahub-testcontainers>=1.5.1", + "infrahub-testcontainers>=1.7.3", "pytest>=9.0,<9.1", "pytest-asyncio>=1.3,<1.4", "pytest-clarity>=1.0.1", diff --git a/tests/integration/test_infrahub_client.py b/tests/integration/test_infrahub_client.py index 2fe9d801..54be643a 100644 --- a/tests/integration/test_infrahub_client.py +++ b/tests/integration/test_infrahub_client.py @@ -162,7 +162,6 @@ async def test_profile(self, client: InfrahubClient, base_dataset: None, person_ obj1 = await client.get(kind=TESTING_DOG, id=obj.id) assert obj1.color.value == "#111111" - @pytest.mark.xfail(reason="Require Infrahub v1.7") async def test_profile_relationship_is_from_profile( self, client: InfrahubClient, base_dataset: None, person_liam: InfrahubNode ) -> None: diff --git a/tests/integration/test_infrahub_client_sync.py b/tests/integration/test_infrahub_client_sync.py index 472c3378..34c91a44 100644 --- a/tests/integration/test_infrahub_client_sync.py +++ b/tests/integration/test_infrahub_client_sync.py @@ -161,7 +161,6 @@ def test_profile(self, client_sync: InfrahubClientSync, base_dataset: None, pers obj1 = client_sync.get(kind=TESTING_DOG, id=obj.id) assert obj1.color.value == "#222222" - @pytest.mark.xfail(reason="Require Infrahub v1.7") def test_profile_relationship_is_from_profile( self, client_sync: InfrahubClientSync, base_dataset: None, person_liam: InfrahubNode ) -> None: diff --git a/uv.lock b/uv.lock index 77334b35..768484ea 100644 --- a/uv.lock +++ b/uv.lock @@ -93,15 +93,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, ] -[[package]] -name = "async-timeout" -version = "5.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, -] - [[package]] name = "attrs" version = "25.4.0" @@ -854,7 +845,7 @@ provides-extras = ["ctl", "all"] dev = [ { name = "astroid", specifier = ">=3.1,<4.0" }, { name = "codecov" }, - { name = "infrahub-testcontainers", specifier = ">=1.5.1" }, + { name = "infrahub-testcontainers", specifier = ">=1.7.3" }, { name = "invoke", specifier = ">=2.2.0" }, { name = "ipython" }, { name = "mypy", specifier = "==1.11.2" }, @@ -882,7 +873,7 @@ lint = [ { name = "yamllint" }, ] tests = [ - { name = "infrahub-testcontainers", specifier = ">=1.5.1" }, + { name = "infrahub-testcontainers", specifier = ">=1.7.3" }, { name = "pytest", specifier = ">=9.0,<9.1" }, { name = "pytest-asyncio", specifier = ">=1.3,<1.4" }, { name = "pytest-clarity", specifier = ">=1.0.1" }, @@ -898,7 +889,7 @@ types = [ [[package]] name = "infrahub-testcontainers" -version = "1.5.1" +version = "1.7.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -908,9 +899,9 @@ dependencies = [ { name = "pytest" }, { name = "testcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/bc/cc7ea0f479c662b228755af405b6fea759c262b59e876c5fe23cf8d7b7ac/infrahub_testcontainers-1.5.1.tar.gz", hash = "sha256:7a81b88fd887465320ed055aa3b56301059d821737d165123a01564b3b0bf122", size = 16095, upload-time = "2025-11-13T15:29:03.767Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/14/a69cd2301d0bcb1d008e51bcf2fdae695623e1c6cb9f91c70f8d3776a266/infrahub_testcontainers-1.7.3.tar.gz", hash = "sha256:2de2bde68b34ee29e76f83f35b3c7d227d4267aa34735b6827cbd3e7cca1d258", size = 16448, upload-time = "2026-01-28T14:11:24.483Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/62/fb936916fff9eddfe5e64ebe3dab33045ec30f63b7ff7aa409d57a008db3/infrahub_testcontainers-1.5.1-py3-none-any.whl", hash = "sha256:7b32ff5c0b3d5b516df28a6fc2a0c8b410a5224e1e08d3591f431e5008d9f475", size = 23109, upload-time = "2025-11-13T15:29:02.834Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/f5401d1d102fac0f0909fcd1159c57e7a266686aedec1ca9a97217d656ee/infrahub_testcontainers-1.7.3-py3-none-any.whl", hash = "sha256:c176f3ea545f4143f1f7333a0bbf24bcc3319a54357863a185c41ebe8eda1590", size = 23202, upload-time = "2026-01-28T14:11:23.121Z" }, ] [[package]] @@ -1638,7 +1629,7 @@ wheels = [ [[package]] name = "prefect-client" -version = "3.4.23" +version = "3.6.13" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1669,12 +1660,12 @@ dependencies = [ { name = "pydantic-settings" }, { name = "python-dateutil" }, { name = "python-slugify" }, - { name = "python-socks", extra = ["asyncio"] }, { name = "pytz" }, { name = "pyyaml" }, { name = "rfc3339-validator" }, { name = "rich" }, { name = "ruamel-yaml" }, + { name = "ruamel-yaml-clib", marker = "platform_python_implementation == 'CPython'" }, { name = "sniffio" }, { name = "toml" }, { name = "typing-extensions" }, @@ -1682,9 +1673,9 @@ dependencies = [ { name = "websockets" }, { name = "whenever", marker = "python_full_version >= '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/26/f11ab9c0d1533b3b40dac593f7c5eafa4765697a9e926bdbc00687387ee4/prefect_client-3.4.23.tar.gz", hash = "sha256:23d035c1e5a9df0c69c701c5f6ad3f8b80530c19a2383b4ca319a2397fe09ac4", size = 672422, upload-time = "2025-10-09T20:39:30.556Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/a9/5bfc95aee6ca8e202b7f4235e7e2dcb61a29e62e4b1e2f9eef3163883e6e/prefect_client-3.6.13.tar.gz", hash = "sha256:9f3ceb2771c9f6bffa7c8ec01f625ed27c2c7743e69b42d845311a55da8b3d60", size = 728904, upload-time = "2026-01-23T04:17:50.173Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/91/3210a6924c7957073785bb3f3246ad518130dcb672b7bd12e52ae990d9f6/prefect_client-3.4.23-py3-none-any.whl", hash = "sha256:6d3ac95ced68a3d5d461e13f90df35871ce90e58194b8a354f840c6a8e6c1fa1", size = 831473, upload-time = "2025-10-09T20:39:28.377Z" }, + { url = "https://files.pythonhosted.org/packages/c8/51/0216ef9c7ca6002e7b5ae92cdb2858e4f8c5d69c7f2a4a9050afc1086934/prefect_client-3.6.13-py3-none-any.whl", hash = "sha256:3076194ec12b3770e53b1cb8f1d68a7628b8658912e183431a398d7e1617570d", size = 899733, upload-time = "2026-01-23T04:17:47.825Z" }, ] [[package]] @@ -2099,20 +2090,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, ] -[[package]] -name = "python-socks" -version = "2.7.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/13/24c319ca6a19852d50850893a09e44c72d0f8eee38e62c55d2ee10e9149e/python_socks-2.7.3.tar.gz", hash = "sha256:06f4ae34c5828c96f631872e102425bbf44ad841d65ce68329e8dc1af428c1f1", size = 273160, upload-time = "2025-11-10T08:32:08.483Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/21/ab/b1bb545bcbb72d4e65662988c79ff6d4601c43424439030cb62027f45540/python_socks-2.7.3-py3-none-any.whl", hash = "sha256:e0fda261fa8d08bbcbb7852cdf5e7f576f15d5e55559f6bc16db1bad8bad399b", size = 55076, upload-time = "2025-11-10T08:32:05.928Z" }, -] - -[package.optional-dependencies] -asyncio = [ - { name = "async-timeout", marker = "python_full_version < '3.11'" }, -] - [[package]] name = "pytokens" version = "0.2.0" From 504a618615737cbbfc1529b7ec6fcacc2b7fea93 Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Fri, 6 Feb 2026 13:23:44 +0100 Subject: [PATCH 06/89] IHS-193 Add support for file upload/download for `CoreFileObject` (#792) This change adds support to create, update and retrieve nodes which schemas implement `CoreFileObject`. The proposed user exposed API follows these key points: - `node.upload_from_path(path)` to select a file from disk for upload (streams during upload) - `node.upload_from_bytes(content, name)` to set content for upload (supports bytes or BinaryIO) - `node.download_file(dest)` to download a file to memory or stream to disk Being able to stream a file to disk or from a disk is important in order to support large files and to avoid them being loaded completely into memory (which would be problematic for +1GB files in general). The choice of using `upload_from_path` and `upload_from_bytes` is to prevent a collision with a potential attribute or relationship called `file` in the schema. That is also the reason why the `file` GraphQL parameter is outside the `data` one instead of inside it. Here we introduce a couple of components to try to make our code SOLID (it's not much for now, but it's honest work): - `FileHandler` / `FileHandlerSync` dedicated classes for file I/O operations - `MultipartBuilder` GraphQL Multipart Request Spec payload building It sure won't make our code SOLID but it won't add to the burden for now. So given the user who loaded a schema, using our SDK will look like: #### Upload a file when creating a node ```python from pathlib import Path from infrahub_sdk import InfrahubClientSync client = InfrahubClientSync() contract = client.create( kind="NetworkCircuitContract", contract_start="2026-01-01", contract_end="2026-12-31" ) # Option 1: Select file from disk (will be streamed during upload) contract.upload_from_path(path=Path("/tmp/contract.pdf")) # Option 2: Upload from memory (for small files or dynamically generated content) contract.upload_from_bytes(content=b"file content", name="contract.pdf") # Save as usual contract.save() ``` #### Download a file from a node ```python from pathlib import Path contract = client.get(kind="NetworkCircuitContract", id="abc123") # Download to memory (suitable for small files) content = contract.download_file() # Or stream directly to disk (suitable for large files) bytes_written = contract.download_file(dest=Path("/tmp/downloaded.pdf")) ``` --- changelog/ihs193.added.md | 1 + infrahub_sdk/client.py | 357 ++++++++++++++++++++--- infrahub_sdk/ctl/branch.py | 4 +- infrahub_sdk/file_handler.py | 348 ++++++++++++++++++++++ infrahub_sdk/graphql/__init__.py | 2 + infrahub_sdk/graphql/constants.py | 5 + infrahub_sdk/graphql/multipart.py | 100 +++++++ infrahub_sdk/graphql/renderers.py | 5 +- infrahub_sdk/node/constants.py | 3 + infrahub_sdk/node/node.py | 314 +++++++++++++++++--- infrahub_sdk/protocols.py | 21 +- pyproject.toml | 2 + tests/unit/sdk/conftest.py | 46 +++ tests/unit/sdk/graphql/test_multipart.py | 177 +++++++++++ tests/unit/sdk/test_file_handler.py | 311 ++++++++++++++++++++ tests/unit/sdk/test_file_object.py | 295 +++++++++++++++++++ tests/unit/sdk/test_node.py | 255 +++++++++++++++- 17 files changed, 2155 insertions(+), 91 deletions(-) create mode 100644 changelog/ihs193.added.md create mode 100644 infrahub_sdk/file_handler.py create mode 100644 infrahub_sdk/graphql/multipart.py create mode 100644 tests/unit/sdk/graphql/test_multipart.py create mode 100644 tests/unit/sdk/test_file_handler.py create mode 100644 tests/unit/sdk/test_file_object.py diff --git a/changelog/ihs193.added.md b/changelog/ihs193.added.md new file mode 100644 index 00000000..61572618 --- /dev/null +++ b/changelog/ihs193.added.md @@ -0,0 +1 @@ +Added support for FileObject nodes with file upload and download capabilities. New methods `upload_from_path(path)` and `upload_from_bytes(content, name)` allow setting file content before saving, while `download_file(dest)` enables downloading files to memory or streaming to disk for large files. diff --git a/infrahub_sdk/client.py b/infrahub_sdk/client.py index 92cebdea..4b1a59fb 100644 --- a/infrahub_sdk/client.py +++ b/infrahub_sdk/client.py @@ -5,18 +5,12 @@ import logging import time import warnings -from collections.abc import Callable, Coroutine, Mapping, MutableMapping +from collections.abc import AsyncIterator, Callable, Coroutine, Iterator, Mapping, MutableMapping +from contextlib import asynccontextmanager, contextmanager from datetime import datetime from functools import wraps from time import sleep -from typing import ( - TYPE_CHECKING, - Any, - Literal, - TypedDict, - TypeVar, - overload, -) +from typing import TYPE_CHECKING, Any, BinaryIO, Literal, TypedDict, TypeVar, overload from urllib.parse import urlencode import httpx @@ -24,12 +18,7 @@ from typing_extensions import Self from .batch import InfrahubBatch, InfrahubBatchSync -from .branch import ( - MUTATION_QUERY_TASK, - BranchData, - InfrahubBranchManager, - InfrahubBranchManagerSync, -) +from .branch import MUTATION_QUERY_TASK, BranchData, InfrahubBranchManager, InfrahubBranchManagerSync from .config import Config from .constants import InfrahubClientMode from .convert_object_type import CONVERT_OBJECT_MUTATION, ConversionFieldInput @@ -44,11 +33,8 @@ ServerNotResponsiveError, URLNotFoundError, ) -from .graphql import Mutation, Query -from .node import ( - InfrahubNode, - InfrahubNodeSync, -) +from .graphql import MultipartBuilder, Mutation, Query +from .node import InfrahubNode, InfrahubNodeSync from .object_store import ObjectStore, ObjectStoreSync from .protocols_base import CoreNode, CoreNodeSync from .queries import QUERY_USER, get_commit_update_mutation @@ -1008,6 +994,128 @@ async def execute_graphql( # TODO add a special method to execute mutation that will check if the method returned OK + async def _execute_graphql_with_file( + self, + query: str, + variables: dict | None = None, + file_content: BinaryIO | None = None, + file_name: str | None = None, + branch_name: str | None = None, + timeout: int | None = None, + tracker: str | None = None, + ) -> dict: + """Execute a GraphQL mutation with a file upload using multipart/form-data. + + This method follows the GraphQL Multipart Request Spec for file uploads. + The file is attached to the 'file' variable in the mutation. + + Args: + query: GraphQL mutation query that includes a $file variable of type Upload! + variables: Variables to pass along with the GraphQL query. + file_content: The file content as a file-like object (BinaryIO). + file_name: The name of the file being uploaded. + branch_name: Name of the branch on which the mutation will be executed. + timeout: Timeout in seconds for the query. + tracker: Optional tracker for request tracing. + + Raises: + GraphQLError: When the GraphQL response contains errors. + + Returns: + dict: The GraphQL data payload (response["data"]). + """ + branch_name = branch_name or self.default_branch + url = self._graphql_url(branch_name=branch_name) + + # Prepare variables with file placeholder + variables = variables or {} + variables["file"] = None + + headers = copy.copy(self.headers or {}) + # Remove content-type header - httpx will set it for multipart + headers.pop("content-type", None) + if self.insert_tracker and tracker: + headers["X-Infrahub-Tracker"] = tracker + + self._echo(url=url, query=query, variables=variables) + + resp = await self._post_multipart( + url=url, + query=query, + variables=variables, + file_content=file_content, + file_name=file_name or "upload", + headers=headers, + timeout=timeout, + ) + + resp.raise_for_status() + response = decode_json(response=resp) + + if "errors" in response: + raise GraphQLError(errors=response["errors"], query=query, variables=variables) + + return response["data"] + + @handle_relogin + async def _post_multipart( + self, + url: str, + query: str, + variables: dict, + file_content: BinaryIO | None, + file_name: str, + headers: dict | None = None, + timeout: int | None = None, + ) -> httpx.Response: + """Execute a HTTP POST with multipart/form-data for GraphQL file uploads. + + The file_content is streamed directly from the file-like object, avoiding loading the entire file into memory for large files. + """ + await self.login() + + headers = headers or {} + base_headers = copy.copy(self.headers or {}) + # Remove content-type from base headers - httpx will set it for multipart + base_headers.pop("content-type", None) + headers.update(base_headers) + + # Build the multipart form data according to GraphQL Multipart Request Spec + files = MultipartBuilder.build_payload( + query=query, variables=variables, file_content=file_content, file_name=file_name + ) + + return await self._request_multipart( + url=url, headers=headers, timeout=timeout or self.default_timeout, files=files + ) + + def _build_proxy_config(self) -> ProxyConfig: + """Build proxy configuration for httpx AsyncClient.""" + proxy_config: ProxyConfig = {"proxy": None, "mounts": None} + if self.config.proxy: + proxy_config["proxy"] = self.config.proxy + elif self.config.proxy_mounts.is_set: + proxy_config["mounts"] = { + key: httpx.AsyncHTTPTransport(proxy=value) + for key, value in self.config.proxy_mounts.model_dump(by_alias=True).items() + } + return proxy_config + + async def _request_multipart( + self, url: str, headers: dict[str, Any], timeout: int, files: dict[str, Any] + ) -> httpx.Response: + """Execute a multipart HTTP POST request.""" + async with httpx.AsyncClient(**self._build_proxy_config(), verify=self.config.tls_context) as client: + try: + response = await client.post(url=url, headers=headers, timeout=timeout, files=files) + except httpx.NetworkError as exc: + raise ServerNotReachableError(address=self.address) from exc + except httpx.ReadTimeout as exc: + raise ServerNotResponsiveError(url=url, timeout=timeout) from exc + + self._record(response) + return response + @handle_relogin async def _post( self, @@ -1057,6 +1165,36 @@ async def _get(self, url: str, headers: dict | None = None, timeout: int | None timeout=timeout or self.default_timeout, ) + @asynccontextmanager + async def _get_streaming( + self, url: str, headers: dict | None = None, timeout: int | None = None + ) -> AsyncIterator[httpx.Response]: + """Execute a streaming HTTP GET with HTTPX. + + Returns an async context manager that yields the streaming response. + Use this for downloading large files without loading into memory. + + Raises: + ServerNotReachableError if we are not able to connect to the server + ServerNotResponsiveError if the server didn't respond before the timeout expired + """ + await self.login() + + headers = headers or {} + base_headers = copy.copy(self.headers or {}) + headers.update(base_headers) + + async with httpx.AsyncClient(**self._build_proxy_config(), verify=self.config.tls_context) as client: + try: + async with client.stream( + method="GET", url=url, headers=headers, timeout=timeout or self.default_timeout + ) as response: + yield response + except httpx.NetworkError as exc: + raise ServerNotReachableError(address=self.address) from exc + except httpx.ReadTimeout as exc: + raise ServerNotResponsiveError(url=url, timeout=timeout or self.default_timeout) from exc + async def _request( self, url: str, @@ -1081,19 +1219,7 @@ async def _default_request_method( if payload: params["json"] = payload - proxy_config: ProxyConfig = {"proxy": None, "mounts": None} - if self.config.proxy: - proxy_config["proxy"] = self.config.proxy - elif self.config.proxy_mounts.is_set: - proxy_config["mounts"] = { - key: httpx.AsyncHTTPTransport(proxy=value) - for key, value in self.config.proxy_mounts.model_dump(by_alias=True).items() - } - - async with httpx.AsyncClient( - **proxy_config, - verify=self.config.tls_context, - ) as client: + async with httpx.AsyncClient(**self._build_proxy_config(), verify=self.config.tls_context) as client: try: response = await client.request( method=method.value, @@ -1924,6 +2050,126 @@ def execute_graphql( # TODO add a special method to execute mutation that will check if the method returned OK + def _execute_graphql_with_file( + self, + query: str, + variables: dict | None = None, + file_content: BinaryIO | None = None, + file_name: str | None = None, + branch_name: str | None = None, + timeout: int | None = None, + tracker: str | None = None, + ) -> dict: + """Execute a GraphQL mutation with a file upload using multipart/form-data. + + This method follows the GraphQL Multipart Request Spec for file uploads. + The file is attached to the 'file' variable in the mutation. + + Args: + query: GraphQL mutation query that includes a $file variable of type Upload! + variables: Variables to pass along with the GraphQL query. + file_content: The file content as a file-like object (BinaryIO). + file_name: The name of the file being uploaded. + branch_name: Name of the branch on which the mutation will be executed. + timeout: Timeout in seconds for the query. + tracker: Optional tracker for request tracing. + + Raises: + GraphQLError: When the GraphQL response contains errors. + + Returns: + dict: The GraphQL data payload (response["data"]). + """ + branch_name = branch_name or self.default_branch + url = self._graphql_url(branch_name=branch_name) + + # Prepare variables with file placeholder + variables = variables or {} + variables["file"] = None + + headers = copy.copy(self.headers or {}) + # Remove content-type header - httpx will set it for multipart + headers.pop("content-type", None) + if self.insert_tracker and tracker: + headers["X-Infrahub-Tracker"] = tracker + + self._echo(url=url, query=query, variables=variables) + + resp = self._post_multipart( + url=url, + query=query, + variables=variables, + file_content=file_content, + file_name=file_name or "upload", + headers=headers, + timeout=timeout, + ) + + resp.raise_for_status() + response = decode_json(response=resp) + + if "errors" in response: + raise GraphQLError(errors=response["errors"], query=query, variables=variables) + + return response["data"] + + @handle_relogin_sync + def _post_multipart( + self, + url: str, + query: str, + variables: dict, + file_content: BinaryIO | None, + file_name: str, + headers: dict | None = None, + timeout: int | None = None, + ) -> httpx.Response: + """Execute a HTTP POST with multipart/form-data for GraphQL file uploads. + + The file_content is streamed directly from the file-like object, avoiding loading the entire file into memory for large files. + """ + self.login() + + headers = headers or {} + base_headers = copy.copy(self.headers or {}) + # Remove content-type from base headers - httpx will set it for multipart + base_headers.pop("content-type", None) + headers.update(base_headers) + + # Build the multipart form data according to GraphQL Multipart Request Spec + files = MultipartBuilder.build_payload( + query=query, variables=variables, file_content=file_content, file_name=file_name + ) + + return self._request_multipart(url=url, headers=headers, timeout=timeout or self.default_timeout, files=files) + + def _build_proxy_config(self) -> ProxyConfigSync: + """Build proxy configuration for httpx Client.""" + proxy_config: ProxyConfigSync = {"proxy": None, "mounts": None} + if self.config.proxy: + proxy_config["proxy"] = self.config.proxy + elif self.config.proxy_mounts.is_set: + proxy_config["mounts"] = { + key: httpx.HTTPTransport(proxy=value) + for key, value in self.config.proxy_mounts.model_dump(by_alias=True).items() + } + return proxy_config + + def _request_multipart( + self, url: str, headers: dict[str, Any], timeout: int, files: dict[str, Any] + ) -> httpx.Response: + """Execute a multipart HTTP POST request.""" + with httpx.Client(**self._build_proxy_config(), verify=self.config.tls_context) as client: + try: + response = client.post(url=url, headers=headers, timeout=timeout, files=files) + except httpx.NetworkError as exc: + raise ServerNotReachableError(address=self.address) from exc + except httpx.ReadTimeout as exc: + raise ServerNotResponsiveError(url=url, timeout=timeout) from exc + + self._record(response) + return response + def count( self, kind: str | type[SchemaType], @@ -2995,6 +3241,36 @@ def _get(self, url: str, headers: dict | None = None, timeout: int | None = None timeout=timeout or self.default_timeout, ) + @contextmanager + def _get_streaming( + self, url: str, headers: dict | None = None, timeout: int | None = None + ) -> Iterator[httpx.Response]: + """Execute a streaming HTTP GET with HTTPX. + + Returns a context manager that yields the streaming response. + Use this for downloading large files without loading into memory. + + Raises: + ServerNotReachableError if we are not able to connect to the server + ServerNotResponsiveError if the server didn't respond before the timeout expired + """ + self.login() + + headers = headers or {} + base_headers = copy.copy(self.headers or {}) + headers.update(base_headers) + + with httpx.Client(**self._build_proxy_config(), verify=self.config.tls_context) as client: + try: + with client.stream( + method="GET", url=url, headers=headers, timeout=timeout or self.default_timeout + ) as response: + yield response + except httpx.NetworkError as exc: + raise ServerNotReachableError(address=self.address) from exc + except httpx.ReadTimeout as exc: + raise ServerNotResponsiveError(url=url, timeout=timeout or self.default_timeout) from exc + @handle_relogin_sync def _post( self, @@ -3047,20 +3323,7 @@ def _default_request_method( if payload: params["json"] = payload - proxy_config: ProxyConfigSync = {"proxy": None, "mounts": None} - - if self.config.proxy: - proxy_config["proxy"] = self.config.proxy - elif self.config.proxy_mounts.is_set: - proxy_config["mounts"] = { - key: httpx.HTTPTransport(proxy=value) - for key, value in self.config.proxy_mounts.model_dump(by_alias=True).items() - } - - with httpx.Client( - **proxy_config, - verify=self.config.tls_context, - ) as client: + with httpx.Client(**self._build_proxy_config(), verify=self.config.tls_context) as client: try: response = client.request( method=method.value, diff --git a/infrahub_sdk/ctl/branch.py b/infrahub_sdk/ctl/branch.py index 60d67e86..4182f56e 100644 --- a/infrahub_sdk/ctl/branch.py +++ b/infrahub_sdk/ctl/branch.py @@ -119,8 +119,8 @@ def generate_proposed_change_tables(proposed_changes: list[CoreProposedChange]) proposed_change_table.add_row("Name", pc.name.value) proposed_change_table.add_row("State", str(pc.state.value)) proposed_change_table.add_row("Is draft", "Yes" if pc.is_draft.value else "No") - proposed_change_table.add_row("Created by", pc.created_by.peer.name.value) # type: ignore[union-attr] - proposed_change_table.add_row("Created at", format_timestamp(str(pc.created_by.updated_at))) + proposed_change_table.add_row("Created by", pc.created_by.peer.name.value) # type: ignore[attr-defined] + proposed_change_table.add_row("Created at", format_timestamp(str(pc.created_by.updated_at))) # type: ignore[attr-defined] proposed_change_table.add_row("Approvals", str(len(pc.approved_by.peers))) proposed_change_table.add_row("Rejections", str(len(pc.rejected_by.peers))) diff --git a/infrahub_sdk/file_handler.py b/infrahub_sdk/file_handler.py new file mode 100644 index 00000000..5d32441a --- /dev/null +++ b/infrahub_sdk/file_handler.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +from dataclasses import dataclass +from io import BytesIO +from pathlib import Path +from typing import TYPE_CHECKING, BinaryIO, cast, overload + +import anyio +import httpx + +from .exceptions import AuthenticationError, NodeNotFoundError, ServerNotReachableError + +if TYPE_CHECKING: + from .client import InfrahubClient, InfrahubClientSync + + +@dataclass +class PreparedFile: + file_object: BinaryIO | None + filename: str | None + should_close: bool + + +class FileHandlerBase: + """Base class for file handling operations. + + Provides common functionality for both async and sync file handlers, including upload preparation and error handling. + """ + + @staticmethod + async def prepare_upload(content: bytes | Path | BinaryIO | None, name: str | None = None) -> PreparedFile: + """Prepare file content for upload (async version). + + Converts various content types to a consistent BinaryIO interface for streaming uploads. + For Path inputs, opens the file handle in a thread pool to avoid blocking the event loop. + The actual file reading is streamed by httpx during the HTTP request. + + Args: + content: The file content as bytes, a Path to a file, or a file-like object. + Can be None if no file is set. + name: Optional filename. If not provided and content is a Path, + the filename will be derived from the path. + + Returns: + A PreparedFile containing the file object, filename, and whether it should be closed. + """ + if content is None: + return PreparedFile(file_object=None, filename=None, should_close=False) + + if name is None and isinstance(content, Path): + name = content.name + + filename = name or "uploaded_file" + + if isinstance(content, bytes): + return PreparedFile(file_object=BytesIO(content), filename=filename, should_close=False) + if isinstance(content, Path): + # Open file in thread pool to avoid blocking the event loop + # Returns a sync file handle that httpx can stream from in chunks + file_obj = await anyio.to_thread.run_sync(content.open, "rb") + return PreparedFile(file_object=cast("BinaryIO", file_obj), filename=filename, should_close=True) + + # At this point, content must be a BinaryIO (file-like object) + return PreparedFile(file_object=cast("BinaryIO", content), filename=filename, should_close=False) + + @staticmethod + def prepare_upload_sync(content: bytes | Path | BinaryIO | None, name: str | None = None) -> PreparedFile: + """Prepare file content for upload (sync version). + + Converts various content types to a consistent BinaryIO interface for streaming uploads. + + Args: + content: The file content as bytes, a Path to a file, or a file-like object. + Can be None if no file is set. + name: Optional filename. If not provided and content is a Path, + the filename will be derived from the path. + + Returns: + A PreparedFile containing the file object, filename, and whether it should be closed. + """ + if content is None: + return PreparedFile(file_object=None, filename=None, should_close=False) + + if name is None and isinstance(content, Path): + name = content.name + + filename = name or "uploaded_file" + + if isinstance(content, bytes): + return PreparedFile(file_object=BytesIO(content), filename=filename, should_close=False) + if isinstance(content, Path): + return PreparedFile(file_object=content.open("rb"), filename=filename, should_close=True) + + # At this point, content must be a BinaryIO (file-like object) + return PreparedFile(file_object=cast("BinaryIO", content), filename=filename, should_close=False) + + @staticmethod + def handle_error_response(exc: httpx.HTTPStatusError) -> None: + """Handle HTTP error responses for file operations. + + Args: + exc: The HTTP status error from httpx. + + Raises: + AuthenticationError: If authentication fails (401/403). + NodeNotFoundError: If the file/node is not found (404). + httpx.HTTPStatusError: For other HTTP errors. + """ + if exc.response.status_code in {401, 403}: + response = exc.response.json() + errors = response.get("errors", []) + messages = [error.get("message") for error in errors] + raise AuthenticationError(" | ".join(messages)) from exc + if exc.response.status_code == 404: + response = exc.response.json() + detail = response.get("detail", "File not found") + raise NodeNotFoundError(node_type="FileObject", identifier=detail) from exc + raise exc + + @staticmethod + def handle_response(resp: httpx.Response) -> bytes: + """Handle the HTTP response and return file content as bytes. + + Args: + resp: The HTTP response from httpx. + + Returns: + The file content as bytes. + + Raises: + AuthenticationError: If authentication fails. + NodeNotFoundError: If the file is not found. + """ + try: + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + FileHandlerBase.handle_error_response(exc=exc) + return resp.content + + +class FileHandler(FileHandlerBase): + """Async file handler for download operations. + + Handles file downloads with support for streaming to disk + for memory-efficient handling of large files. + """ + + def __init__(self, client: InfrahubClient) -> None: + """Initialize the async file handler. + + Args: + client: The async Infrahub client instance. + """ + self._client = client + + def _build_url(self, node_id: str, branch: str | None) -> str: + """Build the download URL for a file. + + Args: + node_id: The ID of the FileObject node. + branch: Optional branch name. + + Returns: + The complete URL for downloading the file. + """ + url = f"{self._client.address}/api/storage/files/{node_id}" + if branch: + url = f"{url}?branch={branch}" + return url + + @overload + async def download(self, node_id: str, branch: str | None) -> bytes: ... + + @overload + async def download(self, node_id: str, branch: str | None, dest: Path) -> int: ... + + @overload + async def download(self, node_id: str, branch: str | None, dest: None) -> bytes: ... + + async def download(self, node_id: str, branch: str | None, dest: Path | None = None) -> bytes | int: + """Download file content from a FileObject node. + + Args: + node_id: The ID of the FileObject node. + branch: Optional branch name. Uses client default if not provided. + dest: Optional destination path. If provided, streams to disk. + + Returns: + If dest is None: The file content as bytes. + If dest is provided: The number of bytes written. + + Raises: + ServerNotReachableError: If the server is not reachable. + AuthenticationError: If authentication fails. + NodeNotFoundError: If the node/file is not found. + """ + effective_branch = branch or self._client.default_branch + url = self._build_url(node_id=node_id, branch=effective_branch) + + if dest is not None: + return await self._stream_to_file(url=url, dest=dest) + + try: + resp = await self._client._get(url=url) + except ServerNotReachableError: + self._client.log.error(f"Unable to connect to {self._client.address}") + raise + + return self.handle_response(resp=resp) + + async def _stream_to_file(self, url: str, dest: Path) -> int: + """Stream download directly to a file without loading into memory. + + Args: + url: The URL to download from. + dest: The destination path to write to. + + Returns: + The number of bytes written to the file. + + Raises: + ServerNotReachableError: If the server is not reachable. + AuthenticationError: If authentication fails. + NodeNotFoundError: If the file is not found. + """ + try: + async with self._client._get_streaming(url=url) as resp: + try: + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + # Need to read the response body for error details + await resp.aread() + self.handle_error_response(exc=exc) + + bytes_written = 0 + async with await anyio.Path(dest).open("wb") as f: + async for chunk in resp.aiter_bytes(chunk_size=65536): + await f.write(chunk) + bytes_written += len(chunk) + return bytes_written + except ServerNotReachableError: + self._client.log.error(f"Unable to connect to {self._client.address}") + raise + + +class FileHandlerSync(FileHandlerBase): + """Sync file handler for download operations. + + Handles file downloads with support for streaming to disk + for memory-efficient handling of large files. + """ + + def __init__(self, client: InfrahubClientSync) -> None: + """Initialize the sync file handler. + + Args: + client: The sync Infrahub client instance. + """ + self._client = client + + def _build_url(self, node_id: str, branch: str | None) -> str: + """Build the download URL for a file. + + Args: + node_id: The ID of the FileObject node. + branch: Optional branch name. + + Returns: + The complete URL for downloading the file. + """ + url = f"{self._client.address}/api/storage/files/{node_id}" + if branch: + url = f"{url}?branch={branch}" + return url + + @overload + def download(self, node_id: str, branch: str | None) -> bytes: ... + + @overload + def download(self, node_id: str, branch: str | None, dest: Path) -> int: ... + + @overload + def download(self, node_id: str, branch: str | None, dest: None) -> bytes: ... + + def download(self, node_id: str, branch: str | None, dest: Path | None = None) -> bytes | int: + """Download file content from a FileObject node. + + Args: + node_id: The ID of the FileObject node. + branch: Optional branch name. Uses client default if not provided. + dest: Optional destination path. If provided, streams to disk. + + Returns: + If dest is None: The file content as bytes. + If dest is provided: The number of bytes written. + + Raises: + ServerNotReachableError: If the server is not reachable. + AuthenticationError: If authentication fails. + NodeNotFoundError: If the node/file is not found. + """ + effective_branch = branch or self._client.default_branch + url = self._build_url(node_id=node_id, branch=effective_branch) + + if dest is not None: + return self._stream_to_file(url=url, dest=dest) + + try: + resp = self._client._get(url=url) + except ServerNotReachableError: + self._client.log.error(f"Unable to connect to {self._client.address}") + raise + + return self.handle_response(resp=resp) + + def _stream_to_file(self, url: str, dest: Path) -> int: + """Stream download directly to a file without loading into memory. + + Args: + url: The URL to download from. + dest: The destination path to write to. + + Returns: + The number of bytes written to the file. + + Raises: + ServerNotReachableError: If the server is not reachable. + AuthenticationError: If authentication fails. + NodeNotFoundError: If the file is not found. + """ + try: + with self._client._get_streaming(url=url) as resp: + try: + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + # Need to read the response body for error details + resp.read() + self.handle_error_response(exc=exc) + + bytes_written = 0 + with dest.open("wb") as f: + for chunk in resp.iter_bytes(chunk_size=65536): + f.write(chunk) + bytes_written += len(chunk) + return bytes_written + except ServerNotReachableError: + self._client.log.error(f"Unable to connect to {self._client.address}") + raise diff --git a/infrahub_sdk/graphql/__init__.py b/infrahub_sdk/graphql/__init__.py index 33438e35..743919b6 100644 --- a/infrahub_sdk/graphql/__init__.py +++ b/infrahub_sdk/graphql/__init__.py @@ -1,9 +1,11 @@ from .constants import VARIABLE_TYPE_MAPPING +from .multipart import MultipartBuilder from .query import Mutation, Query from .renderers import render_input_block, render_query_block, render_variables_to_string __all__ = [ "VARIABLE_TYPE_MAPPING", + "MultipartBuilder", "Mutation", "Query", "render_input_block", diff --git a/infrahub_sdk/graphql/constants.py b/infrahub_sdk/graphql/constants.py index e2033155..0fed5c57 100644 --- a/infrahub_sdk/graphql/constants.py +++ b/infrahub_sdk/graphql/constants.py @@ -1,4 +1,6 @@ from datetime import datetime +from pathlib import Path +from typing import BinaryIO VARIABLE_TYPE_MAPPING = ( (str, "String!"), @@ -11,4 +13,7 @@ (bool | None, "Boolean"), (datetime, "DateTime!"), (datetime | None, "DateTime"), + (bytes, "Upload!"), + (Path, "Upload!"), + (BinaryIO, "Upload!"), ) diff --git a/infrahub_sdk/graphql/multipart.py b/infrahub_sdk/graphql/multipart.py new file mode 100644 index 00000000..bdb1f84e --- /dev/null +++ b/infrahub_sdk/graphql/multipart.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import ujson + +if TYPE_CHECKING: + from typing import BinaryIO + + +class MultipartBuilder: + """Builds multipart form data payloads for GraphQL file uploads. + + This class implements the GraphQL Multipart Request Spec for uploading files via GraphQL mutations. The spec defines a standard way to send files + alongside GraphQL operations using multipart/form-data. + + The payload structure follows the spec: + - operations: JSON containing the GraphQL query and variables + - map: JSON mapping file keys to variable paths + - 0, 1, ...: The actual file contents + + Example payload: + { + "operations": '{"query": "mutation($file: Upload!) {...}", "variables": {"file": null}}', + "map": '{"0": ["variables.file"]}', + "0": (filename, file_content) + } + """ + + @staticmethod + def build_operations(query: str, variables: dict[str, Any]) -> str: + """Build the operations JSON string. + + Args: + query: The GraphQL query string. + variables: The variables dict (file variable should be null). + + Returns: + JSON string containing the query and variables. + """ + return ujson.dumps({"query": query, "variables": variables}) + + @staticmethod + def build_file_map(file_key: str = "0", variable_path: str = "variables.file") -> str: + """Build the file map JSON string. + + Args: + file_key: The key used for the file in the multipart payload. + variable_path: The path to the file variable in the GraphQL variables. + + Returns: + JSON string mapping the file key to the variable path. + """ + return ujson.dumps({file_key: [variable_path]}) + + @staticmethod + def build_payload( + query: str, + variables: dict[str, Any], + file_content: BinaryIO | None = None, + file_name: str = "upload", + ) -> dict[str, Any]: + """Build the complete multipart form data payload. + + Constructs the payload according to the GraphQL Multipart Request Spec. The returned dict can be passed directly to httpx as the `files` + parameter. + + Args: + query: The GraphQL query string containing $file: Upload! variable. + variables: The variables dict. The 'file' key will be set to null. + file_content: The file content as a file-like object (BinaryIO). + If None, only the operations and map will be included. + file_name: The filename to use for the upload. + + Returns: + A dict suitable for httpx's `files` parameter in a POST request. + + Example: + >>> builder = MultipartBuilder() + >>> payload = builder.build_payload( + ... query="mutation($file: Upload!) { upload(file: $file) { id } }", + ... variables={"other": "value"}, + ... file_content=open("file.pdf", "rb"), + ... file_name="document.pdf", + ... ) + >>> # payload can be passed to httpx.post(..., files=payload) + """ + # Ensure file variable is null (spec requirement) + variables = {**variables, "file": None} + + operations = MultipartBuilder.build_operations(query=query, variables=variables) + file_map = MultipartBuilder.build_file_map() + + files: dict[str, Any] = {"operations": (None, operations), "map": (None, file_map)} + + if file_content is not None: + # httpx streams from file-like objects automatically + files["0"] = (file_name, file_content) + + return files diff --git a/infrahub_sdk/graphql/renderers.py b/infrahub_sdk/graphql/renderers.py index b0d2ab28..e2e4aafc 100644 --- a/infrahub_sdk/graphql/renderers.py +++ b/infrahub_sdk/graphql/renderers.py @@ -3,7 +3,8 @@ import json from datetime import datetime from enum import Enum -from typing import Any +from pathlib import Path +from typing import Any, BinaryIO from pydantic import BaseModel @@ -88,7 +89,7 @@ def convert_to_graphql_as_string(value: Any, convert_enum: bool = False) -> str: return str(value) -GRAPHQL_VARIABLE_TYPES = type[str | int | float | bool | datetime | None] +GRAPHQL_VARIABLE_TYPES = type[str | int | float | bool | datetime | bytes | Path | BinaryIO | None] def render_variables_to_string(data: dict[str, GRAPHQL_VARIABLE_TYPES]) -> str: diff --git a/infrahub_sdk/node/constants.py b/infrahub_sdk/node/constants.py index 8d301115..7a0bc6fd 100644 --- a/infrahub_sdk/node/constants.py +++ b/infrahub_sdk/node/constants.py @@ -27,6 +27,9 @@ ARTIFACT_DEFINITION_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE = ( "calling generate is only supported for CoreArtifactDefinition nodes" ) +FILE_DOWNLOAD_FEATURE_NOT_SUPPORTED_MESSAGE = ( + "calling download_file is only supported for nodes that inherit from CoreFileObject" +) HIERARCHY_FETCH_FEATURE_NOT_SUPPORTED_MESSAGE = "Hierarchical fields are not supported for this node." diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index 25d9d191..74f1a0fc 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -2,10 +2,12 @@ from collections.abc import Iterable from copy import copy, deepcopy -from typing import TYPE_CHECKING, Any +from pathlib import Path +from typing import TYPE_CHECKING, Any, BinaryIO from ..constants import InfrahubClientMode from ..exceptions import FeatureNotSupportedError, NodeNotFoundError, ResourceNotDefinedError, SchemaNotFoundError +from ..file_handler import FileHandler, FileHandlerBase, FileHandlerSync, PreparedFile from ..graphql import Mutation, Query from ..schema import ( GenericSchemaAPI, @@ -21,6 +23,7 @@ ARTIFACT_DEFINITION_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE, ARTIFACT_FETCH_FEATURE_NOT_SUPPORTED_MESSAGE, ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE, + FILE_DOWNLOAD_FEATURE_NOT_SUPPORTED_MESSAGE, PROPERTIES_OBJECT, ) from .metadata import NodeMetadata @@ -65,14 +68,15 @@ def __init__(self, schema: MainSchemaTypesAPI, branch: str, data: dict | None = self._attributes = [item.name for item in self._schema.attributes] self._relationships = [item.name for item in self._schema.relationships] - # GenericSchemaAPI doesn't have inherit_from, so we need to check the type first - if isinstance(schema, GenericSchemaAPI): - self._artifact_support = False - else: - inherit_from = getattr(schema, "inherit_from", None) or [] - self._artifact_support = "CoreArtifactTarget" in inherit_from + # GenericSchemaAPI doesn't have inherit_from + inherit_from: list[str] = getattr(schema, "inherit_from", None) or [] + self._artifact_support = "CoreArtifactTarget" in inherit_from + self._file_object_support = "CoreFileObject" in inherit_from self._artifact_definition_support = schema.kind == "CoreArtifactDefinition" + self._file_content: bytes | Path | BinaryIO | None = None + self._file_name: str | None = None + # Check if this node is hierarchical (supports parent/children and ancestors/descendants) if not isinstance(schema, (ProfileSchemaAPI, GenericSchemaAPI, TemplateSchemaAPI)): self._hierarchy_support = getattr(schema, "hierarchy", None) is not None @@ -213,6 +217,72 @@ def is_ip_address(self) -> bool: def is_resource_pool(self) -> bool: 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 self._file_object_support + + def upload_from_path(self, path: Path) -> None: + """Set a file from disk to be uploaded when saving this FileObject node. + + The file will be streamed during upload, avoiding loading the entire file into memory. + + Args: + path: Path to the file on disk. + + Raises: + FeatureNotSupportedError: If this node doesn't inherit from CoreFileObject. + + Example: + node.upload_from_path(path=Path("/path/to/large_file.pdf")) + """ + if not self._file_object_support: + raise FeatureNotSupportedError( + f"File upload is not supported for {self._schema.kind}. Only nodes inheriting from CoreFileObject support file uploads." + ) + self._file_content = path + self._file_name = path.name + + def upload_from_bytes(self, content: bytes | BinaryIO, name: str) -> None: + """Set content to be uploaded when saving this FileObject node. + + The content can be provided as bytes or a file-like object. + Using BinaryIO is recommended for large content to stream during upload. + + Args: + content: The file content as bytes or a file-like object. + name: The filename to use for the uploaded file. + + Raises: + FeatureNotSupportedError: If this node doesn't inherit from CoreFileObject. + + Examples: + # Using bytes (for small files) + node.upload_from_bytes(content=b"file content", name="example.txt") + + # Using file-like object (for large files) + with open("/path/to/file.bin", "rb") as f: + node.upload_from_bytes(content=f, name="file.bin") + """ + if not self._file_object_support: + raise FeatureNotSupportedError( + f"File upload is not supported for {self._schema.kind}. Only nodes inheriting from CoreFileObject support file uploads." + ) + self._file_content = content + self._file_name = name + + def clear_file(self) -> None: + """Clear any pending file content.""" + self._file_content = None + self._file_name = None + + async def _get_file_for_upload(self) -> PreparedFile: + """Get the file content as a file-like object for upload (async version).""" + return await FileHandlerBase.prepare_upload(content=self._file_content, name=self._file_name) + + def _get_file_for_upload_sync(self) -> PreparedFile: + """Get the file content as a file-like object for upload (sync version).""" + return FileHandlerBase.prepare_upload_sync(content=self._file_content, name=self._file_name) + def get_raw_graphql_data(self) -> dict | None: return self._data @@ -297,10 +367,16 @@ def _generate_input_data( # noqa: C901, PLR0915 elif self.hfid is not None and not exclude_hfid: data["hfid"] = self.hfid - mutation_payload = {"data": data} + mutation_payload: dict[str, Any] = {"data": data} if context_data := self._get_request_context(request_context=request_context): mutation_payload["context"] = context_data + # Add file variable for FileObject nodes with pending file content + # file is a mutation argument at the same level as data, not inside data + if self._file_object_support and self._file_content is not None: + mutation_payload["file"] = "$file" + mutation_variables["file"] = bytes + return { "data": mutation_payload, "variables": variables, @@ -426,6 +502,10 @@ def _validate_artifact_definition_support(self, message: str) -> None: if not self._artifact_definition_support: raise FeatureNotSupportedError(message) + def _validate_file_object_support(self, message: str) -> None: + if not self._file_object_support: + raise FeatureNotSupportedError(message) + def generate_query_data_init( self, filters: dict[str, Any] | None = None, @@ -515,6 +595,7 @@ def __init__( data: Optional data to initialize the node. """ self._client = client + self._file_handler = FileHandler(client=client) # Extract node_metadata before extracting node data (node_metadata is sibling to node in edges) node_metadata_data: dict | None = None @@ -703,6 +784,41 @@ async def artifact_fetch(self, name: str) -> str | dict[str, Any]: artifact = await self._client.get(kind="CoreArtifact", name__value=name, object__ids=[self.id]) return await self._client.object_store.get(identifier=artifact._get_attribute(name="storage_id").value) + async def download_file(self, dest: Path | None = None) -> bytes | int: + """Download the file content from this FileObject node. + + This method is only available for nodes that inherit from CoreFileObject. + The node must have been saved (have an id) before calling this method. + + Args: + dest: Optional destination path. If provided, the file will be streamed + directly to this path (memory-efficient for large files) and the + number of bytes written will be returned. If not provided, the + file content will be returned as bytes. + + Returns: + If dest is None: The file content as bytes. + If dest is provided: The number of bytes written to the file. + + Raises: + FeatureNotSupportedError: If this node doesn't inherit from CoreFileObject. + ValueError: If the node hasn't been saved yet or file not found. + AuthenticationError: If authentication fails. + + Examples: + # Download to memory + content = await contract.download_file() + + # Stream to file (memory-efficient for large files) + bytes_written = await contract.download_file(dest=Path("/tmp/contract.pdf")) + """ + self._validate_file_object_support(message=FILE_DOWNLOAD_FEATURE_NOT_SUPPORTED_MESSAGE) + + if not self.id: + raise ValueError("Cannot download file for a node that hasn't been saved yet.") + + 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: input_data = {"data": {"id": self.id}} if context_data := self._get_request_context(request_context=request_context): @@ -1037,6 +1153,12 @@ async def _process_mutation_result( async def create( self, allow_upsert: bool = False, timeout: int | None = None, request_context: RequestContext | None = None ) -> None: + 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 " + "file content before saving." + ) + mutation_query = self._generate_mutation_query() # Upserting means we may want to create, meaning payload contains all mandatory fields required for a creation, @@ -1049,19 +1171,39 @@ async def create( input_data = self._generate_input_data(exclude_hfid=True, request_context=request_context) mutation_name = f"{self._schema.kind}Create" tracker = f"mutation-{str(self._schema.kind).lower()}-create" + query = Mutation( mutation=mutation_name, input_data=input_data["data"], query=mutation_query, variables=input_data["mutation_variables"], ) - response = await self._client.execute_graphql( - query=query.render(), - branch_name=self._branch, - tracker=tracker, - variables=input_data["variables"], - timeout=timeout, - ) + + if "file" in input_data["mutation_variables"]: + prepared = await self._get_file_for_upload() + try: + response = await self._client._execute_graphql_with_file( + query=query.render(), + variables=input_data["variables"], + file_content=prepared.file_object, + file_name=prepared.filename, + branch_name=self._branch, + tracker=tracker, + timeout=timeout, + ) + finally: + if prepared.should_close and prepared.file_object: + prepared.file_object.close() + # Clear the file content after successful upload + self.clear_file() + else: + response = await self._client.execute_graphql( + query=query.render(), + branch_name=self._branch, + tracker=tracker, + variables=input_data["variables"], + timeout=timeout, + ) await self._process_mutation_result(mutation_name=mutation_name, response=response, timeout=timeout) async def update( @@ -1070,6 +1212,7 @@ async def update( 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" + tracker = f"mutation-{str(self._schema.kind).lower()}-update" query = Mutation( mutation=mutation_name, @@ -1077,13 +1220,32 @@ async def update( query=mutation_query, variables=input_data["mutation_variables"], ) - response = await self._client.execute_graphql( - query=query.render(), - branch_name=self._branch, - timeout=timeout, - tracker=f"mutation-{str(self._schema.kind).lower()}-update", - variables=input_data["variables"], - ) + + if "file" in input_data["mutation_variables"]: + prepared = await self._get_file_for_upload() + try: + response = await self._client._execute_graphql_with_file( + query=query.render(), + variables=input_data["variables"], + file_content=prepared.file_object, + file_name=prepared.filename, + branch_name=self._branch, + tracker=tracker, + timeout=timeout, + ) + finally: + if prepared.should_close and prepared.file_object: + prepared.file_object.close() + # Clear the file content after successful upload + self.clear_file() + else: + response = await self._client.execute_graphql( + query=query.render(), + branch_name=self._branch, + timeout=timeout, + tracker=tracker, + variables=input_data["variables"], + ) await self._process_mutation_result(mutation_name=mutation_name, response=response, timeout=timeout) async def _process_relationships( @@ -1323,6 +1485,7 @@ def __init__( data (Optional[dict]): Optional data to initialize the node. """ self._client = client + self._file_handler = FileHandlerSync(client=client) # Extract node_metadata before extracting node data (node_metadata is sibling to node in edges) node_metadata_data: dict | None = None @@ -1512,6 +1675,41 @@ def artifact_fetch(self, name: str) -> str | dict[str, Any]: 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) + def download_file(self, dest: Path | None = None) -> bytes | int: + """Download the file content from this FileObject node. + + This method is only available for nodes that inherit from CoreFileObject. + The node must have been saved (have an id) before calling this method. + + Args: + dest: Optional destination path. If provided, the file will be streamed + directly to this path (memory-efficient for large files) and the + number of bytes written will be returned. If not provided, the + file content will be returned as bytes. + + Returns: + If dest is None: The file content as bytes. + If dest is provided: The number of bytes written to the file. + + Raises: + FeatureNotSupportedError: If this node doesn't inherit from CoreFileObject. + ValueError: If the node hasn't been saved yet or file not found. + AuthenticationError: If authentication fails. + + Examples: + # Download to memory + content = contract.download_file() + + # Stream to file (memory-efficient for large files) + bytes_written = contract.download_file(dest=Path("/tmp/contract.pdf")) + """ + self._validate_file_object_support(message=FILE_DOWNLOAD_FEATURE_NOT_SUPPORTED_MESSAGE) + + if not self.id: + raise ValueError("Cannot download file for a node that hasn't been saved yet.") + + 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: input_data = {"data": {"id": self.id}} if context_data := self._get_request_context(request_context=request_context): @@ -1845,6 +2043,12 @@ def _process_mutation_result( def create( self, allow_upsert: bool = False, timeout: int | None = None, request_context: RequestContext | None = None ) -> None: + 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 " + "file content before saving." + ) + mutation_query = self._generate_mutation_query() if allow_upsert: @@ -1855,6 +2059,7 @@ def create( input_data = self._generate_input_data(exclude_hfid=True, request_context=request_context) mutation_name = f"{self._schema.kind}Create" tracker = f"mutation-{str(self._schema.kind).lower()}-create" + query = Mutation( mutation=mutation_name, input_data=input_data["data"], @@ -1862,13 +2067,31 @@ def create( variables=input_data["mutation_variables"], ) - response = self._client.execute_graphql( - query=query.render(), - branch_name=self._branch, - tracker=tracker, - variables=input_data["variables"], - timeout=timeout, - ) + if "file" in input_data["mutation_variables"]: + prepared = self._get_file_for_upload_sync() + try: + response = self._client._execute_graphql_with_file( + query=query.render(), + variables=input_data["variables"], + file_content=prepared.file_object, + file_name=prepared.filename, + branch_name=self._branch, + tracker=tracker, + timeout=timeout, + ) + finally: + if prepared.should_close and prepared.file_object: + prepared.file_object.close() + # Clear the file content after successful upload + self.clear_file() + else: + response = self._client.execute_graphql( + query=query.render(), + branch_name=self._branch, + tracker=tracker, + variables=input_data["variables"], + timeout=timeout, + ) self._process_mutation_result(mutation_name=mutation_name, response=response, timeout=timeout) def update( @@ -1877,6 +2100,7 @@ def update( 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" + tracker = f"mutation-{str(self._schema.kind).lower()}-update" query = Mutation( mutation=mutation_name, @@ -1885,13 +2109,31 @@ def update( variables=input_data["mutation_variables"], ) - response = self._client.execute_graphql( - query=query.render(), - branch_name=self._branch, - tracker=f"mutation-{str(self._schema.kind).lower()}-update", - variables=input_data["variables"], - timeout=timeout, - ) + if "file" in input_data["mutation_variables"]: + prepared = self._get_file_for_upload_sync() + try: + response = self._client._execute_graphql_with_file( + query=query.render(), + variables=input_data["variables"], + file_content=prepared.file_object, + file_name=prepared.filename, + branch_name=self._branch, + tracker=tracker, + timeout=timeout, + ) + finally: + if prepared.should_close and prepared.file_object: + prepared.file_object.close() + # Clear the file content after successful upload + self.clear_file() + else: + response = self._client.execute_graphql( + query=query.render(), + branch_name=self._branch, + tracker=tracker, + variables=input_data["variables"], + timeout=timeout, + ) self._process_mutation_result(mutation_name=mutation_name, response=response, timeout=timeout) def _process_relationships( diff --git a/infrahub_sdk/protocols.py b/infrahub_sdk/protocols.py index b3752bed..c359ad5c 100644 --- a/infrahub_sdk/protocols.py +++ b/infrahub_sdk/protocols.py @@ -29,7 +29,6 @@ StringOptional, ) -# pylint: disable=too-many-ancestors # --------------------------------------------- # ASYNC @@ -108,6 +107,14 @@ class CoreCredential(CoreNode): description: StringOptional +class CoreFileObject(CoreNode): + file_name: String + checksum: String + file_size: Integer + file_type: String + storage_id: String + + class CoreGenericAccount(CoreNode): name: String password: HashedPassword @@ -227,6 +234,7 @@ class CoreValidator(CoreNode): class CoreWebhook(CoreNode): name: String event_type: Enum + active: Boolean branch_scope: Dropdown node_kind: StringOptional description: StringOptional @@ -499,7 +507,6 @@ class CoreProposedChange(CoreTaskTarget): approved_by: RelationshipManager rejected_by: RelationshipManager reviewers: RelationshipManager - created_by: RelatedNode comments: RelationshipManager threads: RelationshipManager validations: RelationshipManager @@ -665,6 +672,14 @@ class CoreCredentialSync(CoreNodeSync): description: StringOptional +class CoreFileObjectSync(CoreNodeSync): + file_name: String + checksum: String + file_size: Integer + file_type: String + storage_id: String + + class CoreGenericAccountSync(CoreNodeSync): name: String password: HashedPassword @@ -784,6 +799,7 @@ class CoreValidatorSync(CoreNodeSync): class CoreWebhookSync(CoreNodeSync): name: String event_type: Enum + active: Boolean branch_scope: Dropdown node_kind: StringOptional description: StringOptional @@ -1056,7 +1072,6 @@ class CoreProposedChangeSync(CoreTaskTargetSync): approved_by: RelationshipManagerSync rejected_by: RelationshipManagerSync reviewers: RelationshipManagerSync - created_by: RelatedNodeSync comments: RelationshipManagerSync threads: RelationshipManagerSync validations: RelationshipManagerSync diff --git a/pyproject.toml b/pyproject.toml index 0c5ac918..461e25b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -305,6 +305,7 @@ max-complexity = 17 # Review and change the below later # ################################################################################################## "PLR0912", # Too many branches + "PLR0904", # Too many public methods ] "infrahub_sdk/node/related_node.py" = [ @@ -352,6 +353,7 @@ max-complexity = 17 "PT013", # Incorrect import of `pytest`; use `import pytest` instead ] + # tests/integration/ "tests/integration/test_infrahub_client.py" = ["PLR0904"] "tests/integration/test_infrahub_client_sync.py" = ["PLR0904"] diff --git a/tests/unit/sdk/conftest.py b/tests/unit/sdk/conftest.py index 8fb9ecf2..3c3fe933 100644 --- a/tests/unit/sdk/conftest.py +++ b/tests/unit/sdk/conftest.py @@ -2645,3 +2645,49 @@ async def nested_device_with_interfaces_schema() -> NodeSchemaAPI: ], } return NodeSchema(**data).convert_api() + + +@pytest.fixture +async def file_object_schema() -> NodeSchemaAPI: + """Schema for a node that inherits from CoreFileObject.""" + data = { + "name": "CircuitContract", + "namespace": "Network", + "label": "Circuit Contract", + "default_filter": "file_name__value", + "inherit_from": ["CoreFileObject"], + "order_by": ["file_name__value"], + "display_labels": ["file_name__value"], + "attributes": [ + # Simulate inherited attributes from CoreFileObject + {"name": "file_name", "kind": "Text", "read_only": True, "optional": False}, + {"name": "checksum", "kind": "Text", "read_only": True, "optional": False}, + {"name": "file_size", "kind": "Number", "read_only": True, "optional": False}, + {"name": "file_type", "kind": "Text", "read_only": True, "optional": False}, + {"name": "storage_id", "kind": "Text", "read_only": True, "optional": False}, + {"name": "contract_start", "kind": "DateTime", "optional": False}, + {"name": "contract_end", "kind": "DateTime", "optional": False}, + ], + "relationships": [], + } + return NodeSchema(**data).convert_api() + + +@pytest.fixture +async def non_file_object_schema() -> NodeSchemaAPI: + """Schema for a regular node that does not inherit from CoreFileObject.""" + data = { + "name": "Device", + "namespace": "Infra", + "label": "Device", + "default_filter": "name__value", + "inherit_from": [], + "order_by": ["name__value"], + "display_labels": ["name__value"], + "attributes": [ + {"name": "name", "kind": "Text", "unique": True}, + {"name": "description", "kind": "Text", "optional": True}, + ], + "relationships": [], + } + return NodeSchema(**data).convert_api() diff --git a/tests/unit/sdk/graphql/test_multipart.py b/tests/unit/sdk/graphql/test_multipart.py new file mode 100644 index 00000000..35a0e58a --- /dev/null +++ b/tests/unit/sdk/graphql/test_multipart.py @@ -0,0 +1,177 @@ +"""Unit tests for MultipartBuilder class.""" + +from __future__ import annotations + +from io import BytesIO + +import ujson + +from infrahub_sdk.graphql import MultipartBuilder + + +def test_build_operations_simple() -> None: + """Test building operations with simple query and variables.""" + query = "mutation($file: Upload!) { upload(file: $file) { id } }" + variables = {"other": "value"} + + result = MultipartBuilder.build_operations(query=query, variables=variables) + + parsed = ujson.loads(result) + assert parsed["query"] == query + assert parsed["variables"] == variables + + +def test_build_operations_empty_variables() -> None: + """Test building operations with empty variables.""" + query = "mutation { doSomething { id } }" + variables: dict[str, str] = {} + + result = MultipartBuilder.build_operations(query=query, variables=variables) + + parsed = ujson.loads(result) + assert parsed["query"] == query + assert parsed["variables"] == {} + + +def test_build_operations_complex_variables() -> None: + """Test building operations with nested variables.""" + query = "mutation($input: CreateInput!) { create(input: $input) { id } }" + variables = {"input": {"name": "test", "nested": {"value": 123}, "list": [1, 2, 3]}} + + result = MultipartBuilder.build_operations(query=query, variables=variables) + + parsed = ujson.loads(result) + assert parsed["variables"]["input"]["name"] == "test" + assert parsed["variables"]["input"]["nested"]["value"] == 123 + assert parsed["variables"]["input"]["list"] == [1, 2, 3] + + +def test_build_file_map_defaults() -> None: + """Test building file map with default values.""" + result = MultipartBuilder.build_file_map() + + parsed = ujson.loads(result) + assert parsed == {"0": ["variables.file"]} + + +def test_build_file_map_custom_key() -> None: + """Test building file map with custom file key.""" + result = MultipartBuilder.build_file_map(file_key="1") + + parsed = ujson.loads(result) + assert parsed == {"1": ["variables.file"]} + + +def test_build_file_map_custom_path() -> None: + """Test building file map with custom variable path.""" + result = MultipartBuilder.build_file_map(variable_path="variables.input.document") + + parsed = ujson.loads(result) + assert parsed == {"0": ["variables.input.document"]} + + +def test_build_file_map_both_custom() -> None: + """Test building file map with both custom values.""" + result = MultipartBuilder.build_file_map(file_key="attachment", variable_path="variables.attachment") + + parsed = ujson.loads(result) + assert parsed == {"attachment": ["variables.attachment"]} + + +def test_build_payload_with_file() -> None: + """Test building complete payload with file content.""" + query = "mutation($file: Upload!) { upload(file: $file) { id } }" + variables = {"other": "value"} + file_content = BytesIO(b"test file content") + file_name = "document.pdf" + + result = MultipartBuilder.build_payload( + query=query, variables=variables, file_content=file_content, file_name=file_name + ) + + # Check operations + assert "operations" in result + assert result["operations"][0] is None # No filename for operations + operations_json = ujson.loads(result["operations"][1]) + assert operations_json["query"] == query + assert operations_json["variables"]["other"] == "value" + assert operations_json["variables"]["file"] is None # File var should be null + + # Check map + assert "map" in result + assert result["map"][0] is None + map_json = ujson.loads(result["map"][1]) + assert map_json == {"0": ["variables.file"]} + + # Check file + assert "0" in result + assert result["0"][0] == file_name + assert result["0"][1] is file_content + + +def test_build_payload_without_file() -> None: + """Test building payload without file content.""" + query = "mutation($file: Upload!) { upload(file: $file) { id } }" + variables = {"other": "value"} + + result = MultipartBuilder.build_payload(query=query, variables=variables, file_content=None, file_name="unused.txt") + + # Should have operations and map + assert "operations" in result + assert "map" in result + + # Should NOT have file key + assert "0" not in result + + +def test_build_payload_sets_file_var_to_null() -> None: + """Test that build_payload sets file variable to null per spec.""" + query = "mutation($file: Upload!) { upload(file: $file) { id } }" + variables = {"file": "should_be_overwritten", "other": "value"} + file_content = BytesIO(b"content") + + result = MultipartBuilder.build_payload( + query=query, variables=variables, file_content=file_content, file_name="test.txt" + ) + + operations_json = ujson.loads(result["operations"][1]) + assert operations_json["variables"]["file"] is None + assert operations_json["variables"]["other"] == "value" + + +def test_build_payload_default_filename() -> None: + """Test that default filename is used when not specified.""" + query = "mutation($file: Upload!) { upload(file: $file) { id } }" + file_content = BytesIO(b"content") + + result = MultipartBuilder.build_payload( + query=query, + variables={}, + file_content=file_content, + ) + + assert result["0"][0] == "upload" + + +def test_build_payload_preserves_existing_variables() -> None: + """Test that existing variables are preserved in the payload.""" + query = "mutation($file: Upload!, $nodeId: ID!) { upload(file: $file, node: $nodeId) { id } }" + variables = { + "nodeId": "node-123", + "description": "A test file", + "nested": {"key": "value"}, + } + file_content = BytesIO(b"content") + + result = MultipartBuilder.build_payload( + query=query, + variables=variables, + file_content=file_content, + file_name="test.txt", + ) + + operations_json = ujson.loads(result["operations"][1]) + assert operations_json["variables"]["nodeId"] == "node-123" + assert operations_json["variables"]["description"] == "A test file" + assert operations_json["variables"]["nested"] == {"key": "value"} + assert operations_json["variables"]["file"] is None # file is null per spec diff --git a/tests/unit/sdk/test_file_handler.py b/tests/unit/sdk/test_file_handler.py new file mode 100644 index 00000000..826278cf --- /dev/null +++ b/tests/unit/sdk/test_file_handler.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import tempfile +from io import BytesIO +from pathlib import Path +from typing import TYPE_CHECKING + +import anyio +import httpx +import pytest + +from infrahub_sdk.exceptions import AuthenticationError, NodeNotFoundError +from infrahub_sdk.file_handler import FileHandler, FileHandlerBase, FileHandlerSync, PreparedFile + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + + from tests.unit.sdk.conftest import BothClients + + +FILE_CONTENT_BYTES = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR..." +NODE_ID = "test-node-123" + + +async def test_prepare_upload_with_bytes() -> None: + """Test preparing upload with bytes content (async).""" + content = b"test file content" + prepared = await FileHandlerBase.prepare_upload(content=content, name="test.txt") + + assert isinstance(prepared, PreparedFile) + assert prepared.file_object is not None + assert isinstance(prepared.file_object, BytesIO) + assert prepared.filename == "test.txt" + assert prepared.should_close is False + assert prepared.file_object.read() == content + + +async def test_prepare_upload_with_bytes_default_name() -> None: + """Test preparing upload with bytes content and no name (async).""" + content = b"test file content" + prepared = await FileHandlerBase.prepare_upload(content=content) + + assert prepared.file_object is not None + assert prepared.filename == "uploaded_file" + assert prepared.should_close is False + + +async def test_prepare_upload_with_path() -> None: + """Test preparing upload with Path content (async, opens in thread pool).""" + with tempfile.NamedTemporaryFile(suffix=".txt") as tmp: + tmp.write(b"test content from file") + tmp.flush() + tmp_path = Path(tmp.name) + + prepared = await FileHandlerBase.prepare_upload(content=tmp_path) + + assert prepared.file_object is not None + assert prepared.filename == tmp_path.name + assert prepared.should_close is True + assert prepared.file_object.read() == b"test content from file" + prepared.file_object.close() + + +async def test_prepare_upload_with_path_custom_name() -> None: + """Test preparing upload with Path content and custom name (async).""" + with tempfile.NamedTemporaryFile(suffix=".txt") as tmp: + tmp.write(b"test content") + tmp.flush() + tmp_path = Path(tmp.name) + + prepared = await FileHandlerBase.prepare_upload(content=tmp_path, name="custom_name.txt") + + assert prepared.filename == "custom_name.txt" + assert prepared.file_object + prepared.file_object.close() + + +async def test_prepare_upload_with_binary_io() -> None: + """Test preparing upload with BinaryIO content (async).""" + content = BytesIO(b"binary io content") + prepared = await FileHandlerBase.prepare_upload(content=content, name="binary.bin") + + assert prepared.file_object is content + assert prepared.filename == "binary.bin" + assert prepared.should_close is False + + +async def test_prepare_upload_with_none() -> None: + """Test preparing upload with None content (async).""" + prepared = await FileHandlerBase.prepare_upload(content=None) + + assert prepared.file_object is None + assert prepared.filename is None + assert prepared.should_close is False + + +def test_prepare_upload_sync_with_bytes() -> None: + """Test preparing upload with bytes content (sync).""" + content = b"test file content" + prepared = FileHandlerBase.prepare_upload_sync(content=content, name="test.txt") + + assert isinstance(prepared, PreparedFile) + assert prepared.file_object is not None + assert isinstance(prepared.file_object, BytesIO) + assert prepared.filename == "test.txt" + assert prepared.should_close is False + assert prepared.file_object.read() == content + + +def test_prepare_upload_sync_with_bytes_default_name() -> None: + """Test preparing upload with bytes content and no name (sync).""" + content = b"test file content" + prepared = FileHandlerBase.prepare_upload_sync(content=content) + + assert prepared.file_object is not None + assert prepared.filename == "uploaded_file" + assert prepared.should_close is False + + +def test_prepare_upload_sync_with_path() -> None: + """Test preparing upload with Path content (sync).""" + with tempfile.NamedTemporaryFile(suffix=".txt") as tmp: + tmp.write(b"test content from file") + tmp.flush() + tmp_path = Path(tmp.name) + + prepared = FileHandlerBase.prepare_upload_sync(content=tmp_path) + + assert prepared.file_object is not None + assert prepared.filename == tmp_path.name + assert prepared.should_close is True + assert prepared.file_object.read() == b"test content from file" + prepared.file_object.close() + + +def test_prepare_upload_sync_with_path_custom_name() -> None: + """Test preparing upload with Path content and custom name (sync).""" + with tempfile.NamedTemporaryFile(suffix=".txt") as tmp: + tmp.write(b"test content") + tmp.flush() + tmp_path = Path(tmp.name) + + prepared = FileHandlerBase.prepare_upload_sync(content=tmp_path, name="custom_name.txt") + + assert prepared.filename == "custom_name.txt" + assert prepared.file_object + prepared.file_object.close() + + +def test_prepare_upload_sync_with_binary_io() -> None: + """Test preparing upload with BinaryIO content (sync).""" + content = BytesIO(b"binary io content") + prepared = FileHandlerBase.prepare_upload_sync(content=content, name="binary.bin") + + assert prepared.file_object is content + assert prepared.filename == "binary.bin" + assert prepared.should_close is False + + +def test_prepare_upload_sync_with_none() -> None: + """Test preparing upload with None content (sync).""" + prepared = FileHandlerBase.prepare_upload_sync(content=None) + + assert prepared.file_object is None + assert prepared.filename is None + assert prepared.should_close is False + + +def test_handle_error_response_401() -> None: + """Test handling 401 authentication error.""" + response = httpx.Response(status_code=401, json={"errors": [{"message": "Invalid token"}]}) + exc = httpx.HTTPStatusError(message="Unauthorized", request=httpx.Request("GET", "http://test"), response=response) + + with pytest.raises(AuthenticationError) as excinfo: + FileHandlerBase.handle_error_response(exc=exc) + + assert "Invalid token" in str(excinfo.value) + + +def test_handle_error_response_403() -> None: + """Test handling 403 forbidden error.""" + response = httpx.Response(status_code=403, json={"errors": [{"message": "Access denied"}]}) + exc = httpx.HTTPStatusError(message="Forbidden", request=httpx.Request("GET", "http://test"), response=response) + + with pytest.raises(AuthenticationError) as excinfo: + FileHandlerBase.handle_error_response(exc=exc) + + assert "Access denied" in str(excinfo.value) + + +def test_handle_error_response_404() -> None: + """Test handling 404 not found error.""" + response = httpx.Response(status_code=404, json={"detail": "File not found with ID abc123"}) + exc = httpx.HTTPStatusError(message="Not Found", request=httpx.Request("GET", "http://test"), response=response) + + with pytest.raises(NodeNotFoundError) as excinfo: + FileHandlerBase.handle_error_response(exc=exc) + + assert "File not found with ID abc123" in str(excinfo.value) + + +def test_handle_error_response_500() -> None: + """Test handling 500 server error (re-raises).""" + response = httpx.Response(status_code=500, json={"error": "Internal server error"}) + exc = httpx.HTTPStatusError(message="Server Error", request=httpx.Request("GET", "http://test"), response=response) + + with pytest.raises(httpx.HTTPStatusError): + FileHandlerBase.handle_error_response(exc=exc) + + +def test_handle_response_success() -> None: + """Test handling successful response.""" + request = httpx.Request("GET", "http://test") + response = httpx.Response(status_code=200, content=FILE_CONTENT_BYTES, request=request) + + result = FileHandlerBase.handle_response(resp=response) + + assert result == FILE_CONTENT_BYTES + + +@pytest.fixture +def mock_download_success(httpx_mock: HTTPXMock) -> HTTPXMock: + """Mock successful file download.""" + httpx_mock.add_response( + method="GET", + url="http://mock/api/storage/files/test-node-123?branch=main", + content=FILE_CONTENT_BYTES, + headers={"Content-Type": "application/octet-stream"}, + ) + return httpx_mock + + +@pytest.fixture +def mock_download_stream(httpx_mock: HTTPXMock) -> HTTPXMock: + """Mock successful streaming file download.""" + httpx_mock.add_response( + method="GET", + url="http://mock/api/storage/files/stream-node?branch=main", + content=FILE_CONTENT_BYTES, + headers={"Content-Type": "application/octet-stream"}, + ) + return httpx_mock + + +client_types = ["standard", "sync"] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_file_handler_download_to_memory( + client_type: str, clients: BothClients, mock_download_success: HTTPXMock +) -> None: + """Test downloading file to memory via FileHandler.""" + client = getattr(clients, client_type) + + if client_type == "standard": + handler = FileHandler(client=client) + content = await handler.download(node_id=NODE_ID, branch="main") + else: + handler = FileHandlerSync(client=client) + content = handler.download(node_id=NODE_ID, branch="main") + + assert content == FILE_CONTENT_BYTES + + +@pytest.mark.parametrize("client_type", client_types) +async def test_file_handler_download_to_disk( + client_type: str, clients: BothClients, mock_download_stream: HTTPXMock +) -> None: + """Test streaming file download to disk via FileHandler.""" + client = getattr(clients, client_type) + + with tempfile.TemporaryDirectory() as tmpdir: + dest_path = Path(tmpdir) / "downloaded.bin" + + if client_type == "standard": + handler = FileHandler(client=client) + bytes_written = await handler.download(node_id="stream-node", branch="main", dest=dest_path) + else: + handler = FileHandlerSync(client=client) + bytes_written = handler.download(node_id="stream-node", branch="main", dest=dest_path) + + assert bytes_written == len(FILE_CONTENT_BYTES) + assert await anyio.Path(dest_path).read_bytes() == FILE_CONTENT_BYTES + + +@pytest.mark.parametrize("client_type", client_types) +async def test_file_handler_build_url_with_branch(client_type: str, clients: BothClients) -> None: + """Test URL building with branch parameter.""" + client = getattr(clients, client_type) + + if client_type == "standard": + handler = FileHandler(client=client) + else: + handler = FileHandlerSync(client=client) + + url = handler._build_url(node_id="node-123", branch="feature-branch") + assert url == "http://mock/api/storage/files/node-123?branch=feature-branch" + + +@pytest.mark.parametrize("client_type", client_types) +async def test_file_handler_build_url_without_branch(client_type: str, clients: BothClients) -> None: + """Test URL building without branch parameter.""" + client = getattr(clients, client_type) + + if client_type == "standard": + handler = FileHandler(client=client) + else: + handler = FileHandlerSync(client=client) + + url = handler._build_url(node_id="node-456", branch=None) + assert url == "http://mock/api/storage/files/node-456" diff --git a/tests/unit/sdk/test_file_object.py b/tests/unit/sdk/test_file_object.py new file mode 100644 index 00000000..f6267003 --- /dev/null +++ b/tests/unit/sdk/test_file_object.py @@ -0,0 +1,295 @@ +import tempfile +from pathlib import Path + +import anyio +import pytest +from pytest_httpx import HTTPXMock + +from infrahub_sdk.exceptions import FeatureNotSupportedError +from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync +from infrahub_sdk.schema import NodeSchemaAPI +from tests.unit.sdk.conftest import BothClients + +pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True) + +client_types = ["standard", "sync"] + +FILE_CONTENT = b"Test file content" +FILE_NAME = "contract.pdf" +FILE_MIME_TYPE = "application/pdf" + + +@pytest.fixture +def mock_node_create_with_file(httpx_mock: HTTPXMock) -> HTTPXMock: + """Mock the HTTP response for node create with file upload.""" + httpx_mock.add_response( + method="POST", + json={ + "data": { + "NetworkCircuitContractCreate": { + "ok": True, + "object": { + "id": "new-file-node-123", + "display_label": FILE_NAME, + "file_name": {"value": FILE_NAME}, + "checksum": {"value": "abc123checksum"}, + "file_size": {"value": len(FILE_CONTENT)}, + "file_type": {"value": FILE_MIME_TYPE}, + "storage_id": {"value": "storage-xyz-789"}, + "contract_start": {"value": "2024-01-01T00:00:00Z"}, + "contract_end": {"value": "2024-12-31T23:59:59Z"}, + }, + } + } + }, + is_reusable=True, + ) + return httpx_mock + + +@pytest.fixture +def mock_node_update_with_file(httpx_mock: HTTPXMock) -> HTTPXMock: + """Mock the HTTP response for node update with file upload.""" + httpx_mock.add_response( + method="POST", + json={ + "data": { + "NetworkCircuitContractUpdate": { + "ok": True, + "object": { + "id": "existing-file-node-456", + "display_label": FILE_NAME, + "file_name": {"value": FILE_NAME}, + "checksum": {"value": "updated123checksum"}, + "file_size": {"value": len(FILE_CONTENT)}, + "file_type": {"value": FILE_MIME_TYPE}, + "storage_id": {"value": "storage-updated-789"}, + "contract_start": {"value": "2024-01-01T00:00:00Z"}, + "contract_end": {"value": "2024-12-31T23:59:59Z"}, + }, + } + } + }, + is_reusable=True, + ) + return httpx_mock + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_create_with_file_uses_multipart( + client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI, mock_node_create_with_file: HTTPXMock +) -> None: + """Test that node.save() for create with file content sends a multipart request.""" + client = getattr(clients, client_type) + + if client_type == "standard": + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=client, schema=file_object_schema, branch="main") + + node.contract_start.value = "2024-01-01T00:00:00Z" # type: ignore[union-attr] + node.contract_end.value = "2024-12-31T23:59:59Z" # type: ignore[union-attr] + node.upload_from_bytes(content=FILE_CONTENT, name=FILE_NAME) + + if isinstance(node, InfrahubNode): + await node.save() + else: + node.save() + + requests = mock_node_create_with_file.get_requests() + assert len(requests) == 1 + assert requests[0].headers.get("x-infrahub-tracker") == "mutation-networkcircuitcontract-create" + assert requests[0].headers.get("content-type").startswith("multipart/form-data;") + assert b"Content-Disposition: form-data" in requests[0].content + assert f'filename="{FILE_NAME}"'.encode() in requests[0].content + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_update_with_file_uses_multipart( + client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI, mock_node_update_with_file: HTTPXMock +) -> None: + """Test that node.save() for update with file content sends a multipart request.""" + client = getattr(clients, client_type) + + if client_type == "standard": + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=client, schema=file_object_schema, branch="main") + + # Simulate an existing node + node.id = "existing-file-node-456" + node._existing = True + node.contract_start.value = "2024-01-01T00:00:00Z" # type: ignore[union-attr] + node.contract_end.value = "2024-12-31T23:59:59Z" # type: ignore[union-attr] + node.upload_from_bytes(content=FILE_CONTENT, name=FILE_NAME) + + if isinstance(node, InfrahubNode): + await node.save() + else: + node.save() + + requests = mock_node_update_with_file.get_requests() + assert len(requests) == 1 + assert requests[0].headers.get("x-infrahub-tracker") == "mutation-networkcircuitcontract-update" + assert requests[0].headers.get("content-type").startswith("multipart/form-data;") + assert b"Content-Disposition: form-data" in requests[0].content + assert f'filename="{FILE_NAME}"'.encode() in requests[0].content + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_create_file_object_without_file_raises( + client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI +) -> None: + """Test that creating a FileObject node without file content raises an error.""" + client = getattr(clients, client_type) + + if client_type == "standard": + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=client, schema=file_object_schema, branch="main") + + node.contract_start.value = "2024-01-01T00:00:00Z" # type: ignore[union-attr] + node.contract_end.value = "2024-12-31T23:59:59Z" # type: ignore[union-attr] + + with pytest.raises(ValueError, match=r"Cannot create .* without file content"): + if isinstance(node, InfrahubNode): + await node.save() + else: + node.save() + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_save_clears_file_after_upload( + client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI, mock_node_create_with_file: HTTPXMock +) -> None: + """Test that file content is cleared after successful save.""" + client = getattr(clients, client_type) + + if client_type == "standard": + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=client, schema=file_object_schema, branch="main") + + node.contract_start.value = "2024-01-01T00:00:00Z" # type: ignore[union-attr] + node.contract_end.value = "2024-12-31T23:59:59Z" # type: ignore[union-attr] + + node.upload_from_bytes(content=FILE_CONTENT, name=FILE_NAME) + assert node._file_content is not None + assert node._file_name is not None + + if isinstance(node, InfrahubNode): + await node.save() + else: + node.save() + + # File content should be cleared after save + assert node._file_content is None + assert node._file_name is None + + +@pytest.fixture +def mock_download_file(httpx_mock: HTTPXMock) -> HTTPXMock: + httpx_mock.add_response( + method="GET", + url="http://mock/api/storage/files/file-node-123?branch=main", + content=FILE_CONTENT, + headers={"Content-Type": FILE_MIME_TYPE, "Content-Disposition": f'attachment; filename="{FILE_NAME}"'}, + ) + return httpx_mock + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_download_file( + client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI, mock_download_file: HTTPXMock +) -> None: + """Test downloading a file from a FileObject node.""" + client = getattr(clients, client_type) + + if client_type == "standard": + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=client, schema=file_object_schema, branch="main") + + node.id = "file-node-123" + if isinstance(node, InfrahubNode): + content = await node.download_file() + else: + content = node.download_file() + + assert content == FILE_CONTENT + + +@pytest.fixture +def mock_download_file_to_disk(httpx_mock: HTTPXMock) -> HTTPXMock: + httpx_mock.add_response( + method="GET", + url="http://mock/api/storage/files/file-node-stream?branch=main", + content=FILE_CONTENT, + headers={"Content-Type": FILE_MIME_TYPE, "Content-Disposition": f'attachment; filename="{FILE_NAME}"'}, + ) + return httpx_mock + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_download_file_to_disk( + client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI, mock_download_file_to_disk: HTTPXMock +) -> None: + """Test downloading a file from a FileObject node directly to disk.""" + client = getattr(clients, client_type) + + if client_type == "standard": + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=client, schema=file_object_schema, branch="main") + + node.id = "file-node-stream" + with tempfile.TemporaryDirectory() as tmpdir: + dest_path = Path(tmpdir) / "downloaded.bin" + + if isinstance(node, InfrahubNode): + bytes_written = await node.download_file(dest=dest_path) + else: + bytes_written = node.download_file(dest=dest_path) + + assert bytes_written == len(FILE_CONTENT) + assert await anyio.Path(dest_path).read_bytes() == FILE_CONTENT + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_download_file_not_file_object_raises( + client_type: str, clients: BothClients, non_file_object_schema: NodeSchemaAPI +) -> None: + """Test that download_file raises error on non-FileObject nodes.""" + client = getattr(clients, client_type) + + if client_type == "standard": + node = InfrahubNode(client=client, schema=non_file_object_schema, branch="main") + with pytest.raises( + FeatureNotSupportedError, + match=r"calling download_file is only supported for nodes that inherit from CoreFileObject", + ): + await node.download_file() + else: + node = InfrahubNodeSync(client=client, schema=non_file_object_schema, branch="main") + with pytest.raises( + FeatureNotSupportedError, + match=r"calling download_file is only supported for nodes that inherit from CoreFileObject", + ): + node.download_file() + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_download_file_unsaved_node_raises( + client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI +) -> None: + """Test that download_file raises error on unsaved nodes.""" + client = getattr(clients, client_type) + + if client_type == "standard": + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + with pytest.raises(ValueError, match=r"Cannot download file for a node that hasn't been saved yet"): + await node.download_file() + else: + node = InfrahubNodeSync(client=client, schema=file_object_schema, branch="main") + with pytest.raises(ValueError, match=r"Cannot download file for a node that hasn't been saved yet"): + node.download_file() diff --git a/tests/unit/sdk/test_node.py b/tests/unit/sdk/test_node.py index 8dc18c9b..b517a5f0 100644 --- a/tests/unit/sdk/test_node.py +++ b/tests/unit/sdk/test_node.py @@ -2,11 +2,14 @@ import inspect import ipaddress +import tempfile +from io import BytesIO +from pathlib import Path from typing import TYPE_CHECKING import pytest -from infrahub_sdk.exceptions import NodeNotFoundError +from infrahub_sdk.exceptions import FeatureNotSupportedError, NodeNotFoundError from infrahub_sdk.node import ( InfrahubNode, InfrahubNodeBase, @@ -3271,3 +3274,253 @@ def test_relationship_manager_generate_query_data_without_include_metadata() -> assert "count" in data assert "edges" in data assert "node" in data["edges"] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_is_file_object_true( + client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI +) -> None: + """Test that is_file_object returns True for nodes inheriting from CoreFileObject.""" + if client_type == "standard": + node = InfrahubNode(client=clients.standard, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=clients.sync, schema=file_object_schema, branch="main") + + assert node.is_file_object() + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_is_file_object_false( + client_type: str, clients: BothClients, non_file_object_schema: NodeSchemaAPI +) -> None: + """Test that is_file_object returns False for regular nodes.""" + if client_type == "standard": + node = InfrahubNode(client=clients.standard, schema=non_file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=clients.sync, schema=non_file_object_schema, branch="main") + + assert not node.is_file_object() + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_upload_from_bytes_with_bytes( + client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI +) -> None: + """Test that upload_from_bytes works with bytes on FileObject nodes.""" + if client_type == "standard": + node = InfrahubNode(client=clients.standard, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=clients.sync, schema=file_object_schema, branch="main") + + file_content = b"PDF content here" + node.upload_from_bytes(content=file_content, name="contract.pdf") + + assert node._file_content == file_content + assert node._file_name == "contract.pdf" + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_upload_from_path(client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI) -> None: + """Test that upload_from_path works with a Path object.""" + if client_type == "standard": + node = InfrahubNode(client=clients.standard, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=clients.sync, schema=file_object_schema, branch="main") + + file_content = b"Content from file path" + with tempfile.NamedTemporaryFile(suffix=".pdf") as tmp: + tmp.write(file_content) + tmp.flush() + tmp_path = Path(tmp.name) + + node.upload_from_path(path=tmp_path) + assert node._file_content == tmp_path + assert node._file_name == tmp_path.name + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_upload_from_bytes_with_binary_io( + client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI +) -> None: + """Test that upload_from_bytes works with a BinaryIO object.""" + if client_type == "standard": + node = InfrahubNode(client=clients.standard, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=clients.sync, schema=file_object_schema, branch="main") + + file_content = b"Content from BinaryIO" + file_obj = BytesIO(file_content) + + node.upload_from_bytes(content=file_obj, name="uploaded.pdf") + + assert node._file_content == file_obj + assert node._file_name == "uploaded.pdf" + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_upload_from_bytes_on_non_file_object_raises( + client_type: str, clients: BothClients, non_file_object_schema: NodeSchemaAPI +) -> None: + """Test that upload_from_bytes raises FeatureNotSupportedError on non-FileObject nodes.""" + if client_type == "standard": + node = InfrahubNode(client=clients.standard, schema=non_file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=clients.sync, schema=non_file_object_schema, branch="main") + + with pytest.raises(FeatureNotSupportedError, match=r"File upload is not supported"): + node.upload_from_bytes(content=b"some content", name="file.txt") + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_upload_from_path_on_non_file_object_raises( + client_type: str, clients: BothClients, non_file_object_schema: NodeSchemaAPI +) -> None: + """Test that upload_from_path raises FeatureNotSupportedError on non-FileObject nodes.""" + if client_type == "standard": + node = InfrahubNode(client=clients.standard, schema=non_file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=clients.sync, schema=non_file_object_schema, branch="main") + + with pytest.raises(FeatureNotSupportedError, match=r"File upload is not supported"): + node.upload_from_path(path=Path("/some/file.txt")) + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_clear_file(client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI) -> None: + """Test that clear_file removes pending file content.""" + if client_type == "standard": + node = InfrahubNode(client=clients.standard, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=clients.sync, schema=file_object_schema, branch="main") + + file_content = b"Test content" + file_name = "file.txt" + + node.upload_from_bytes(content=file_content, name=file_name) + assert node._file_content == file_content + assert node._file_name == file_name + + node.clear_file() + assert node._file_content is None + assert node._file_name is None + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_get_file_for_upload_bytes( + client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI +) -> None: + """Test _get_file_for_upload with bytes returns PreparedFile with BytesIO.""" + if client_type == "standard": + node = InfrahubNode(client=clients.standard, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=clients.sync, schema=file_object_schema, branch="main") + + file_content = b"Test content" + file_name = "test.txt" + node.upload_from_bytes(content=file_content, name=file_name) + + if isinstance(node, InfrahubNode): + prepared = await node._get_file_for_upload() + else: + prepared = node._get_file_for_upload_sync() + + assert prepared.file_object + assert prepared.filename == file_name + assert not prepared.should_close + assert prepared.file_object.read() == file_content + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_get_file_for_upload_path( + client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI +) -> None: + """Test _get_file_for_upload with Path returns PreparedFile with opened file handle.""" + if client_type == "standard": + node = InfrahubNode(client=clients.standard, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=clients.sync, schema=file_object_schema, branch="main") + + file_content = b"Content from path" + with tempfile.NamedTemporaryFile(suffix=".pdf") as tmp: + tmp.write(file_content) + tmp.flush() + tmp_path = Path(tmp.name) + + node.upload_from_path(path=tmp_path) + + if isinstance(node, InfrahubNode): + prepared = await node._get_file_for_upload() + else: + prepared = node._get_file_for_upload_sync() + + assert prepared.file_object + assert prepared.filename == tmp_path.name + assert prepared.should_close # Path files should be closed after upload + assert prepared.file_object.read() == file_content + prepared.file_object.close() + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_get_file_for_upload_binary_io( + client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI +) -> None: + """Test _get_file_for_upload with BinaryIO returns PreparedFile with the same object.""" + if client_type == "standard": + node = InfrahubNode(client=clients.standard, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=clients.sync, schema=file_object_schema, branch="main") + + file_content = b"Content from BinaryIO" + file_name = "test.bin" + file_obj_input = BytesIO(file_content) + node.upload_from_bytes(content=file_obj_input, name=file_name) + + if isinstance(node, InfrahubNode): + prepared = await node._get_file_for_upload() + else: + prepared = node._get_file_for_upload_sync() + + assert prepared.file_object is file_obj_input # Should be the same object + assert prepared.filename == file_name + assert not prepared.should_close # BinaryIO provided by user shouldn't be closed + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_get_file_for_upload_none( + client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI +) -> None: + """Test _get_file_for_upload with no file set returns PreparedFile with None values.""" + if client_type == "standard": + node = InfrahubNode(client=clients.standard, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=clients.sync, schema=file_object_schema, branch="main") + + if isinstance(node, InfrahubNode): + prepared = await node._get_file_for_upload() + else: + prepared = node._get_file_for_upload_sync() + + assert prepared.file_object is None + assert prepared.filename is None + assert not prepared.should_close + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_generate_input_data_with_file( + client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI +) -> None: + """Test _generate_input_data places file at mutation level, not inside data.""" + if client_type == "standard": + node = InfrahubNode(client=clients.standard, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=clients.sync, schema=file_object_schema, branch="main") + + node.upload_from_bytes(content=b"test content", name="test.txt") + + input_data = node._generate_input_data() + + assert "file" in input_data["data"], "file should be at mutation payload level" + assert input_data["data"]["file"] == "$file" + assert "file" not in input_data["data"]["data"], "file should not be inside nested data dict" + assert "file" in input_data["mutation_variables"] + assert input_data["mutation_variables"]["file"] is bytes From 2e6c3f82ff6cb5b5435cc6ce67bcbcd7988b01cb Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Fri, 6 Feb 2026 17:49:25 +0100 Subject: [PATCH 07/89] Fix infrahubctl proposed change table generation (#805) This was broken because the API server will now expose the `created_by` and `created_at` values inside the node metadata. Also add `get_node_metadata` to `CoreNodeBase` protocol. --- .../+infrahubctl-proposedchange.changed.md | 1 + infrahub_sdk/ctl/branch.py | 10 ++-- infrahub_sdk/protocols_base.py | 3 ++ tests/unit/ctl/test_branch_report.py | 48 ++++++++++--------- 4 files changed, 37 insertions(+), 25 deletions(-) create mode 100644 changelog/+infrahubctl-proposedchange.changed.md diff --git a/changelog/+infrahubctl-proposedchange.changed.md b/changelog/+infrahubctl-proposedchange.changed.md new file mode 100644 index 00000000..9b75045d --- /dev/null +++ b/changelog/+infrahubctl-proposedchange.changed.md @@ -0,0 +1 @@ +Updated branch report command to use node metadata for proposed change creator information instead of the deprecated relationship-based approach. Requires Infrahub 1.7 or above. diff --git a/infrahub_sdk/ctl/branch.py b/infrahub_sdk/ctl/branch.py index 4182f56e..d309c1d5 100644 --- a/infrahub_sdk/ctl/branch.py +++ b/infrahub_sdk/ctl/branch.py @@ -110,6 +110,10 @@ def generate_proposed_change_tables(proposed_changes: list[CoreProposedChange]) proposed_change_tables: list[Table] = [] for pc in proposed_changes: + metadata = pc.get_node_metadata() + created_by = metadata.created_by.display_label if metadata and metadata.created_by else "-" + created_at = format_timestamp(metadata.created_at) if metadata and metadata.created_at else "-" + # Create proposal table proposed_change_table = Table(show_header=False, box=None) proposed_change_table.add_column(justify="left") @@ -119,8 +123,8 @@ def generate_proposed_change_tables(proposed_changes: list[CoreProposedChange]) proposed_change_table.add_row("Name", pc.name.value) proposed_change_table.add_row("State", str(pc.state.value)) proposed_change_table.add_row("Is draft", "Yes" if pc.is_draft.value else "No") - proposed_change_table.add_row("Created by", pc.created_by.peer.name.value) # type: ignore[attr-defined] - proposed_change_table.add_row("Created at", format_timestamp(str(pc.created_by.updated_at))) # type: ignore[attr-defined] + proposed_change_table.add_row("Created by", created_by) + proposed_change_table.add_row("Created at", created_at) proposed_change_table.add_row("Approvals", str(len(pc.approved_by.peers))) proposed_change_table.add_row("Rejections", str(len(pc.rejected_by.peers))) @@ -295,9 +299,9 @@ async def report( proposed_changes = await client.filters( kind=CoreProposedChange, # type: ignore[type-abstract] source_branch__value=branch_name, - include=["created_by"], prefetch_relationships=True, property=True, + include_metadata=True, ) branch_table = generate_branch_report_table(branch=branch, diff_tree=diff_tree, git_files_changed=git_files_changed) diff --git a/infrahub_sdk/protocols_base.py b/infrahub_sdk/protocols_base.py index 8a841b5b..7f6569ae 100644 --- a/infrahub_sdk/protocols_base.py +++ b/infrahub_sdk/protocols_base.py @@ -6,6 +6,7 @@ import ipaddress from .context import RequestContext + from .node.metadata import NodeMetadata from .schema import MainSchemaTypes @@ -203,6 +204,8 @@ def is_resource_pool(self) -> bool: ... def get_raw_graphql_data(self) -> dict | None: ... + def get_node_metadata(self) -> NodeMetadata | None: ... + @runtime_checkable class CoreNode(CoreNodeBase, Protocol): diff --git a/tests/unit/ctl/test_branch_report.py b/tests/unit/ctl/test_branch_report.py index c9af76c5..201f1c92 100644 --- a/tests/unit/ctl/test_branch_report.py +++ b/tests/unit/ctl/test_branch_report.py @@ -341,19 +341,21 @@ def mock_branch_report_with_proposed_changes(httpx_mock: HTTPXMock) -> HTTPXMock ], }, "rejected_by": {"count": 0, "edges": []}, + }, + "node_metadata": { + "created_at": "2025-11-10T14:30:00Z", "created_by": { - "node": { - "id": "187895d8-723e-8f5d-3614-c517ac8e761c", - "hfid": ["johndoe"], - "display_label": "John Doe", - "__typename": "CoreAccount", - "name": {"value": "John Doe"}, - }, - "properties": { - "updated_at": "2025-11-10T14:30:00Z", - }, + "id": "187895d8-723e-8f5d-3614-c517ac8e761c", + "__typename": "CoreAccount", + "display_label": "John Doe", }, - } + "updated_at": "2025-11-10T14:30:00Z", + "updated_by": { + "id": "187895d8-723e-8f5d-3614-c517ac8e761c", + "__typename": "CoreAccount", + "display_label": "John Doe", + }, + }, }, { "node": { @@ -392,19 +394,21 @@ def mock_branch_report_with_proposed_changes(httpx_mock: HTTPXMock) -> HTTPXMock }, ], }, + }, + "node_metadata": { + "created_at": "2025-11-12T09:15:00Z", "created_by": { - "node": { - "id": "287895d8-723e-8f5d-3614-c517ac8e762c", - "hfid": ["janesmith"], - "display_label": "Jane Smith", - "__typename": "CoreAccount", - "name": {"value": "Jane Smith"}, - }, - "properties": { - "updated_at": "2025-11-10T14:30:00Z", - }, + "id": "287895d8-723e-8f5d-3614-c517ac8e762c", + "__typename": "CoreAccount", + "display_label": "Jane Smith", + }, + "updated_at": "2025-11-12T09:15:00Z", + "updated_by": { + "id": "287895d8-723e-8f5d-3614-c517ac8e762c", + "__typename": "CoreAccount", + "display_label": "Jane Smith", }, - } + }, }, ], } From 829800d3681e92ed949585ad736fe3e223db81d2 Mon Sep 17 00:00:00 2001 From: Babatunde Olusola Date: Mon, 9 Feb 2026 04:42:00 +0100 Subject: [PATCH 08/89] IFC-2184: Add to branch status (#794) --- infrahub_sdk/branch.py | 1 + 1 file changed, 1 insertion(+) diff --git a/infrahub_sdk/branch.py b/infrahub_sdk/branch.py index 2c32a481..4c89bd41 100644 --- a/infrahub_sdk/branch.py +++ b/infrahub_sdk/branch.py @@ -19,6 +19,7 @@ class BranchStatus(str, Enum): NEED_REBASE = "NEED_REBASE" NEED_UPGRADE_REBASE = "NEED_UPGRADE_REBASE" DELETING = "DELETING" + MERGED = "MERGED" class BranchData(BaseModel): From 7acb4d823939d05d9045e2bd5598971712ebe5ef Mon Sep 17 00:00:00 2001 From: Babatunde Olusola Date: Mon, 9 Feb 2026 09:41:13 +0100 Subject: [PATCH 09/89] migrate from pre-commit to prek (#789) --- .pre-commit-config.yaml | 2 +- pyproject.toml | 2 +- uv.lock | 104 ++++++++++------------------------------ 3 files changed, 28 insertions(+), 80 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 537c7969..1c393efd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.11.9 + rev: v0.14.10 hooks: # Run the linter. - id: ruff diff --git a/pyproject.toml b/pyproject.toml index 0c5ac918..37838059 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,7 @@ dev = [ {include-group = "types"}, "ipython", "requests", - "pre-commit>=2.20.0", + "prek>=0.3.0", "codecov", "invoke>=2.2.0", "towncrier>=24.8.0", diff --git a/uv.lock b/uv.lock index 8bb4349d..5407cb7e 100644 --- a/uv.lock +++ b/uv.lock @@ -177,15 +177,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, ] -[[package]] -name = "cfgv" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, -] - [[package]] name = "charset-normalizer" version = "3.4.4" @@ -455,15 +446,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] -[[package]] -name = "distlib" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, -] - [[package]] name = "docker" version = "7.1.0" @@ -560,15 +542,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/23/dfb161e91db7c92727db505dc72a384ee79681fe0603f706f9f9f52c2901/fastapi-0.121.2-py3-none-any.whl", hash = "sha256:f2d80b49a86a846b70cc3a03eb5ea6ad2939298bf6a7fe377aa9cd3dd079d358", size = 109201, upload-time = "2025-11-13T17:05:52.718Z" }, ] -[[package]] -name = "filelock" -version = "3.20.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, -] - [[package]] name = "fsspec" version = "2025.10.0" @@ -690,15 +663,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, ] -[[package]] -name = "identify" -version = "2.6.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, -] - [[package]] name = "idna" version = "3.11" @@ -770,7 +734,7 @@ dev = [ { name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "mypy" }, - { name = "pre-commit" }, + { name = "prek" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-clarity" }, @@ -849,7 +813,7 @@ dev = [ { name = "invoke", specifier = ">=2.2.0" }, { name = "ipython" }, { name = "mypy", specifier = "==1.11.2" }, - { name = "pre-commit", specifier = ">=2.20.0" }, + { name = "prek", specifier = ">=0.3.0" }, { name = "pytest", specifier = ">=9.0,<9.1" }, { name = "pytest-asyncio", specifier = ">=1.3,<1.4" }, { name = "pytest-clarity", specifier = ">=1.0.1" }, @@ -1232,15 +1196,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/a2/20e3cc569b3a41cc36181212c99f3e3c0aa9201174b2bf99313328824a2b/netutils-1.15.1-py3-none-any.whl", hash = "sha256:c42886d456f9b21bee395628b100dc2cd4b68fcc223f33c669672c3468d6b4dc", size = 532245, upload-time = "2025-10-21T00:41:06.141Z" }, ] -[[package]] -name = "nodeenv" -version = "1.9.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, -] - [[package]] name = "numpy" version = "2.2.6" @@ -1611,22 +1566,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/d1/e4ed95fdd3ef13b78630280d9e9e240aeb65cc7c544ec57106149c3942fb/pprintpp-0.4.0-py2.py3-none-any.whl", hash = "sha256:b6b4dcdd0c0c0d75e4d7b2f21a9e933e5b2ce62b26e1a54537f9651ae5a5c01d", size = 16952, upload-time = "2018-07-01T01:42:36.496Z" }, ] -[[package]] -name = "pre-commit" -version = "4.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cfgv" }, - { name = "identify" }, - { name = "nodeenv" }, - { name = "pyyaml" }, - { name = "virtualenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, -] - [[package]] name = "prefect-client" version = "3.6.13" @@ -1678,6 +1617,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/51/0216ef9c7ca6002e7b5ae92cdb2858e4f8c5d69c7f2a4a9050afc1086934/prefect_client-3.6.13-py3-none-any.whl", hash = "sha256:3076194ec12b3770e53b1cb8f1d68a7628b8658912e183431a398d7e1617570d", size = 899733, upload-time = "2026-01-23T04:17:47.825Z" }, ] +[[package]] +name = "prek" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/1e/6c23d3470145be1d6ff29d93f2a521864788827d22e509e2b978eb5bb4cb/prek-0.3.0.tar.gz", hash = "sha256:e70f16bbaf2803e490b866cfa997ea5cc46e7ada55d61f0cdd84bc90b8d5ca7f", size = 316063, upload-time = "2026-01-22T04:00:01.648Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/49/469219c19bb00db678806f79fc084ac1ce9952004a183a798db26f6df22b/prek-0.3.0-py3-none-linux_armv6l.whl", hash = "sha256:7e5d40b22deff23e36f7ad91e24b8e62edf32f30f6dad420459f7ec7188233c3", size = 4317493, upload-time = "2026-01-22T03:59:51.769Z" }, + { url = "https://files.pythonhosted.org/packages/87/9f/f7afc49cc0fd92d1ba492929dc1573cb7004d09b61341aa6ee32a5288657/prek-0.3.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6712b58cbb5a7db0aaef180c489ce9f3462e0293d54e54baeedd75fc0d9d8c28", size = 4323961, upload-time = "2026-01-22T03:59:56.92Z" }, + { url = "https://files.pythonhosted.org/packages/42/94/ba36dc29e71d476bf71c3bac2b0c89cfcfc4b8973a0a6b20728f429f4560/prek-0.3.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5f2c446fd9012a98c5690b4badf3f7dfb8d424cf0c6798a2d08ee56511f0a670", size = 3970121, upload-time = "2026-01-22T03:59:55.722Z" }, + { url = "https://files.pythonhosted.org/packages/b5/93/6131dd9f6cde3d72815b978b766de21b2ac9cc15fc38f5c22267cc7e574d/prek-0.3.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:10f3da7cda2397f7d2f3ff7f2be0d7486c15d4941f7568095b7168e57a9c88c5", size = 4307430, upload-time = "2026-01-22T03:59:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/7c55a765d96028d38dc984e66a096a969d80e56f66a47801acc86dede856/prek-0.3.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2f747bb4a4322fea35d548cd2c1bd24477f56ed009f3d62a2b97ecbfc88096ac", size = 4238032, upload-time = "2026-01-22T04:00:02.606Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a7/59d9bf902b749c8a0cef9e8ac073cc5c886634cd09404c00af4a76470b3b/prek-0.3.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40bd61f11d8caabc0e2a5d4c326639d6ff558b580ef4388aabec293ddb5afd35", size = 4493295, upload-time = "2026-01-22T03:59:45.964Z" }, + { url = "https://files.pythonhosted.org/packages/08/dc/902b2e4ddff59ad001ddc2cda3b47e457ab1ee811698a4002b3e4f84faf1/prek-0.3.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d096b5e273d17a1300b20a7101a9e5a624a8104825eb59659776177f7fccea1", size = 5033370, upload-time = "2026-01-22T03:59:44.806Z" }, + { url = "https://files.pythonhosted.org/packages/15/cd/277a3d2768b80bb1ff3c2ea8378687bb4c527d88a8b543bf6f364f8a0dc9/prek-0.3.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df39face5f1298851fbae495267ddf60f1694ea594ed5c6cdb88bdd6de14f6a4", size = 4549792, upload-time = "2026-01-22T03:59:41.518Z" }, + { url = "https://files.pythonhosted.org/packages/26/21/53aeabd3822ef7fa350aac66d099d4d97b05e8383a2df35499229389a642/prek-0.3.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9462f80a576d661490aa058d4493a991a34c7532dea76b7b004a17c8bc6b80f2", size = 4323158, upload-time = "2026-01-22T03:59:54.284Z" }, + { url = "https://files.pythonhosted.org/packages/27/c2/3a7392b0e7fd07e339d89701b49b12a89d85256a57279877195028215957/prek-0.3.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:33d3fa40eecf996ed14bab2d006c39d21ae344677d62599963efd9b27936558e", size = 4344632, upload-time = "2026-01-22T04:00:03.71Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/8254ac981d75d0ce2826bcac74fed901540d629cb2d9f4d73ce62f8ce843/prek-0.3.0-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:d8c6abfd53a23718afdf4e6107418db1d74c5d904e9b7ec7900e285f8da90723", size = 4216608, upload-time = "2026-01-22T03:59:58.527Z" }, + { url = "https://files.pythonhosted.org/packages/20/f5/854d57d89376fac577ee647a1dba1b87e27b2baeca7edc3d40295adeb7c8/prek-0.3.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:eb4c80c3e7c0e16bf307947809112bfef3715a1b83c2b03f5937707934635617", size = 4371174, upload-time = "2026-01-22T03:59:53.088Z" }, + { url = "https://files.pythonhosted.org/packages/03/38/8927619411da8d3f189415c452ec7a463f09dea69e272888723f37b4b18f/prek-0.3.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:602bcce070c50900167acd89dcdf95d27894412f8a7b549c8eb66de612a99653", size = 4659113, upload-time = "2026-01-22T03:59:43.166Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4d/16baeef633b8b230dde878b858c0e955149c860feef518b5eb5aac640eec/prek-0.3.0-py3-none-win32.whl", hash = "sha256:a69229365ce33c68c05db7ae73ad1ef8bc7f0914ab3bc484ab7781256bcdfb7a", size = 3937103, upload-time = "2026-01-22T03:59:48.719Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f2/c7395b4afd1bba32cad2b24c30fd7781e94c1e41137348cd150bbef001d6/prek-0.3.0-py3-none-win_amd64.whl", hash = "sha256:a0379afd8d31bd5da6ee8977820fdb3c30601bed836b39761e6f605451dbccaa", size = 4290763, upload-time = "2026-01-22T03:59:59.938Z" }, + { url = "https://files.pythonhosted.org/packages/df/83/97ed76ab5470025992cd50cb1ebdeb21fcf6c25459f9ffc49ac7bf040cf4/prek-0.3.0-py3-none-win_arm64.whl", hash = "sha256:82e2c64f75dc1ea6f2023f4322500eb8da5d0557baf06c88677bddf163e1542a", size = 4041580, upload-time = "2026-01-22T03:59:50.082Z" }, +] + [[package]] name = "prometheus-client" version = "0.23.1" @@ -2940,21 +2903,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, ] -[[package]] -name = "virtualenv" -version = "20.36.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "distlib" }, - { name = "filelock" }, - { name = "platformdirs" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, -] - [[package]] name = "wcwidth" version = "0.2.14" From c340321ba33952cf161dbde0b0ffdfdbe40b30d9 Mon Sep 17 00:00:00 2001 From: "infrahub-github-bot-app[bot]" <190746546+infrahub-github-bot-app[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:09:33 +0100 Subject: [PATCH 10/89] migrate from pre-commit to prek (#789) (#808) Co-authored-by: Babatunde Olusola --- .pre-commit-config.yaml | 2 +- pyproject.toml | 2 +- uv.lock | 104 ++++++++++------------------------------ 3 files changed, 28 insertions(+), 80 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 537c7969..1c393efd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.11.9 + rev: v0.14.10 hooks: # Run the linter. - id: ruff diff --git a/pyproject.toml b/pyproject.toml index 461e25b1..a5a74ec6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,7 @@ dev = [ {include-group = "types"}, "ipython", "requests", - "pre-commit>=2.20.0", + "prek>=0.3.0", "codecov", "invoke>=2.2.0", "towncrier>=24.8.0", diff --git a/uv.lock b/uv.lock index 8bb4349d..5407cb7e 100644 --- a/uv.lock +++ b/uv.lock @@ -177,15 +177,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, ] -[[package]] -name = "cfgv" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, -] - [[package]] name = "charset-normalizer" version = "3.4.4" @@ -455,15 +446,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] -[[package]] -name = "distlib" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, -] - [[package]] name = "docker" version = "7.1.0" @@ -560,15 +542,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/23/dfb161e91db7c92727db505dc72a384ee79681fe0603f706f9f9f52c2901/fastapi-0.121.2-py3-none-any.whl", hash = "sha256:f2d80b49a86a846b70cc3a03eb5ea6ad2939298bf6a7fe377aa9cd3dd079d358", size = 109201, upload-time = "2025-11-13T17:05:52.718Z" }, ] -[[package]] -name = "filelock" -version = "3.20.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, -] - [[package]] name = "fsspec" version = "2025.10.0" @@ -690,15 +663,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, ] -[[package]] -name = "identify" -version = "2.6.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, -] - [[package]] name = "idna" version = "3.11" @@ -770,7 +734,7 @@ dev = [ { name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "mypy" }, - { name = "pre-commit" }, + { name = "prek" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-clarity" }, @@ -849,7 +813,7 @@ dev = [ { name = "invoke", specifier = ">=2.2.0" }, { name = "ipython" }, { name = "mypy", specifier = "==1.11.2" }, - { name = "pre-commit", specifier = ">=2.20.0" }, + { name = "prek", specifier = ">=0.3.0" }, { name = "pytest", specifier = ">=9.0,<9.1" }, { name = "pytest-asyncio", specifier = ">=1.3,<1.4" }, { name = "pytest-clarity", specifier = ">=1.0.1" }, @@ -1232,15 +1196,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/a2/20e3cc569b3a41cc36181212c99f3e3c0aa9201174b2bf99313328824a2b/netutils-1.15.1-py3-none-any.whl", hash = "sha256:c42886d456f9b21bee395628b100dc2cd4b68fcc223f33c669672c3468d6b4dc", size = 532245, upload-time = "2025-10-21T00:41:06.141Z" }, ] -[[package]] -name = "nodeenv" -version = "1.9.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, -] - [[package]] name = "numpy" version = "2.2.6" @@ -1611,22 +1566,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/d1/e4ed95fdd3ef13b78630280d9e9e240aeb65cc7c544ec57106149c3942fb/pprintpp-0.4.0-py2.py3-none-any.whl", hash = "sha256:b6b4dcdd0c0c0d75e4d7b2f21a9e933e5b2ce62b26e1a54537f9651ae5a5c01d", size = 16952, upload-time = "2018-07-01T01:42:36.496Z" }, ] -[[package]] -name = "pre-commit" -version = "4.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cfgv" }, - { name = "identify" }, - { name = "nodeenv" }, - { name = "pyyaml" }, - { name = "virtualenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, -] - [[package]] name = "prefect-client" version = "3.6.13" @@ -1678,6 +1617,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/51/0216ef9c7ca6002e7b5ae92cdb2858e4f8c5d69c7f2a4a9050afc1086934/prefect_client-3.6.13-py3-none-any.whl", hash = "sha256:3076194ec12b3770e53b1cb8f1d68a7628b8658912e183431a398d7e1617570d", size = 899733, upload-time = "2026-01-23T04:17:47.825Z" }, ] +[[package]] +name = "prek" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/1e/6c23d3470145be1d6ff29d93f2a521864788827d22e509e2b978eb5bb4cb/prek-0.3.0.tar.gz", hash = "sha256:e70f16bbaf2803e490b866cfa997ea5cc46e7ada55d61f0cdd84bc90b8d5ca7f", size = 316063, upload-time = "2026-01-22T04:00:01.648Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/49/469219c19bb00db678806f79fc084ac1ce9952004a183a798db26f6df22b/prek-0.3.0-py3-none-linux_armv6l.whl", hash = "sha256:7e5d40b22deff23e36f7ad91e24b8e62edf32f30f6dad420459f7ec7188233c3", size = 4317493, upload-time = "2026-01-22T03:59:51.769Z" }, + { url = "https://files.pythonhosted.org/packages/87/9f/f7afc49cc0fd92d1ba492929dc1573cb7004d09b61341aa6ee32a5288657/prek-0.3.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6712b58cbb5a7db0aaef180c489ce9f3462e0293d54e54baeedd75fc0d9d8c28", size = 4323961, upload-time = "2026-01-22T03:59:56.92Z" }, + { url = "https://files.pythonhosted.org/packages/42/94/ba36dc29e71d476bf71c3bac2b0c89cfcfc4b8973a0a6b20728f429f4560/prek-0.3.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5f2c446fd9012a98c5690b4badf3f7dfb8d424cf0c6798a2d08ee56511f0a670", size = 3970121, upload-time = "2026-01-22T03:59:55.722Z" }, + { url = "https://files.pythonhosted.org/packages/b5/93/6131dd9f6cde3d72815b978b766de21b2ac9cc15fc38f5c22267cc7e574d/prek-0.3.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:10f3da7cda2397f7d2f3ff7f2be0d7486c15d4941f7568095b7168e57a9c88c5", size = 4307430, upload-time = "2026-01-22T03:59:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/7c55a765d96028d38dc984e66a096a969d80e56f66a47801acc86dede856/prek-0.3.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2f747bb4a4322fea35d548cd2c1bd24477f56ed009f3d62a2b97ecbfc88096ac", size = 4238032, upload-time = "2026-01-22T04:00:02.606Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a7/59d9bf902b749c8a0cef9e8ac073cc5c886634cd09404c00af4a76470b3b/prek-0.3.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40bd61f11d8caabc0e2a5d4c326639d6ff558b580ef4388aabec293ddb5afd35", size = 4493295, upload-time = "2026-01-22T03:59:45.964Z" }, + { url = "https://files.pythonhosted.org/packages/08/dc/902b2e4ddff59ad001ddc2cda3b47e457ab1ee811698a4002b3e4f84faf1/prek-0.3.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d096b5e273d17a1300b20a7101a9e5a624a8104825eb59659776177f7fccea1", size = 5033370, upload-time = "2026-01-22T03:59:44.806Z" }, + { url = "https://files.pythonhosted.org/packages/15/cd/277a3d2768b80bb1ff3c2ea8378687bb4c527d88a8b543bf6f364f8a0dc9/prek-0.3.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df39face5f1298851fbae495267ddf60f1694ea594ed5c6cdb88bdd6de14f6a4", size = 4549792, upload-time = "2026-01-22T03:59:41.518Z" }, + { url = "https://files.pythonhosted.org/packages/26/21/53aeabd3822ef7fa350aac66d099d4d97b05e8383a2df35499229389a642/prek-0.3.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9462f80a576d661490aa058d4493a991a34c7532dea76b7b004a17c8bc6b80f2", size = 4323158, upload-time = "2026-01-22T03:59:54.284Z" }, + { url = "https://files.pythonhosted.org/packages/27/c2/3a7392b0e7fd07e339d89701b49b12a89d85256a57279877195028215957/prek-0.3.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:33d3fa40eecf996ed14bab2d006c39d21ae344677d62599963efd9b27936558e", size = 4344632, upload-time = "2026-01-22T04:00:03.71Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/8254ac981d75d0ce2826bcac74fed901540d629cb2d9f4d73ce62f8ce843/prek-0.3.0-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:d8c6abfd53a23718afdf4e6107418db1d74c5d904e9b7ec7900e285f8da90723", size = 4216608, upload-time = "2026-01-22T03:59:58.527Z" }, + { url = "https://files.pythonhosted.org/packages/20/f5/854d57d89376fac577ee647a1dba1b87e27b2baeca7edc3d40295adeb7c8/prek-0.3.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:eb4c80c3e7c0e16bf307947809112bfef3715a1b83c2b03f5937707934635617", size = 4371174, upload-time = "2026-01-22T03:59:53.088Z" }, + { url = "https://files.pythonhosted.org/packages/03/38/8927619411da8d3f189415c452ec7a463f09dea69e272888723f37b4b18f/prek-0.3.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:602bcce070c50900167acd89dcdf95d27894412f8a7b549c8eb66de612a99653", size = 4659113, upload-time = "2026-01-22T03:59:43.166Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4d/16baeef633b8b230dde878b858c0e955149c860feef518b5eb5aac640eec/prek-0.3.0-py3-none-win32.whl", hash = "sha256:a69229365ce33c68c05db7ae73ad1ef8bc7f0914ab3bc484ab7781256bcdfb7a", size = 3937103, upload-time = "2026-01-22T03:59:48.719Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f2/c7395b4afd1bba32cad2b24c30fd7781e94c1e41137348cd150bbef001d6/prek-0.3.0-py3-none-win_amd64.whl", hash = "sha256:a0379afd8d31bd5da6ee8977820fdb3c30601bed836b39761e6f605451dbccaa", size = 4290763, upload-time = "2026-01-22T03:59:59.938Z" }, + { url = "https://files.pythonhosted.org/packages/df/83/97ed76ab5470025992cd50cb1ebdeb21fcf6c25459f9ffc49ac7bf040cf4/prek-0.3.0-py3-none-win_arm64.whl", hash = "sha256:82e2c64f75dc1ea6f2023f4322500eb8da5d0557baf06c88677bddf163e1542a", size = 4041580, upload-time = "2026-01-22T03:59:50.082Z" }, +] + [[package]] name = "prometheus-client" version = "0.23.1" @@ -2940,21 +2903,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, ] -[[package]] -name = "virtualenv" -version = "20.36.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "distlib" }, - { name = "filelock" }, - { name = "platformdirs" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, -] - [[package]] name = "wcwidth" version = "0.2.14" From ceb7e611b190995a2e0744dc9ae05cbb5e4b2378 Mon Sep 17 00:00:00 2001 From: Antoine Delannoy Date: Wed, 23 Jul 2025 10:07:36 +0200 Subject: [PATCH 11/89] docs: fix docusaurus icon interpretation IHS-198 --- docs/package-lock.json | 22 ++++++++++++++++++++++ docs/package.json | 1 + docs/src/theme/MDXComponents.js | 10 ++++++++++ 3 files changed, 33 insertions(+) create mode 100644 docs/src/theme/MDXComponents.js diff --git a/docs/package-lock.json b/docs/package-lock.json index 6a594bda..348ea86e 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@docusaurus/core": "^3.8.1", "@docusaurus/preset-classic": "^3.8.1", + "@iconify/react": "^6.0.0", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", @@ -4020,6 +4021,27 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@iconify/react": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@iconify/react/-/react-6.0.0.tgz", + "integrity": "sha512-eqNscABVZS8eCpZLU/L5F5UokMS9mnCf56iS1nM9YYHdH8ZxqZL9zyjSwW60IOQFsXZkilbBiv+1paMXBhSQnw==", + "license": "MIT", + "dependencies": { + "@iconify/types": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/cyberalien" + }, + "peerDependencies": { + "react": ">=16" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", diff --git a/docs/package.json b/docs/package.json index 0dc1e714..0816be34 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,6 +17,7 @@ "dependencies": { "@docusaurus/core": "^3.8.1", "@docusaurus/preset-classic": "^3.8.1", + "@iconify/react": "^6.0.0", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", diff --git a/docs/src/theme/MDXComponents.js b/docs/src/theme/MDXComponents.js new file mode 100644 index 00000000..cea81a13 --- /dev/null +++ b/docs/src/theme/MDXComponents.js @@ -0,0 +1,10 @@ +import React from 'react'; +// Import the original mapper +import MDXComponents from '@theme-original/MDXComponents'; +import { Icon } from '@iconify/react'; // Import the entire Iconify library. + +export default { + // Re-use the default mapping + ...MDXComponents, + Icon: Icon, // Make the iconify Icon component available in MDX as . +}; \ No newline at end of file From 8175d228d5066fa28d35c8625b0dfc85be310cd6 Mon Sep 17 00:00:00 2001 From: Antoine Delannoy Date: Wed, 23 Jul 2025 10:08:02 +0200 Subject: [PATCH 12/89] docs: Docusaurus sidebar configuration -> new sdk reference section IHS-198 --- docs/sidebars-python-sdk.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/sidebars-python-sdk.ts b/docs/sidebars-python-sdk.ts index e2fc932c..49d7a606 100644 --- a/docs/sidebars-python-sdk.ts +++ b/docs/sidebars-python-sdk.ts @@ -1,4 +1,4 @@ -import type {SidebarsConfig} from '@docusaurus/plugin-content-docs'; +import type { SidebarsConfig } from '@docusaurus/plugin-content-docs'; const sidebars: SidebarsConfig = { pythonSdkSidebar: [ @@ -39,6 +39,16 @@ const sidebars: SidebarsConfig = { type: 'category', label: 'Reference', items: [ + { + type: 'category', + label: 'Python SDK API', + items: [ + { + type: 'autogenerated', + dirName: 'sdk_ref', + }, + ], + }, 'reference/config', 'reference/templating', ], From 4a8818eaaa7cb14b5299b15a51b3691df6428aa8 Mon Sep 17 00:00:00 2001 From: Antoine Delannoy Date: Wed, 23 Jul 2025 11:14:14 +0200 Subject: [PATCH 13/89] feat: new invoke command generate-sdk-api-docs IHS-196 --- tasks.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tasks.py b/tasks.py index b2434df0..475c9152 100644 --- a/tasks.py +++ b/tasks.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import json import sys @@ -281,3 +283,49 @@ def generate_repository_jsonschema(context: Context) -> None: repository_jsonschema.parent.mkdir(parents=True, exist_ok=True) repository_jsonschema.write_text(schema) print(f"Wrote to {repository_jsonschema}") + + +@task(name="generate-sdk-api-docs") +def generate_sdk_api_docs(context: Context, output: str | None = None) -> None: + """Generate API documentation for the Python SDK.""" + + # This is the list of code modules to generate documentation for. + MODULES_LIST = [ + "infrahub_sdk.client", + "infrahub_sdk.node.node", + ] + + import operator + import shutil + import tempfile + from functools import reduce + + output_dir = Path(output) if output else DOCUMENTATION_DIRECTORY / "docs" / "python-sdk" / "sdk_ref" + + # Create a temporary directory to store the generated documentation + with tempfile.TemporaryDirectory() as tmp_dir: + # Generate the API documentation using mdxify and get flat file structure + exec_cmd = f"mdxify {' '.join(MODULES_LIST)} --output-dir {tmp_dir}" + context.run(exec_cmd, pty=True) + + # Remove current obsolete documentation file structure + if (output_dir / "infrahub_sdk").exists(): + shutil.rmtree(output_dir / "infrahub_sdk") + + # Get all .mdx files in the generated doc folder and apply filters + filters = ["__init__"] + filtered_files = [ + file + for file in list(Path(tmp_dir).glob("*.mdx")) + if all(filter.lower() not in file.name for filter in filters) + ] + + # Reorganize the generated relevant files into the desired structure + for mdx_file in filtered_files: + target_path = output_dir / reduce(operator.truediv, (Path(part) for part in mdx_file.name.split("-"))) + + # Create the future parent directory if it doesn't exist + target_path.parent.mkdir(parents=True, exist_ok=True) + + # Move the file to the new location + shutil.move(mdx_file, target_path) From 673fd757b0ddeb299ebbcd822e8fd074a434a12a Mon Sep 17 00:00:00 2001 From: Antoine Delannoy Date: Wed, 23 Jul 2025 11:31:03 +0200 Subject: [PATCH 14/89] chore: add mdxify package IHS-196 --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index c2e471cd..6233ce07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ ctl = [ "typer>=0.12.5", "click==8.1.*", "ariadne-codegen==0.15.3", + "mdxify>=0.2.23; python_version>='3.10'", ] all = [ From a6d8d05b61487f801489d10ae3d90e30fbcbb5a9 Mon Sep 17 00:00:00 2001 From: Antoine Delannoy Date: Wed, 23 Jul 2025 14:54:51 +0200 Subject: [PATCH 15/89] feat: new ci check to ensure sdk api documentation is up to date IHS-197 --- .github/workflows/ci.yml | 48 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67b452db..d19bf953 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -173,7 +173,7 @@ jobs: uses: actions/setup-node@v5 with: node-version: 20 - cache: 'npm' + cache: "npm" cache-dependency-path: docs/package-lock.json - name: "Install dependencies" run: npm install @@ -217,6 +217,50 @@ jobs: - name: Validate generated documentation run: uv run invoke docs-validate + check-api-documentation-obsolescence: + if: | + always() && !cancelled() && + !contains(needs.*.result, 'failure') && + !contains(needs.*.result, 'cancelled') && + (needs.files-changed.outputs.python == 'true') || (needs.files-changed.outputs.documentation_generated == 'true') + needs: ["prepare-environment", "files-changed", "yaml-lint", "python-lint"] + runs-on: "ubuntu-22.04" + env: + DOCS_COMMAND: "poetry run invoke generate-sdk-api-docs" + SDK_API_DOCS_DIR: "docs/docs/python-sdk/sdk_ref" + timeout-minutes: 5 + steps: + - name: "Check out repository code" + uses: "actions/checkout@v4" + with: + submodules: true + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: "Setup Python environment" + run: | + pipx install poetry==${{ needs.prepare-environment.outputs.POETRY_VERSION }} + poetry config virtualenvs.create true --local + poetry env use 3.12 + - name: "Install dependencies" + run: "poetry install --no-interaction --no-ansi --extras ctl" + - name: "Setup environment" + run: "pip install invoke toml" + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + cache-dependency-path: "**/package-lock.json" + - name: Install markdown linter + run: npm install -g markdownlint-cli2 + - name: "Generate SDK API documentation" + run: ${{ env.DOCS_COMMAND }} + - name: "Check if SDK API documentation needs to be refreshed" + run: | + git diff --quiet ${SDK_API_DOCS_DIR} + validate-documentation-style: if: | always() && !cancelled() && @@ -240,7 +284,7 @@ jobs: env: VALE_VERSION: ${{ env.VALE_VERSION }} - name: "Validate documentation style" - run: ./vale $(find ./docs -type f \( -name "*.mdx" -o -name "*.md" \) ) + run: ./vale $(find ./docs -type d -name sdk_ref -prune -false -o -type f \( -name "*.mdx" -o -name "*.md" \) ) unit-tests: env: From f9d63f22015257b1d9912a1fa75d5865a907585e Mon Sep 17 00:00:00 2001 From: Antoine Delannoy Date: Wed, 23 Jul 2025 15:34:03 +0200 Subject: [PATCH 16/89] internal: fixing docstrings & misspelling IHS-196 --- infrahub_sdk/ctl/utils.py | 2 +- infrahub_sdk/jinja2.py | 2 +- infrahub_sdk/pytest_plugin/items/base.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/infrahub_sdk/ctl/utils.py b/infrahub_sdk/ctl/utils.py index 968f6093..7130ea80 100644 --- a/infrahub_sdk/ctl/utils.py +++ b/infrahub_sdk/ctl/utils.py @@ -51,7 +51,7 @@ def init_logging(debug: bool = False) -> None: def handle_exception(exc: Exception, console: Console, exit_code: int) -> NoReturn: - """Handle exeception in a different fashion based on its type.""" + """Handle exception in a different fashion based on its type.""" if isinstance(exc, Exit): raise typer.Exit(code=exc.exit_code) if isinstance(exc, AuthenticationError): diff --git a/infrahub_sdk/jinja2.py b/infrahub_sdk/jinja2.py index 29afbf06..d64d22c1 100644 --- a/infrahub_sdk/jinja2.py +++ b/infrahub_sdk/jinja2.py @@ -7,7 +7,7 @@ def identify_faulty_jinja_code(traceback: Traceback, nbr_context_lines: int = 3) -> list[tuple[Frame, Syntax]]: """This function identifies the faulty Jinja2 code and beautify it to provide meaningful information to the user. - We use the rich's Traceback to parse the complete stack trace and extract Frames for each expection found in the trace. + We use the rich's Traceback to parse the complete stack trace and extract Frames for each exception found in the trace. """ response = [] diff --git a/infrahub_sdk/pytest_plugin/items/base.py b/infrahub_sdk/pytest_plugin/items/base.py index a1b35a00..ae08f036 100644 --- a/infrahub_sdk/pytest_plugin/items/base.py +++ b/infrahub_sdk/pytest_plugin/items/base.py @@ -75,7 +75,7 @@ def reportinfo(self) -> tuple[Path | str, int | None, str]: def repository_base(self) -> str: """Return the path to the root of the repository - This will be an absolute path if --infrahub-config-path is an absolut path as happens when + This will be an absolute path if --infrahub-config-path is an absolute path as happens when tests are started from within Infrahub server. """ config_path: Path = getattr(self.session, _infrahub_config_path_attribute) From a7e5c630d33ebfc0e6fe99567f3834684ce397b1 Mon Sep 17 00:00:00 2001 From: Antoine Delannoy Date: Fri, 25 Jul 2025 10:21:51 +0200 Subject: [PATCH 17/89] chore: add towncrier changelog fragment for #201 --- changelog/201.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/201.added.md diff --git a/changelog/201.added.md b/changelog/201.added.md new file mode 100644 index 00000000..b64cb2fa --- /dev/null +++ b/changelog/201.added.md @@ -0,0 +1 @@ +Add support for automatic Python SDK API from docstrings in the code. \ No newline at end of file From 9e42f1f75c50396918ecff70a728a01290804a23 Mon Sep 17 00:00:00 2001 From: Patrick Ogenstad Date: Tue, 10 Feb 2026 14:30:35 +0100 Subject: [PATCH 18/89] Use ternary operator if ALIAS_KEY in value and value[ALIAS_KEY] else key` instead of `if`-`else`-block --- infrahub_sdk/client.py | 18 ++++++++++-------- infrahub_sdk/ctl/cli_commands.py | 5 +---- infrahub_sdk/graphql/renderers.py | 5 +---- infrahub_sdk/node/node.py | 10 ++-------- infrahub_sdk/template/__init__.py | 15 +++------------ pyproject.toml | 1 - tests/unit/sdk/test_file_handler.py | 10 ++-------- tests/unit/sdk/test_schema.py | 10 ++-------- 8 files changed, 21 insertions(+), 53 deletions(-) diff --git a/infrahub_sdk/client.py b/infrahub_sdk/client.py index 4b1a59fb..c924ab41 100644 --- a/infrahub_sdk/client.py +++ b/infrahub_sdk/client.py @@ -1868,10 +1868,11 @@ async def convert_object_type( for more information. """ - if fields_mapping is None: - mapping_dict = {} - else: - mapping_dict = {field_name: model.model_dump(mode="json") for field_name, model in fields_mapping.items()} + mapping_dict = ( + {} + if fields_mapping is None + else {field_name: model.model_dump(mode="json") for field_name, model in fields_mapping.items()} + ) branch_name = branch or self.default_branch response = await self.execute_graphql( @@ -3425,10 +3426,11 @@ def convert_object_type( for more information. """ - if fields_mapping is None: - mapping_dict = {} - else: - mapping_dict = {field_name: model.model_dump(mode="json") for field_name, model in fields_mapping.items()} + mapping_dict = ( + {} + if fields_mapping is None + else {field_name: model.model_dump(mode="json") for field_name, model in fields_mapping.items()} + ) branch_name = branch or self.default_branch response = self.execute_graphql( diff --git a/infrahub_sdk/ctl/cli_commands.py b/infrahub_sdk/ctl/cli_commands.py index 2b571723..47b5f78c 100644 --- a/infrahub_sdk/ctl/cli_commands.py +++ b/infrahub_sdk/ctl/cli_commands.py @@ -350,10 +350,7 @@ def transform( # Run Transform result = asyncio.run(transform.run(data=data)) - if isinstance(result, str): - json_string = result - else: - json_string = ujson.dumps(result, indent=2, sort_keys=True) + json_string = result if isinstance(result, str) else ujson.dumps(result, indent=2, sort_keys=True) if out: write_to_file(Path(out), json_string) diff --git a/infrahub_sdk/graphql/renderers.py b/infrahub_sdk/graphql/renderers.py index e2e4aafc..5b6c2c0f 100644 --- a/infrahub_sdk/graphql/renderers.py +++ b/infrahub_sdk/graphql/renderers.py @@ -149,10 +149,7 @@ def render_query_block(data: dict, offset: int = 4, indentation: int = 4, conver elif isinstance(value, dict) and len(value) == 1 and alias_key in value and value[alias_key]: lines.append(f"{offset_str}{value[alias_key]}: {key}") elif isinstance(value, dict): - if value.get(alias_key): - key_str = f"{value[alias_key]}: {key}" - else: - key_str = key + key_str = f"{value[alias_key]}: {key}" if value.get(alias_key) else key if value.get(filters_key): filters_str = ", ".join( diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index 74f1a0fc..e6d63559 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -648,10 +648,7 @@ def _init_relationships(self, data: dict | RelatedNode | None = None) -> None: ) if value is not None } - if peer_id_data: - rel_data = peer_id_data - else: - rel_data = None + rel_data = peer_id_data or None self._relationship_cardinality_one_data[rel_schema.name] = RelatedNode( name=rel_schema.name, branch=self._branch, client=self._client, schema=rel_schema, data=rel_data ) @@ -1538,10 +1535,7 @@ def _init_relationships(self, data: dict | None = None) -> None: ) if value is not None } - if peer_id_data: - rel_data = peer_id_data - else: - rel_data = None + rel_data = peer_id_data or None self._relationship_cardinality_one_data[rel_schema.name] = RelatedNodeSync( name=rel_schema.name, branch=self._branch, client=self._client, schema=rel_schema, data=rel_data ) diff --git a/infrahub_sdk/template/__init__.py b/infrahub_sdk/template/__init__.py index 910ec216..32fb818f 100644 --- a/infrahub_sdk/template/__init__.py +++ b/infrahub_sdk/template/__init__.py @@ -64,10 +64,7 @@ def get_template(self) -> jinja2.Template: return self._template_definition try: - if self.is_string_based: - template = self._get_string_based_template() - else: - template = self._get_file_based_template() + template = self._get_string_based_template() if self.is_string_based else self._get_file_based_template() except jinja2.TemplateSyntaxError as exc: self._raise_template_syntax_error(error=exc) except jinja2.TemplateNotFound as exc: @@ -120,10 +117,7 @@ async def render(self, variables: dict[str, Any]) -> str: errors = _identify_faulty_jinja_code(traceback=traceback) raise JinjaTemplateUndefinedError(message=exc.message, errors=errors) except Exception as exc: - if error_message := getattr(exc, "message", None): - message = error_message - else: - message = str(exc) + message = error_message if (error_message := getattr(exc, "message", None)) else str(exc) raise JinjaTemplateError(message=message or "Unknown template error") return output @@ -188,10 +182,7 @@ def _identify_faulty_jinja_code(traceback: Traceback, nbr_context_lines: int = 3 # Extract only the Jinja related exception for frame in [frame for frame in traceback.trace.stacks[0].frames if not frame.filename.endswith(".py")]: code = "".join(linecache.getlines(frame.filename)) - if frame.filename == "