From 0cd636fbe426b189cd8c2e29003ed60f403018e9 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Wed, 22 Apr 2026 14:14:14 +0100 Subject: [PATCH 01/21] feat(sdk): add sha1_of_source helper for checksum comparison Co-Authored-By: Claude Sonnet 4.6 --- infrahub_sdk/file_handler.py | 49 +++++++++++++++++++++++++++++ tests/unit/sdk/test_file_handler.py | 40 ++++++++++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/infrahub_sdk/file_handler.py b/infrahub_sdk/file_handler.py index 5d32441a..79d5fd27 100644 --- a/infrahub_sdk/file_handler.py +++ b/infrahub_sdk/file_handler.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib from dataclasses import dataclass from io import BytesIO from pathlib import Path @@ -13,6 +14,54 @@ if TYPE_CHECKING: from .client import InfrahubClient, InfrahubClientSync +_SHA1_CHUNK_BYTES = 64 * 1024 + + +def sha1_of_source(source: bytes | Path | BinaryIO) -> str: + """Compute the SHA-1 hex digest of an upload/download source. + + Accepts the same shapes as :meth:`FileHandlerBase.prepare_upload` so + callers can compare local content against a server-stored checksum + without materialising the full file in memory. + + Args: + source: The content to hash. ``bytes`` are hashed in one shot. + A ``Path`` is read in 64 KiB chunks. A ``BinaryIO`` is read + from its current position, then rewound so downstream + callers can re-read it. + + Returns: + Lowercase SHA-1 hex digest, matching the algorithm Infrahub + stores in ``CoreFileObject.checksum``. + + Raises: + TypeError: If ``source`` is not one of the supported types. + """ + hasher = hashlib.sha1(usedforsecurity=False) + + if isinstance(source, bytes): + hasher.update(source) + return hasher.hexdigest() + + if isinstance(source, Path): + with source.open("rb") as fh: + while chunk := fh.read(_SHA1_CHUNK_BYTES): + hasher.update(chunk) + return hasher.hexdigest() + + if hasattr(source, "read") and hasattr(source, "seek"): + start = source.tell() + try: + while chunk := source.read(_SHA1_CHUNK_BYTES): + hasher.update(chunk) + finally: + source.seek(start) + return hasher.hexdigest() + + raise TypeError( + f"sha1_of_source expects bytes, Path, or BinaryIO; got {type(source).__name__}" + ) + @dataclass class PreparedFile: diff --git a/tests/unit/sdk/test_file_handler.py b/tests/unit/sdk/test_file_handler.py index ae59c842..6fcdb845 100644 --- a/tests/unit/sdk/test_file_handler.py +++ b/tests/unit/sdk/test_file_handler.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import tempfile from io import BytesIO from pathlib import Path @@ -10,7 +11,7 @@ import pytest from infrahub_sdk.exceptions import AuthenticationError, NodeNotFoundError -from infrahub_sdk.file_handler import FileHandler, FileHandlerBase, FileHandlerSync, PreparedFile +from infrahub_sdk.file_handler import FileHandler, FileHandlerBase, FileHandlerSync, PreparedFile, sha1_of_source if TYPE_CHECKING: from pytest_httpx import HTTPXMock @@ -303,3 +304,40 @@ async def test_file_handler_build_url_without_branch(client_type: str, clients: url = handler._build_url(node_id="node-456", branch=None) assert url == "http://mock/api/storage/files/node-456" + + +KNOWN_CONTENT = b"hello infrahub" +KNOWN_SHA1 = hashlib.sha1(KNOWN_CONTENT, usedforsecurity=False).hexdigest() + + +class TestSha1OfSource: + def test_bytes_matches_known_digest(self) -> None: + assert sha1_of_source(KNOWN_CONTENT) == KNOWN_SHA1 + + def test_path_matches_known_digest(self, tmp_path: Path) -> None: + target = tmp_path / "sample.bin" + target.write_bytes(KNOWN_CONTENT) + assert sha1_of_source(target) == KNOWN_SHA1 + + def test_binaryio_matches_known_digest(self) -> None: + stream = BytesIO(KNOWN_CONTENT) + assert sha1_of_source(stream) == KNOWN_SHA1 + + def test_binaryio_resets_position(self) -> None: + stream = BytesIO(KNOWN_CONTENT) + sha1_of_source(stream) + # Hashing must not consume the stream — later callers (upload_from_bytes) + # still need to read it. + assert stream.read() == KNOWN_CONTENT + + def test_large_file_streams_without_full_read(self, tmp_path: Path) -> None: + # 2 MiB — bigger than the 64 KiB chunk to exercise the streaming loop. + payload = b"x" * (2 * 1024 * 1024) + target = tmp_path / "big.bin" + target.write_bytes(payload) + expected = hashlib.sha1(payload, usedforsecurity=False).hexdigest() + assert sha1_of_source(target) == expected + + def test_rejects_none(self) -> None: + with pytest.raises(TypeError): + sha1_of_source(None) # type: ignore[arg-type] From f1992816e282df9d8b2cda592117731355cdc0ef Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Wed, 22 Apr 2026 14:18:33 +0100 Subject: [PATCH 02/21] test(sdk): cover sha1_of_source BinaryIO position rewind from non-zero start Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/sdk/test_file_handler.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/sdk/test_file_handler.py b/tests/unit/sdk/test_file_handler.py index 6fcdb845..f8ffacfa 100644 --- a/tests/unit/sdk/test_file_handler.py +++ b/tests/unit/sdk/test_file_handler.py @@ -341,3 +341,10 @@ def test_large_file_streams_without_full_read(self, tmp_path: Path) -> None: def test_rejects_none(self) -> None: with pytest.raises(TypeError): sha1_of_source(None) # type: ignore[arg-type] + + def test_binaryio_resets_to_original_position_not_start(self) -> None: + stream = BytesIO(b"prefixhello") + stream.read(6) # advance to position 6, so only b"hello" remains + digest = sha1_of_source(stream) + assert digest == hashlib.sha1(b"hello", usedforsecurity=False).hexdigest() + assert stream.tell() == 6 # rewound to the original non-zero position, not 0 From 1790610828df4bb270f84e7c1f572ca6de2a365f Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Wed, 22 Apr 2026 14:21:35 +0100 Subject: [PATCH 03/21] feat(sdk): add UploadResult dataclass for idempotent uploads Co-Authored-By: Claude Sonnet 4.6 --- infrahub_sdk/node/__init__.py | 3 ++- infrahub_sdk/node/node.py | 16 ++++++++++++++++ tests/unit/sdk/test_file_object.py | 18 +++++++++++++++++- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/infrahub_sdk/node/__init__.py b/infrahub_sdk/node/__init__.py index 2a1c39e5..f55d979e 100644 --- a/infrahub_sdk/node/__init__.py +++ b/infrahub_sdk/node/__init__.py @@ -11,7 +11,7 @@ PROPERTIES_OBJECT, SAFE_VALUE, ) -from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync +from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync, UploadResult from .parsers import parse_human_friendly_id from .property import NodeProperty from .related_node import RelatedNode, RelatedNodeBase, RelatedNodeSync @@ -37,5 +37,6 @@ "RelationshipManager", "RelationshipManagerBase", "RelationshipManagerSync", + "UploadResult", "parse_human_friendly_id", ] diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index a47209dc..a076c76b 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -2,6 +2,7 @@ from collections.abc import Iterable from copy import copy, deepcopy +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, BinaryIO @@ -39,6 +40,21 @@ from ..types import Order +@dataclass(frozen=True) +class UploadResult: + """Outcome of an idempotent upload attempt. + + Returned by :meth:`InfrahubNode.upload_if_changed` and its sync twin. + ``uploaded`` tells the caller whether a network transfer actually + happened; ``checksum`` is the SHA-1 that the server now reports (or + ``None`` when the node was unsaved and therefore had no prior + server-side content to compare against). + """ + + uploaded: bool + checksum: str | None + + class InfrahubNodeBase: """Base class for InfrahubNode and InfrahubNodeSync""" diff --git a/tests/unit/sdk/test_file_object.py b/tests/unit/sdk/test_file_object.py index f6267003..311b0e96 100644 --- a/tests/unit/sdk/test_file_object.py +++ b/tests/unit/sdk/test_file_object.py @@ -6,7 +6,7 @@ from pytest_httpx import HTTPXMock from infrahub_sdk.exceptions import FeatureNotSupportedError -from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync +from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync, UploadResult from infrahub_sdk.schema import NodeSchemaAPI from tests.unit.sdk.conftest import BothClients @@ -293,3 +293,19 @@ async def test_node_download_file_unsaved_node_raises( 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() + + +class TestUploadResult: + def test_carries_uploaded_and_checksum(self) -> None: + result = UploadResult(uploaded=True, checksum="abc123") + assert result.uploaded is True + assert result.checksum == "abc123" + + def test_checksum_optional(self) -> None: + result = UploadResult(uploaded=False, checksum=None) + assert result.checksum is None + + def test_is_frozen(self) -> None: + result = UploadResult(uploaded=True, checksum="abc") + with pytest.raises(AttributeError): # FrozenInstanceError (3.11+) is a subclass + result.uploaded = False # type: ignore[misc] From 1c7978b829cf46d97df8b921b06d93275ae9befd Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Wed, 22 Apr 2026 14:30:01 +0100 Subject: [PATCH 04/21] docs(sdk): clarify UploadResult.checksum None cases per review Co-Authored-By: Claude Sonnet 4.6 --- infrahub_sdk/node/node.py | 7 ++++--- tests/unit/sdk/test_file_object.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index a076c76b..cb134772 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -46,9 +46,10 @@ class UploadResult: Returned by :meth:`InfrahubNode.upload_if_changed` and its sync twin. ``uploaded`` tells the caller whether a network transfer actually - happened; ``checksum`` is the SHA-1 that the server now reports (or - ``None`` when the node was unsaved and therefore had no prior - server-side content to compare against). + happened; ``checksum`` carries the SHA-1 the server holds after the + operation, or ``None`` when no server checksum is available (either + the node was unsaved and nothing was transferred, or the save did not + return a checksum value). """ uploaded: bool diff --git a/tests/unit/sdk/test_file_object.py b/tests/unit/sdk/test_file_object.py index 311b0e96..da92c908 100644 --- a/tests/unit/sdk/test_file_object.py +++ b/tests/unit/sdk/test_file_object.py @@ -307,5 +307,5 @@ def test_checksum_optional(self) -> None: def test_is_frozen(self) -> None: result = UploadResult(uploaded=True, checksum="abc") - with pytest.raises(AttributeError): # FrozenInstanceError (3.11+) is a subclass + with pytest.raises(AttributeError): # FrozenInstanceError is an AttributeError on all supported versions result.uploaded = False # type: ignore[misc] From 296fd5f2efae742a7145fdb5d04ae0c5a27349ff Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Wed, 22 Apr 2026 14:34:38 +0100 Subject: [PATCH 05/21] feat(sdk): add async matches_local_checksum to InfrahubNode Adds a no-network, no-mutation primitive that callers can use to compare a local bytes/Path/BinaryIO source against the server-stored SHA-1 checksum on a CoreFileObject node, without triggering a transfer. Also adds MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE constant and re-exports it from infrahub_sdk/node/__init__.py. Co-Authored-By: Claude Sonnet 4.6 --- infrahub_sdk/node/__init__.py | 2 + infrahub_sdk/node/constants.py | 3 ++ infrahub_sdk/node/node.py | 37 ++++++++++++- tests/unit/sdk/test_file_object.py | 84 ++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/infrahub_sdk/node/__init__.py b/infrahub_sdk/node/__init__.py index f55d979e..f84929a3 100644 --- a/infrahub_sdk/node/__init__.py +++ b/infrahub_sdk/node/__init__.py @@ -7,6 +7,7 @@ ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE, HFID_STR_SEPARATOR, IP_TYPES, + MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE, PROPERTIES_FLAG, PROPERTIES_OBJECT, SAFE_VALUE, @@ -23,6 +24,7 @@ "ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE", "HFID_STR_SEPARATOR", "IP_TYPES", + "MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE", "PROPERTIES_FLAG", "PROPERTIES_OBJECT", "SAFE_VALUE", diff --git a/infrahub_sdk/node/constants.py b/infrahub_sdk/node/constants.py index 7a0bc6fd..b75a31bd 100644 --- a/infrahub_sdk/node/constants.py +++ b/infrahub_sdk/node/constants.py @@ -30,6 +30,9 @@ FILE_DOWNLOAD_FEATURE_NOT_SUPPORTED_MESSAGE = ( "calling download_file is only supported for nodes that inherit from CoreFileObject" ) +MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE = ( + "calling matches_local_checksum 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 cb134772..403026a7 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -8,7 +8,7 @@ from ..constants import InfrahubClientMode from ..exceptions import FeatureNotSupportedError, NodeNotFoundError, ResourceNotDefinedError, SchemaNotFoundError -from ..file_handler import FileHandler, FileHandlerBase, FileHandlerSync, PreparedFile +from ..file_handler import FileHandler, FileHandlerBase, FileHandlerSync, PreparedFile, sha1_of_source from ..graphql import Mutation, Query from ..schema import ( GenericSchemaAPI, @@ -25,6 +25,7 @@ ARTIFACT_FETCH_FEATURE_NOT_SUPPORTED_MESSAGE, ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE, FILE_DOWNLOAD_FEATURE_NOT_SUPPORTED_MESSAGE, + MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE, PROPERTIES_OBJECT, ) from .metadata import NodeMetadata @@ -824,6 +825,40 @@ async def download_file(self, dest: Path | None = None) -> bytes | int: return await self._file_handler.download(node_id=self.id, branch=self._branch, dest=dest) + async def matches_local_checksum(self, source: bytes | Path | BinaryIO) -> bool: + """Return True if ``source``'s SHA-1 matches this node's server checksum. + + Only available for nodes inheriting from ``CoreFileObject``. Callers + that want to branch on the comparison without invoking a transfer + should use this primitive instead of reading ``node.checksum.value`` + and hashing ``source`` themselves, so the hashing convention stays + centralised in the SDK. + + Parameters: + source: Local content to hash and compare. Accepts the same + shapes as :func:`infrahub_sdk.file_handler.sha1_of_source`. + + Returns: + True if the local digest equals the server's stored checksum. + + Raises: + FeatureNotSupportedError: Node is not a ``CoreFileObject``. + ValueError: Node has no server-side checksum yet (unsaved or + file never attached). + """ + self._validate_file_object_support( + message=MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE + ) + + server_checksum = getattr(self, "checksum", None) + if server_checksum is None or server_checksum.value is None: + raise ValueError( + f"{self._schema.kind} node has no checksum on the server yet; " + "save the node with file content before comparing." + ) + + return sha1_of_source(source) == server_checksum.value + 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): diff --git a/tests/unit/sdk/test_file_object.py b/tests/unit/sdk/test_file_object.py index da92c908..9021833a 100644 --- a/tests/unit/sdk/test_file_object.py +++ b/tests/unit/sdk/test_file_object.py @@ -1,3 +1,4 @@ +import hashlib import tempfile from pathlib import Path @@ -309,3 +310,86 @@ def test_is_frozen(self) -> None: result = UploadResult(uploaded=True, checksum="abc") with pytest.raises(AttributeError): # FrozenInstanceError is an AttributeError on all supported versions result.uploaded = False # type: ignore[misc] + + +@pytest.mark.parametrize("client_type", client_types) +class TestMatchesLocalChecksum: + async def test_bytes_match( + self, client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI + ) -> None: + if client_type == "sync": + pytest.skip("sync variant added in Task 4") + + payload = b"matching content" + digest = hashlib.sha1(payload, usedforsecurity=False).hexdigest() + + client = getattr(clients, client_type) + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + node.id = "node-1" + node.checksum.value = digest # type: ignore[attr-defined] + + assert await node.matches_local_checksum(payload) is True + + async def test_bytes_differ( + self, client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI + ) -> None: + if client_type == "sync": + pytest.skip("sync variant added in Task 4") + + client = getattr(clients, client_type) + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + node.id = "node-1" + node.checksum.value = "different-digest" # type: ignore[attr-defined] + + assert await node.matches_local_checksum(b"hello world") is False + + async def test_path_source( + self, + client_type: str, + clients: BothClients, + file_object_schema: NodeSchemaAPI, + tmp_path: Path, + ) -> None: + if client_type == "sync": + pytest.skip("sync variant added in Task 4") + + payload = b"file on disk" + target = tmp_path / "f.bin" + target.write_bytes(payload) + digest = hashlib.sha1(payload, usedforsecurity=False).hexdigest() + + client = getattr(clients, client_type) + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + node.id = "node-1" + node.checksum.value = digest # type: ignore[attr-defined] + + assert await node.matches_local_checksum(target) is True + + async def test_raises_for_non_file_object( + self, client_type: str, clients: BothClients, non_file_object_schema: NodeSchemaAPI + ) -> None: + if client_type == "sync": + pytest.skip("sync variant added in Task 4") + + client = getattr(clients, client_type) + node = InfrahubNode(client=client, schema=non_file_object_schema, branch="main") + + with pytest.raises( + FeatureNotSupportedError, + match=r"calling matches_local_checksum is only supported", + ): + await node.matches_local_checksum(b"anything") + + async def test_raises_when_no_server_checksum( + self, client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI + ) -> None: + if client_type == "sync": + pytest.skip("sync variant added in Task 4") + + client = getattr(clients, client_type) + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + node.id = "node-1" + # Do NOT set node.checksum.value — default is None. + + with pytest.raises(ValueError, match=r"has no checksum"): + await node.matches_local_checksum(b"anything") From 702917fa0a74617d56c0176b1c0f9507d6a85b4a Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Wed, 22 Apr 2026 14:41:12 +0100 Subject: [PATCH 06/21] refactor(sdk): apply Task 3 review feedback to matches_local_checksum Co-Authored-By: Claude Sonnet 4.6 --- infrahub_sdk/node/node.py | 12 ++++++------ tests/unit/sdk/test_file_object.py | 19 ++----------------- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index 403026a7..2de48758 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -834,7 +834,7 @@ async def matches_local_checksum(self, source: bytes | Path | BinaryIO) -> bool: and hashing ``source`` themselves, so the hashing convention stays centralised in the SDK. - Parameters: + Args: source: Local content to hash and compare. Accepts the same shapes as :func:`infrahub_sdk.file_handler.sha1_of_source`. @@ -850,14 +850,14 @@ async def matches_local_checksum(self, source: bytes | Path | BinaryIO) -> bool: message=MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE ) - server_checksum = getattr(self, "checksum", None) - if server_checksum is None or server_checksum.value is None: + server_checksum = self.checksum # type: ignore[attr-defined] + if server_checksum.value is None: # type: ignore[union-attr] raise ValueError( - f"{self._schema.kind} node has no checksum on the server yet; " - "save the node with file content before comparing." + f"{self._schema.kind} node has no server-side checksum; " + "ensure the node has been saved with file content attached before comparing." ) - return sha1_of_source(source) == server_checksum.value + return sha1_of_source(source) == server_checksum.value # type: ignore[union-attr] async def delete(self, timeout: int | None = None, request_context: RequestContext | None = None) -> None: input_data = {"data": {"id": self.id}} diff --git a/tests/unit/sdk/test_file_object.py b/tests/unit/sdk/test_file_object.py index 9021833a..5535bb0b 100644 --- a/tests/unit/sdk/test_file_object.py +++ b/tests/unit/sdk/test_file_object.py @@ -312,14 +312,11 @@ def test_is_frozen(self) -> None: result.uploaded = False # type: ignore[misc] -@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("client_type", ["standard"]) class TestMatchesLocalChecksum: async def test_bytes_match( self, client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI ) -> None: - if client_type == "sync": - pytest.skip("sync variant added in Task 4") - payload = b"matching content" digest = hashlib.sha1(payload, usedforsecurity=False).hexdigest() @@ -333,9 +330,6 @@ async def test_bytes_match( async def test_bytes_differ( self, client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI ) -> None: - if client_type == "sync": - pytest.skip("sync variant added in Task 4") - client = getattr(clients, client_type) node = InfrahubNode(client=client, schema=file_object_schema, branch="main") node.id = "node-1" @@ -350,9 +344,6 @@ async def test_path_source( file_object_schema: NodeSchemaAPI, tmp_path: Path, ) -> None: - if client_type == "sync": - pytest.skip("sync variant added in Task 4") - payload = b"file on disk" target = tmp_path / "f.bin" target.write_bytes(payload) @@ -368,9 +359,6 @@ async def test_path_source( async def test_raises_for_non_file_object( self, client_type: str, clients: BothClients, non_file_object_schema: NodeSchemaAPI ) -> None: - if client_type == "sync": - pytest.skip("sync variant added in Task 4") - client = getattr(clients, client_type) node = InfrahubNode(client=client, schema=non_file_object_schema, branch="main") @@ -383,13 +371,10 @@ async def test_raises_for_non_file_object( async def test_raises_when_no_server_checksum( self, client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI ) -> None: - if client_type == "sync": - pytest.skip("sync variant added in Task 4") - client = getattr(clients, client_type) node = InfrahubNode(client=client, schema=file_object_schema, branch="main") node.id = "node-1" # Do NOT set node.checksum.value — default is None. - with pytest.raises(ValueError, match=r"has no checksum"): + with pytest.raises(ValueError, match=r"has no server-side checksum"): await node.matches_local_checksum(b"anything") From 12011d09c76d092d840c01f70373ed488902128a Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Wed, 22 Apr 2026 14:43:44 +0100 Subject: [PATCH 07/21] feat(sdk): add sync matches_local_checksum to InfrahubNodeSync --- infrahub_sdk/node/node.py | 30 +++++++++++++ tests/unit/sdk/test_file_object.py | 67 +++++++++++++++++++++++------- 2 files changed, 81 insertions(+), 16 deletions(-) diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index 2de48758..73c858b9 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -1743,6 +1743,36 @@ def download_file(self, dest: Path | None = None) -> bytes | int: return self._file_handler.download(node_id=self.id, branch=self._branch, dest=dest) + def matches_local_checksum(self, source: bytes | Path | BinaryIO) -> bool: + """Return True if ``source``'s SHA-1 matches this node's server checksum. + + Sync equivalent of :meth:`InfrahubNode.matches_local_checksum`. See + that method for full documentation. + + Args: + source: Local content to hash and compare. Accepts the same + shapes as :func:`infrahub_sdk.file_handler.sha1_of_source`. + + Returns: + True if the local digest equals the server's stored checksum. + + Raises: + FeatureNotSupportedError: Node is not a ``CoreFileObject``. + ValueError: Node has no server-side checksum yet. + """ + self._validate_file_object_support( + message=MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE + ) + + server_checksum = self.checksum # type: ignore[attr-defined] + if server_checksum.value is None: # type: ignore[union-attr] + raise ValueError( + f"{self._schema.kind} node has no server-side checksum; " + "ensure the node has been saved with file content attached before comparing." + ) + + return sha1_of_source(source) == server_checksum.value # type: ignore[union-attr] + 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): diff --git a/tests/unit/sdk/test_file_object.py b/tests/unit/sdk/test_file_object.py index 5535bb0b..491f41e1 100644 --- a/tests/unit/sdk/test_file_object.py +++ b/tests/unit/sdk/test_file_object.py @@ -312,7 +312,7 @@ def test_is_frozen(self) -> None: result.uploaded = False # type: ignore[misc] -@pytest.mark.parametrize("client_type", ["standard"]) +@pytest.mark.parametrize("client_type", client_types) class TestMatchesLocalChecksum: async def test_bytes_match( self, client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI @@ -321,21 +321,33 @@ async def test_bytes_match( digest = hashlib.sha1(payload, usedforsecurity=False).hexdigest() client = getattr(clients, client_type) - node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + 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 = "node-1" node.checksum.value = digest # type: ignore[attr-defined] - assert await node.matches_local_checksum(payload) is True + if isinstance(node, InfrahubNode): + assert await node.matches_local_checksum(payload) is True + else: + assert node.matches_local_checksum(payload) is True async def test_bytes_differ( self, client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI ) -> None: client = getattr(clients, client_type) - node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + 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 = "node-1" node.checksum.value = "different-digest" # type: ignore[attr-defined] - assert await node.matches_local_checksum(b"hello world") is False + if isinstance(node, InfrahubNode): + assert await node.matches_local_checksum(b"hello world") is False + else: + assert node.matches_local_checksum(b"hello world") is False async def test_path_source( self, @@ -350,31 +362,54 @@ async def test_path_source( digest = hashlib.sha1(payload, usedforsecurity=False).hexdigest() client = getattr(clients, client_type) - node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + 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 = "node-1" node.checksum.value = digest # type: ignore[attr-defined] - assert await node.matches_local_checksum(target) is True + if isinstance(node, InfrahubNode): + assert await node.matches_local_checksum(target) is True + else: + assert node.matches_local_checksum(target) is True async def test_raises_for_non_file_object( self, client_type: str, clients: BothClients, non_file_object_schema: NodeSchemaAPI ) -> None: client = getattr(clients, client_type) - node = InfrahubNode(client=client, schema=non_file_object_schema, branch="main") + if client_type == "standard": + node = InfrahubNode(client=client, schema=non_file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=client, schema=non_file_object_schema, branch="main") - with pytest.raises( - FeatureNotSupportedError, - match=r"calling matches_local_checksum is only supported", - ): - await node.matches_local_checksum(b"anything") + if isinstance(node, InfrahubNode): + with pytest.raises( + FeatureNotSupportedError, + match=r"calling matches_local_checksum is only supported", + ): + await node.matches_local_checksum(b"anything") + else: + with pytest.raises( + FeatureNotSupportedError, + match=r"calling matches_local_checksum is only supported", + ): + node.matches_local_checksum(b"anything") async def test_raises_when_no_server_checksum( self, client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI ) -> None: client = getattr(clients, client_type) - node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + 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 = "node-1" # Do NOT set node.checksum.value — default is None. - with pytest.raises(ValueError, match=r"has no server-side checksum"): - await node.matches_local_checksum(b"anything") + if isinstance(node, InfrahubNode): + with pytest.raises(ValueError, match=r"has no server-side checksum"): + await node.matches_local_checksum(b"anything") + else: + with pytest.raises(ValueError, match=r"has no server-side checksum"): + node.matches_local_checksum(b"anything") From dfd3123b3720cf004d9cbbb68e6ffb4eca66cd5f Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Wed, 22 Apr 2026 14:51:30 +0100 Subject: [PATCH 08/21] feat(sdk): add async upload_if_changed with SHA-1 idempotency Adds InfrahubNode.upload_if_changed() which composes sha1_of_source, upload_from_path/upload_from_bytes, and save() to perform uploads only when local content differs from the server-side checksum. Returns an UploadResult with the locally-computed digest as the post-upload checksum. Co-Authored-By: Claude Sonnet 4.6 --- infrahub_sdk/node/__init__.py | 2 + infrahub_sdk/node/constants.py | 3 + infrahub_sdk/node/node.py | 66 ++++++++++++++ tests/unit/sdk/test_file_object.py | 142 +++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+) diff --git a/infrahub_sdk/node/__init__.py b/infrahub_sdk/node/__init__.py index f84929a3..136100e8 100644 --- a/infrahub_sdk/node/__init__.py +++ b/infrahub_sdk/node/__init__.py @@ -11,6 +11,7 @@ PROPERTIES_FLAG, PROPERTIES_OBJECT, SAFE_VALUE, + UPLOAD_IF_CHANGED_FEATURE_NOT_SUPPORTED_MESSAGE, ) from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync, UploadResult from .parsers import parse_human_friendly_id @@ -28,6 +29,7 @@ "PROPERTIES_FLAG", "PROPERTIES_OBJECT", "SAFE_VALUE", + "UPLOAD_IF_CHANGED_FEATURE_NOT_SUPPORTED_MESSAGE", "Attribute", "InfrahubNode", "InfrahubNodeBase", diff --git a/infrahub_sdk/node/constants.py b/infrahub_sdk/node/constants.py index b75a31bd..6a56584e 100644 --- a/infrahub_sdk/node/constants.py +++ b/infrahub_sdk/node/constants.py @@ -33,6 +33,9 @@ MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE = ( "calling matches_local_checksum is only supported for nodes that inherit from CoreFileObject" ) +UPLOAD_IF_CHANGED_FEATURE_NOT_SUPPORTED_MESSAGE = ( + "calling upload_if_changed 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 73c858b9..e424dd8c 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -27,6 +27,7 @@ FILE_DOWNLOAD_FEATURE_NOT_SUPPORTED_MESSAGE, MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE, PROPERTIES_OBJECT, + UPLOAD_IF_CHANGED_FEATURE_NOT_SUPPORTED_MESSAGE, ) from .metadata import NodeMetadata from .related_node import RelatedNode, RelatedNodeBase, RelatedNodeSync @@ -859,6 +860,71 @@ async def matches_local_checksum(self, source: bytes | Path | BinaryIO) -> bool: return sha1_of_source(source) == server_checksum.value # type: ignore[union-attr] + async def upload_if_changed( + self, + source: bytes | Path | BinaryIO, + name: str | None = None, + ) -> UploadResult: + """Upload ``source`` only if its SHA-1 differs from the server checksum. + + Composes :meth:`matches_local_checksum` with :meth:`upload_from_path` + (or :meth:`upload_from_bytes`) and :meth:`save`. For unsaved nodes or + nodes that have no prior server-side file, the upload is always + performed — there is nothing to compare against. + + Args: + source: Content to upload. ``bytes`` and ``BinaryIO`` sources + must supply ``name``; for a ``Path`` the filename is derived + from ``source.name`` when ``name`` is omitted. + name: Filename to use on the server. Required for ``bytes`` / + ``BinaryIO`` sources. + + Returns: + :class:`UploadResult` with ``uploaded=False`` (skipped) or + ``uploaded=True`` (transfer occurred), and the resulting server + checksum (``None`` only when no server checksum was available + after the operation). + + Raises: + FeatureNotSupportedError: Node is not a ``CoreFileObject``. + ValueError: ``source`` is ``bytes`` or ``BinaryIO`` and no + ``name`` was supplied. + """ + self._validate_file_object_support( + message=UPLOAD_IF_CHANGED_FEATURE_NOT_SUPPORTED_MESSAGE + ) + + resolved_name: str | None = name + if resolved_name is None and isinstance(source, Path): + resolved_name = source.name + if not isinstance(source, Path) and resolved_name is None: + raise ValueError("name is required when source is bytes or BinaryIO") + + # Short-circuit only if we have a server checksum to compare against. + server_checksum = getattr(self, "checksum", None) + have_server_state = ( + bool(self.id) + and server_checksum is not None + and server_checksum.value is not None + ) + + # Compute digest before staging — source may only be readable once. + local_digest = sha1_of_source(source) + + if have_server_state and local_digest == server_checksum.value: # type: ignore[union-attr] + return UploadResult(uploaded=False, checksum=server_checksum.value) # type: ignore[union-attr] + + # Either no server state, or checksum mismatched — stage + save. + if isinstance(source, Path): + self.upload_from_path(path=source) + else: + # resolved_name guaranteed non-None by the validation above. + self.upload_from_bytes(content=source, name=resolved_name) # type: ignore[arg-type] + + await self.save() + + return UploadResult(uploaded=True, checksum=local_digest) + 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): diff --git a/tests/unit/sdk/test_file_object.py b/tests/unit/sdk/test_file_object.py index 491f41e1..1f21c97c 100644 --- a/tests/unit/sdk/test_file_object.py +++ b/tests/unit/sdk/test_file_object.py @@ -413,3 +413,145 @@ async def test_raises_when_no_server_checksum( else: with pytest.raises(ValueError, match=r"has no server-side checksum"): node.matches_local_checksum(b"anything") + + +@pytest.fixture +def mock_upload_if_changed_update(httpx_mock: HTTPXMock) -> HTTPXMock: + """Mock the HTTP response for a file upload update (same payload shape as mock_node_update_with_file).""" + httpx_mock.add_response( + method="POST", + json={ + "data": { + "NetworkCircuitContractUpdate": { + "ok": True, + "object": { + "id": "upload-if-changed-node", + "display_label": FILE_NAME, + "file_name": {"value": FILE_NAME}, + "checksum": {"value": "new-server-digest"}, + "file_size": {"value": len(FILE_CONTENT)}, + "file_type": {"value": FILE_MIME_TYPE}, + "storage_id": {"value": "storage-xyz-updated"}, + "contract_start": {"value": "2024-01-01T00:00:00Z"}, + "contract_end": {"value": "2024-12-31T23:59:59Z"}, + }, + } + } + }, + ) + return httpx_mock + + +@pytest.mark.parametrize("client_type", ["standard"]) +class TestUploadIfChanged: + async def test_skips_when_checksum_matches( + self, + client_type: str, + clients: BothClients, + file_object_schema: NodeSchemaAPI, + httpx_mock: HTTPXMock, + ) -> None: + payload = b"unchanged content" + digest = hashlib.sha1(payload, usedforsecurity=False).hexdigest() + + client = getattr(clients, client_type) + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + node.id = "already-on-server" + node.checksum.value = digest # type: ignore[attr-defined, union-attr] + + result = await node.upload_if_changed(source=payload, name="f.bin") + + assert isinstance(result, UploadResult) + assert result.uploaded is False + assert result.checksum == digest + # No HTTP request should have been issued. + assert httpx_mock.get_requests() == [] + + async def test_uploads_when_checksum_differs( + self, + client_type: str, + clients: BothClients, + file_object_schema: NodeSchemaAPI, + mock_upload_if_changed_update: HTTPXMock, + ) -> None: + new_content = b"new content" + expected_digest = hashlib.sha1(new_content, usedforsecurity=False).hexdigest() + + client = getattr(clients, client_type) + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + node.id = "upload-if-changed-node" + node._existing = True + node.checksum.value = "old-server-digest" # type: ignore[attr-defined, union-attr] + + result = await node.upload_if_changed(source=new_content, name="f.bin") + + assert result.uploaded is True + # Post-save checksum is the locally computed SHA-1 of the uploaded content. + assert result.checksum == expected_digest + + async def test_uploads_when_node_unsaved( + self, + client_type: str, + clients: BothClients, + file_object_schema: NodeSchemaAPI, + mock_node_create_with_file: HTTPXMock, + ) -> None: + client = getattr(clients, client_type) + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + # Do NOT set node.id — unsaved. + + result = await node.upload_if_changed(source=b"initial content", name=FILE_NAME) + + assert result.uploaded is True + assert result.checksum is not None + + async def test_derives_name_from_path( + self, + client_type: str, + clients: BothClients, + file_object_schema: NodeSchemaAPI, + mock_upload_if_changed_update: HTTPXMock, + tmp_path: Path, + ) -> None: + target = tmp_path / "derived-name.bin" + target.write_bytes(b"content") + + client = getattr(clients, client_type) + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + node.id = "upload-if-changed-node" + node._existing = True + node.checksum.value = "old-server-digest" # type: ignore[attr-defined, union-attr] + + # No explicit name — should derive from target.name internally. + result = await node.upload_if_changed(source=target) + + assert result.uploaded is True + + async def test_requires_name_for_bytes( + self, + client_type: str, + clients: BothClients, + file_object_schema: NodeSchemaAPI, + ) -> None: + client = getattr(clients, client_type) + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + node.id = "some-id" + node.checksum.value = "x" # type: ignore[attr-defined, union-attr] + + with pytest.raises(ValueError, match=r"name is required"): + await node.upload_if_changed(source=b"bytes content") # no name supplied + + async def test_raises_for_non_file_object( + self, + client_type: str, + clients: BothClients, + non_file_object_schema: NodeSchemaAPI, + ) -> None: + client = getattr(clients, client_type) + node = InfrahubNode(client=client, schema=non_file_object_schema, branch="main") + + with pytest.raises( + FeatureNotSupportedError, + match=r"calling upload_if_changed is only supported", + ): + await node.upload_if_changed(source=b"x", name="f.bin") From 78fd07106e5f2c47ec18f8a35953219429bb1567 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Wed, 22 Apr 2026 14:59:44 +0100 Subject: [PATCH 09/21] refactor(sdk): apply Task 5 review feedback to upload_if_changed Co-Authored-By: Claude Sonnet 4.6 --- infrahub_sdk/node/node.py | 4 ++-- tests/unit/sdk/test_file_object.py | 36 +++++------------------------- 2 files changed, 7 insertions(+), 33 deletions(-) diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index e424dd8c..1e7947c4 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -918,8 +918,8 @@ async def upload_if_changed( if isinstance(source, Path): self.upload_from_path(path=source) else: - # resolved_name guaranteed non-None by the validation above. - self.upload_from_bytes(content=source, name=resolved_name) # type: ignore[arg-type] + assert resolved_name is not None # validated above for non-Path sources # noqa: S101 + self.upload_from_bytes(content=source, name=resolved_name) await self.save() diff --git a/tests/unit/sdk/test_file_object.py b/tests/unit/sdk/test_file_object.py index 1f21c97c..a4d20235 100644 --- a/tests/unit/sdk/test_file_object.py +++ b/tests/unit/sdk/test_file_object.py @@ -415,33 +415,6 @@ async def test_raises_when_no_server_checksum( node.matches_local_checksum(b"anything") -@pytest.fixture -def mock_upload_if_changed_update(httpx_mock: HTTPXMock) -> HTTPXMock: - """Mock the HTTP response for a file upload update (same payload shape as mock_node_update_with_file).""" - httpx_mock.add_response( - method="POST", - json={ - "data": { - "NetworkCircuitContractUpdate": { - "ok": True, - "object": { - "id": "upload-if-changed-node", - "display_label": FILE_NAME, - "file_name": {"value": FILE_NAME}, - "checksum": {"value": "new-server-digest"}, - "file_size": {"value": len(FILE_CONTENT)}, - "file_type": {"value": FILE_MIME_TYPE}, - "storage_id": {"value": "storage-xyz-updated"}, - "contract_start": {"value": "2024-01-01T00:00:00Z"}, - "contract_end": {"value": "2024-12-31T23:59:59Z"}, - }, - } - } - }, - ) - return httpx_mock - - @pytest.mark.parametrize("client_type", ["standard"]) class TestUploadIfChanged: async def test_skips_when_checksum_matches( @@ -457,6 +430,7 @@ async def test_skips_when_checksum_matches( client = getattr(clients, client_type) node = InfrahubNode(client=client, schema=file_object_schema, branch="main") node.id = "already-on-server" + node._existing = True node.checksum.value = digest # type: ignore[attr-defined, union-attr] result = await node.upload_if_changed(source=payload, name="f.bin") @@ -472,14 +446,14 @@ async def test_uploads_when_checksum_differs( client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI, - mock_upload_if_changed_update: HTTPXMock, + mock_node_update_with_file: HTTPXMock, ) -> None: new_content = b"new content" expected_digest = hashlib.sha1(new_content, usedforsecurity=False).hexdigest() client = getattr(clients, client_type) node = InfrahubNode(client=client, schema=file_object_schema, branch="main") - node.id = "upload-if-changed-node" + node.id = "existing-file-node-456" node._existing = True node.checksum.value = "old-server-digest" # type: ignore[attr-defined, union-attr] @@ -510,7 +484,7 @@ async def test_derives_name_from_path( client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI, - mock_upload_if_changed_update: HTTPXMock, + mock_node_update_with_file: HTTPXMock, tmp_path: Path, ) -> None: target = tmp_path / "derived-name.bin" @@ -518,7 +492,7 @@ async def test_derives_name_from_path( client = getattr(clients, client_type) node = InfrahubNode(client=client, schema=file_object_schema, branch="main") - node.id = "upload-if-changed-node" + node.id = "existing-file-node-456" node._existing = True node.checksum.value = "old-server-digest" # type: ignore[attr-defined, union-attr] From 8f8457e171574c1399c6001573b74d6146bf41a5 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Wed, 22 Apr 2026 15:02:20 +0100 Subject: [PATCH 10/21] feat(sdk): add sync upload_if_changed to InfrahubNodeSync Mirror the async InfrahubNode.upload_if_changed on the sync class, extending TestUploadIfChanged to parametrize over both client types (standard + sync) for all 6 test scenarios. Co-Authored-By: Claude Sonnet 4.6 --- infrahub_sdk/node/node.py | 56 ++++++++++++++++++++++ tests/unit/sdk/test_file_object.py | 77 +++++++++++++++++++++++------- 2 files changed, 115 insertions(+), 18 deletions(-) diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index 1e7947c4..db28aa3a 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -1839,6 +1839,62 @@ def matches_local_checksum(self, source: bytes | Path | BinaryIO) -> bool: return sha1_of_source(source) == server_checksum.value # type: ignore[union-attr] + def upload_if_changed( + self, + source: bytes | Path | BinaryIO, + name: str | None = None, + ) -> UploadResult: + """Upload ``source`` only if its SHA-1 differs from the server checksum. + + Sync equivalent of :meth:`InfrahubNode.upload_if_changed`. See that + method for full documentation. + + Args: + source: Content to upload. + name: Filename to use on the server. + + Returns: + :class:`UploadResult`. + + Raises: + FeatureNotSupportedError: Node is not a ``CoreFileObject``. + ValueError: Bytes/BinaryIO source without ``name``. + """ + self._validate_file_object_support( + message=UPLOAD_IF_CHANGED_FEATURE_NOT_SUPPORTED_MESSAGE + ) + + resolved_name: str | None = name + if resolved_name is None and isinstance(source, Path): + resolved_name = source.name + if not isinstance(source, Path) and resolved_name is None: + raise ValueError("name is required when source is bytes or BinaryIO") + + # Short-circuit only if we have a server checksum to compare against. + server_checksum = getattr(self, "checksum", None) + have_server_state = ( + bool(self.id) + and server_checksum is not None + and server_checksum.value is not None + ) + + # Compute digest before staging — source may only be readable once. + local_digest = sha1_of_source(source) + + if have_server_state and local_digest == server_checksum.value: # type: ignore[union-attr] + return UploadResult(uploaded=False, checksum=server_checksum.value) # type: ignore[union-attr] + + # Either no server state, or checksum mismatched — stage + save. + if isinstance(source, Path): + self.upload_from_path(path=source) + else: + assert resolved_name is not None # validated above for non-Path sources # noqa: S101 + self.upload_from_bytes(content=source, name=resolved_name) + + self.save() + + return UploadResult(uploaded=True, checksum=local_digest) + 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): diff --git a/tests/unit/sdk/test_file_object.py b/tests/unit/sdk/test_file_object.py index a4d20235..1fea2211 100644 --- a/tests/unit/sdk/test_file_object.py +++ b/tests/unit/sdk/test_file_object.py @@ -415,7 +415,7 @@ async def test_raises_when_no_server_checksum( node.matches_local_checksum(b"anything") -@pytest.mark.parametrize("client_type", ["standard"]) +@pytest.mark.parametrize("client_type", client_types) class TestUploadIfChanged: async def test_skips_when_checksum_matches( self, @@ -428,12 +428,18 @@ async def test_skips_when_checksum_matches( digest = hashlib.sha1(payload, usedforsecurity=False).hexdigest() client = getattr(clients, client_type) - node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + 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 = "already-on-server" node._existing = True node.checksum.value = digest # type: ignore[attr-defined, union-attr] - result = await node.upload_if_changed(source=payload, name="f.bin") + if isinstance(node, InfrahubNode): + result = await node.upload_if_changed(source=payload, name="f.bin") + else: + result = node.upload_if_changed(source=payload, name="f.bin") assert isinstance(result, UploadResult) assert result.uploaded is False @@ -452,12 +458,18 @@ async def test_uploads_when_checksum_differs( expected_digest = hashlib.sha1(new_content, usedforsecurity=False).hexdigest() client = getattr(clients, client_type) - node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + 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 = "existing-file-node-456" node._existing = True node.checksum.value = "old-server-digest" # type: ignore[attr-defined, union-attr] - result = await node.upload_if_changed(source=new_content, name="f.bin") + if isinstance(node, InfrahubNode): + result = await node.upload_if_changed(source=new_content, name="f.bin") + else: + result = node.upload_if_changed(source=new_content, name="f.bin") assert result.uploaded is True # Post-save checksum is the locally computed SHA-1 of the uploaded content. @@ -471,10 +483,16 @@ async def test_uploads_when_node_unsaved( mock_node_create_with_file: HTTPXMock, ) -> None: client = getattr(clients, client_type) - node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + 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") # Do NOT set node.id — unsaved. - result = await node.upload_if_changed(source=b"initial content", name=FILE_NAME) + if isinstance(node, InfrahubNode): + result = await node.upload_if_changed(source=b"initial content", name=FILE_NAME) + else: + result = node.upload_if_changed(source=b"initial content", name=FILE_NAME) assert result.uploaded is True assert result.checksum is not None @@ -491,13 +509,19 @@ async def test_derives_name_from_path( target.write_bytes(b"content") client = getattr(clients, client_type) - node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + 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 = "existing-file-node-456" node._existing = True node.checksum.value = "old-server-digest" # type: ignore[attr-defined, union-attr] # No explicit name — should derive from target.name internally. - result = await node.upload_if_changed(source=target) + if isinstance(node, InfrahubNode): + result = await node.upload_if_changed(source=target) + else: + result = node.upload_if_changed(source=target) assert result.uploaded is True @@ -508,12 +532,19 @@ async def test_requires_name_for_bytes( file_object_schema: NodeSchemaAPI, ) -> None: client = getattr(clients, client_type) - node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + 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 = "some-id" node.checksum.value = "x" # type: ignore[attr-defined, union-attr] - with pytest.raises(ValueError, match=r"name is required"): - await node.upload_if_changed(source=b"bytes content") # no name supplied + if isinstance(node, InfrahubNode): + with pytest.raises(ValueError, match=r"name is required"): + await node.upload_if_changed(source=b"bytes content") # no name supplied + else: + with pytest.raises(ValueError, match=r"name is required"): + node.upload_if_changed(source=b"bytes content") # no name supplied async def test_raises_for_non_file_object( self, @@ -522,10 +553,20 @@ async def test_raises_for_non_file_object( non_file_object_schema: NodeSchemaAPI, ) -> None: client = getattr(clients, client_type) - node = InfrahubNode(client=client, schema=non_file_object_schema, branch="main") + if client_type == "standard": + node = InfrahubNode(client=client, schema=non_file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=client, schema=non_file_object_schema, branch="main") - with pytest.raises( - FeatureNotSupportedError, - match=r"calling upload_if_changed is only supported", - ): - await node.upload_if_changed(source=b"x", name="f.bin") + if isinstance(node, InfrahubNode): + with pytest.raises( + FeatureNotSupportedError, + match=r"calling upload_if_changed is only supported", + ): + await node.upload_if_changed(source=b"x", name="f.bin") + else: + with pytest.raises( + FeatureNotSupportedError, + match=r"calling upload_if_changed is only supported", + ): + node.upload_if_changed(source=b"x", name="f.bin") From a3a5a6a77c0b54abe40d29455161a349dc0df2e6 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Wed, 22 Apr 2026 15:06:36 +0100 Subject: [PATCH 11/21] feat(sdk): add skip_if_unchanged to async download_file Adds a `skip_if_unchanged: bool = False` kwarg to `InfrahubNode.download_file`. When True and dest is provided, SHA-1 of the local file is compared against the node's server checksum; a match returns 0 immediately without a network request. Includes @overload signatures and 5 new parametrized tests. Co-Authored-By: Claude Sonnet 4.6 --- infrahub_sdk/node/node.py | 40 ++++++++++- tests/unit/sdk/test_file_object.py | 104 +++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index db28aa3a..f2a42ebd 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -4,7 +4,7 @@ from copy import copy, deepcopy from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any, BinaryIO +from typing import TYPE_CHECKING, Any, BinaryIO, overload from ..constants import InfrahubClientMode from ..exceptions import FeatureNotSupportedError, NodeNotFoundError, ResourceNotDefinedError, SchemaNotFoundError @@ -791,7 +791,17 @@ 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: + @overload + async def download_file(self, dest: None = ..., skip_if_unchanged: bool = ...) -> bytes: ... + + @overload + async def download_file(self, dest: Path, skip_if_unchanged: bool = ...) -> int: ... + + async def download_file( + self, + dest: Path | None = None, + skip_if_unchanged: bool = False, + ) -> bytes | int: """Download the file content from this FileObject node. This method is only available for nodes that inherit from CoreFileObject. @@ -802,14 +812,21 @@ async def download_file(self, dest: Path | None = None) -> bytes | int: 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. + skip_if_unchanged: When ``True``, compute the SHA-1 of the file at + ``dest`` (which must be provided) and compare against the + node's server checksum. If they match, return ``0`` without + hitting the network. Returns: If ``dest`` is None: The file content as bytes. If ``dest`` is provided: The number of bytes written to the file. + If ``skip_if_unchanged=True`` and the local file matches the server + checksum: ``0``. Raises: FeatureNotSupportedError: If this node doesn't inherit from CoreFileObject. - ValueError: If the node hasn't been saved yet or file not found. + ValueError: If the node hasn't been saved yet, file not found, or + ``skip_if_unchanged=True`` was passed without a ``dest``. AuthenticationError: If authentication fails. Examples: @@ -818,9 +835,26 @@ async def download_file(self, dest: Path | None = None) -> bytes | int: >>> # Stream to file (memory-efficient for large files) >>> bytes_written = await contract.download_file(dest=Path("/tmp/contract.pdf")) + + >>> # Skip download if local file already matches server checksum + >>> bytes_written = await contract.download_file( + ... dest=Path("/tmp/contract.pdf"), skip_if_unchanged=True + ... ) """ self._validate_file_object_support(message=FILE_DOWNLOAD_FEATURE_NOT_SUPPORTED_MESSAGE) + if skip_if_unchanged: + if dest is None: + raise ValueError("skip_if_unchanged requires dest to be provided") + if dest.exists() and dest.is_file(): + server_checksum = getattr(self, "checksum", None) + if ( + server_checksum is not None + and server_checksum.value is not None + and sha1_of_source(dest) == server_checksum.value + ): + return 0 + if not self.id: raise ValueError("Cannot download file for a node that hasn't been saved yet.") diff --git a/tests/unit/sdk/test_file_object.py b/tests/unit/sdk/test_file_object.py index 1fea2211..d5c76b3c 100644 --- a/tests/unit/sdk/test_file_object.py +++ b/tests/unit/sdk/test_file_object.py @@ -570,3 +570,107 @@ async def test_raises_for_non_file_object( match=r"calling upload_if_changed is only supported", ): node.upload_if_changed(source=b"x", name="f.bin") + + +@pytest.mark.parametrize("client_type", ["standard"]) +class TestDownloadSkipIfUnchanged: + async def test_skip_when_local_matches( + self, + client_type: str, + clients: BothClients, + file_object_schema: NodeSchemaAPI, + tmp_path: Path, + httpx_mock: HTTPXMock, + ) -> None: + payload = b"identical content" + digest = hashlib.sha1(payload, usedforsecurity=False).hexdigest() + dest = tmp_path / "local.bin" + dest.write_bytes(payload) + + client = getattr(clients, client_type) + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + node.id = "file-node-skip" + node.checksum.value = digest # type: ignore[attr-defined, union-attr] + + bytes_written = await node.download_file(dest=dest, skip_if_unchanged=True) + + assert bytes_written == 0 + # No GET to the storage endpoint should have happened. + download_requests = [ + r for r in httpx_mock.get_requests() + if r.method == "GET" and "/api/storage/files/" in r.url.path + ] + assert download_requests == [] + + async def test_downloads_when_local_differs( + self, + client_type: str, + clients: BothClients, + file_object_schema: NodeSchemaAPI, + tmp_path: Path, + mock_download_file_to_disk: HTTPXMock, # existing fixture + ) -> None: + dest = tmp_path / "local.bin" + dest.write_bytes(b"stale content") # different from FILE_CONTENT + + client = getattr(clients, client_type) + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + node.id = "file-node-stream" # id matches mock_download_file_to_disk + node.checksum.value = "server-digest-different-from-local" # type: ignore[attr-defined, union-attr] + + bytes_written = await node.download_file(dest=dest, skip_if_unchanged=True) + + assert bytes_written == len(FILE_CONTENT) + assert dest.read_bytes() == FILE_CONTENT + + async def test_downloads_when_dest_missing( + self, + client_type: str, + clients: BothClients, + file_object_schema: NodeSchemaAPI, + tmp_path: Path, + mock_download_file_to_disk: HTTPXMock, + ) -> None: + dest = tmp_path / "missing.bin" # does not exist + assert not dest.exists() + + client = getattr(clients, client_type) + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + node.id = "file-node-stream" + node.checksum.value = "any-digest" # type: ignore[attr-defined, union-attr] + + bytes_written = await node.download_file(dest=dest, skip_if_unchanged=True) + + assert bytes_written == len(FILE_CONTENT) + assert dest.exists() + + async def test_raises_when_skip_without_dest( + self, + client_type: str, + clients: BothClients, + file_object_schema: NodeSchemaAPI, + ) -> None: + client = getattr(clients, client_type) + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + node.id = "file-node-1" + node.checksum.value = "any-digest" # type: ignore[attr-defined, union-attr] + + with pytest.raises(ValueError, match=r"skip_if_unchanged requires dest"): + await node.download_file(dest=None, skip_if_unchanged=True) + + async def test_default_behavior_unchanged( + self, + client_type: str, + clients: BothClients, + file_object_schema: NodeSchemaAPI, + mock_download_file: HTTPXMock, # existing fixture for in-memory download + ) -> None: + # skip_if_unchanged defaults to False — download always occurs. + client = getattr(clients, client_type) + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + node.id = "file-node-123" # matches mock_download_file + + content = await node.download_file() # no flag + + assert isinstance(content, bytes) + assert content == FILE_CONTENT From 721ff330d366674aa54cc92c9faffa602f908df4 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Wed, 22 Apr 2026 15:13:47 +0100 Subject: [PATCH 12/21] fix(sdk): enforce unsaved-node check before skip_if_unchanged short-circuit Co-Authored-By: Claude Opus 4.7 (1M context) --- infrahub_sdk/node/node.py | 16 ++++++--------- tests/unit/sdk/test_file_object.py | 32 ++++++++++++++++++++++++------ 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index f2a42ebd..047786c9 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -792,7 +792,7 @@ async def artifact_fetch(self, name: str) -> str | dict[str, Any]: return await self._client.object_store.get(identifier=artifact._get_attribute(name="storage_id").value) @overload - async def download_file(self, dest: None = ..., skip_if_unchanged: bool = ...) -> bytes: ... + async def download_file(self, dest: None = None, skip_if_unchanged: bool = ...) -> bytes: ... @overload async def download_file(self, dest: Path, skip_if_unchanged: bool = ...) -> int: ... @@ -843,21 +843,17 @@ async def download_file( """ 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.") + if skip_if_unchanged: if dest is None: raise ValueError("skip_if_unchanged requires dest to be provided") if dest.exists() and dest.is_file(): - server_checksum = getattr(self, "checksum", None) - if ( - server_checksum is not None - and server_checksum.value is not None - and sha1_of_source(dest) == server_checksum.value - ): + server_checksum = self.checksum # type: ignore[attr-defined] + if server_checksum.value is not None and sha1_of_source(dest) == server_checksum.value: # type: ignore[union-attr] return 0 - 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 matches_local_checksum(self, source: bytes | Path | BinaryIO) -> bool: diff --git a/tests/unit/sdk/test_file_object.py b/tests/unit/sdk/test_file_object.py index d5c76b3c..7208034a 100644 --- a/tests/unit/sdk/test_file_object.py +++ b/tests/unit/sdk/test_file_object.py @@ -595,12 +595,9 @@ async def test_skip_when_local_matches( bytes_written = await node.download_file(dest=dest, skip_if_unchanged=True) assert bytes_written == 0 - # No GET to the storage endpoint should have happened. - download_requests = [ - r for r in httpx_mock.get_requests() - if r.method == "GET" and "/api/storage/files/" in r.url.path - ] - assert download_requests == [] + # pytest-httpx raises if any unregistered request is attempted; this also asserts + # that zero requests were made at all. + assert httpx_mock.get_requests() == [] async def test_downloads_when_local_differs( self, @@ -674,3 +671,26 @@ async def test_default_behavior_unchanged( assert isinstance(content, bytes) assert content == FILE_CONTENT + + async def test_skip_raises_for_unsaved_node( + self, + client_type: str, + clients: BothClients, + file_object_schema: NodeSchemaAPI, + tmp_path: Path, + ) -> None: + # Unsaved node (no id) with a dest whose checksum happens to match + # the node's checksum attribute should still raise the unsaved-node + # ValueError, not silently return 0. + payload = b"content" + digest = hashlib.sha1(payload, usedforsecurity=False).hexdigest() + dest = tmp_path / "local.bin" + dest.write_bytes(payload) + + client = getattr(clients, client_type) + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + # Do NOT set node.id — unsaved. + node.checksum.value = digest # type: ignore[attr-defined, union-attr] + + with pytest.raises(ValueError, match=r"hasn't been saved yet"): + await node.download_file(dest=dest, skip_if_unchanged=True) From 442fe0aab8445ec639b0cd0131388614809fc37e Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Wed, 22 Apr 2026 15:17:31 +0100 Subject: [PATCH 13/21] feat(sdk): add skip_if_unchanged to sync download_file Mirror the async InfrahubNode.download_file skip-if-unchanged logic on InfrahubNodeSync, including overloads. Extend TestDownloadSkipIfUnchanged to parametrize over both client types (53 total tests pass). Co-Authored-By: Claude Sonnet 4.6 --- infrahub_sdk/node/node.py | 34 +++++++++++++- tests/unit/sdk/test_file_object.py | 74 ++++++++++++++++++++++++------ 2 files changed, 93 insertions(+), 15 deletions(-) diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index 047786c9..55b97fb8 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -1804,7 +1804,17 @@ 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: + @overload + def download_file(self, dest: None = None, skip_if_unchanged: bool = ...) -> bytes: ... + + @overload + def download_file(self, dest: Path, skip_if_unchanged: bool = ...) -> int: ... + + def download_file( + self, + dest: Path | None = None, + skip_if_unchanged: bool = False, + ) -> bytes | int: """Download the file content from this FileObject node. This method is only available for nodes that inherit from CoreFileObject. @@ -1815,14 +1825,21 @@ def download_file(self, dest: Path | None = None) -> bytes | int: 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. + skip_if_unchanged: When ``True``, compute the SHA-1 of the file at + ``dest`` (which must be provided) and compare against the + node's server checksum. If they match, return ``0`` without + hitting the network. Returns: If ``dest`` is None: The file content as bytes. If ``dest`` is provided: The number of bytes written to the file. + If ``skip_if_unchanged=True`` and the local file matches the server + checksum: ``0``. Raises: FeatureNotSupportedError: If this node doesn't inherit from CoreFileObject. - ValueError: If the node hasn't been saved yet or file not found. + ValueError: If the node hasn't been saved yet, file not found, or + ``skip_if_unchanged=True`` was passed without a ``dest``. AuthenticationError: If authentication fails. Examples: @@ -1831,12 +1848,25 @@ def download_file(self, dest: Path | None = None) -> bytes | int: >>> # Stream to file (memory-efficient for large files) >>> bytes_written = contract.download_file(dest=Path("/tmp/contract.pdf")) + + >>> # Skip download if local file already matches server checksum + >>> bytes_written = contract.download_file( + ... dest=Path("/tmp/contract.pdf"), skip_if_unchanged=True + ... ) """ 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.") + if skip_if_unchanged: + if dest is None: + raise ValueError("skip_if_unchanged requires dest to be provided") + if dest.exists() and dest.is_file(): + server_checksum = self.checksum # type: ignore[attr-defined] + if server_checksum.value is not None and sha1_of_source(dest) == server_checksum.value: # type: ignore[union-attr] + return 0 + return self._file_handler.download(node_id=self.id, branch=self._branch, dest=dest) def matches_local_checksum(self, source: bytes | Path | BinaryIO) -> bool: diff --git a/tests/unit/sdk/test_file_object.py b/tests/unit/sdk/test_file_object.py index 7208034a..373e9781 100644 --- a/tests/unit/sdk/test_file_object.py +++ b/tests/unit/sdk/test_file_object.py @@ -572,7 +572,7 @@ async def test_raises_for_non_file_object( node.upload_if_changed(source=b"x", name="f.bin") -@pytest.mark.parametrize("client_type", ["standard"]) +@pytest.mark.parametrize("client_type", client_types) class TestDownloadSkipIfUnchanged: async def test_skip_when_local_matches( self, @@ -588,11 +588,19 @@ async def test_skip_when_local_matches( dest.write_bytes(payload) client = getattr(clients, client_type) - node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + if client_type == "standard": + node: InfrahubNode | InfrahubNodeSync = InfrahubNode( + client=client, schema=file_object_schema, branch="main" + ) + else: + node = InfrahubNodeSync(client=client, schema=file_object_schema, branch="main") node.id = "file-node-skip" node.checksum.value = digest # type: ignore[attr-defined, union-attr] - bytes_written = await node.download_file(dest=dest, skip_if_unchanged=True) + if isinstance(node, InfrahubNode): + bytes_written = await node.download_file(dest=dest, skip_if_unchanged=True) + else: + bytes_written = node.download_file(dest=dest, skip_if_unchanged=True) assert bytes_written == 0 # pytest-httpx raises if any unregistered request is attempted; this also asserts @@ -611,11 +619,19 @@ async def test_downloads_when_local_differs( dest.write_bytes(b"stale content") # different from FILE_CONTENT client = getattr(clients, client_type) - node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + if client_type == "standard": + node: InfrahubNode | InfrahubNodeSync = 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" # id matches mock_download_file_to_disk node.checksum.value = "server-digest-different-from-local" # type: ignore[attr-defined, union-attr] - bytes_written = await node.download_file(dest=dest, skip_if_unchanged=True) + if isinstance(node, InfrahubNode): + bytes_written = await node.download_file(dest=dest, skip_if_unchanged=True) + else: + bytes_written = node.download_file(dest=dest, skip_if_unchanged=True) assert bytes_written == len(FILE_CONTENT) assert dest.read_bytes() == FILE_CONTENT @@ -632,11 +648,19 @@ async def test_downloads_when_dest_missing( assert not dest.exists() client = getattr(clients, client_type) - node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + if client_type == "standard": + node: InfrahubNode | InfrahubNodeSync = 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" node.checksum.value = "any-digest" # type: ignore[attr-defined, union-attr] - bytes_written = await node.download_file(dest=dest, skip_if_unchanged=True) + if isinstance(node, InfrahubNode): + bytes_written = await node.download_file(dest=dest, skip_if_unchanged=True) + else: + bytes_written = node.download_file(dest=dest, skip_if_unchanged=True) assert bytes_written == len(FILE_CONTENT) assert dest.exists() @@ -648,12 +672,20 @@ async def test_raises_when_skip_without_dest( file_object_schema: NodeSchemaAPI, ) -> None: client = getattr(clients, client_type) - node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + if client_type == "standard": + node: InfrahubNode | InfrahubNodeSync = InfrahubNode( + client=client, schema=file_object_schema, branch="main" + ) + else: + node = InfrahubNodeSync(client=client, schema=file_object_schema, branch="main") node.id = "file-node-1" node.checksum.value = "any-digest" # type: ignore[attr-defined, union-attr] with pytest.raises(ValueError, match=r"skip_if_unchanged requires dest"): - await node.download_file(dest=None, skip_if_unchanged=True) + if isinstance(node, InfrahubNode): + await node.download_file(dest=None, skip_if_unchanged=True) + else: + node.download_file(dest=None, skip_if_unchanged=True) async def test_default_behavior_unchanged( self, @@ -664,10 +696,18 @@ async def test_default_behavior_unchanged( ) -> None: # skip_if_unchanged defaults to False — download always occurs. client = getattr(clients, client_type) - node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + if client_type == "standard": + node: InfrahubNode | InfrahubNodeSync = 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" # matches mock_download_file - content = await node.download_file() # no flag + if isinstance(node, InfrahubNode): + content = await node.download_file() # no flag + else: + content = node.download_file() # no flag assert isinstance(content, bytes) assert content == FILE_CONTENT @@ -688,9 +728,17 @@ async def test_skip_raises_for_unsaved_node( dest.write_bytes(payload) client = getattr(clients, client_type) - node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + if client_type == "standard": + node: InfrahubNode | InfrahubNodeSync = InfrahubNode( + client=client, schema=file_object_schema, branch="main" + ) + else: + node = InfrahubNodeSync(client=client, schema=file_object_schema, branch="main") # Do NOT set node.id — unsaved. node.checksum.value = digest # type: ignore[attr-defined, union-attr] with pytest.raises(ValueError, match=r"hasn't been saved yet"): - await node.download_file(dest=dest, skip_if_unchanged=True) + if isinstance(node, InfrahubNode): + await node.download_file(dest=dest, skip_if_unchanged=True) + else: + node.download_file(dest=dest, skip_if_unchanged=True) From 1abc22f8d99763e2d4a4586a137a812256767d44 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Wed, 22 Apr 2026 15:21:40 +0100 Subject: [PATCH 14/21] docs(sdk): add changelog fragment for idempotent file operations Co-Authored-By: Claude Sonnet 4.6 --- changelog/+idempotent-file-ops.added.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog/+idempotent-file-ops.added.md diff --git a/changelog/+idempotent-file-ops.added.md b/changelog/+idempotent-file-ops.added.md new file mode 100644 index 00000000..0ed3091a --- /dev/null +++ b/changelog/+idempotent-file-ops.added.md @@ -0,0 +1,7 @@ +Added SHA-1 idempotency primitives for `CoreFileObject` nodes: + +- `InfrahubNode.matches_local_checksum(source)` / sync variant — compare a local `bytes | Path | BinaryIO` source against the node's server-stored checksum without invoking a transfer. +- `InfrahubNode.upload_if_changed(source, name=None)` / sync variant — stage + save only when the local source differs from the server, returning an `UploadResult(uploaded, checksum)` dataclass. +- `download_file(..., skip_if_unchanged=True)` — short-circuit the download when `dest` already exists on disk with a matching SHA-1. Returns `0` bytes written when skipped. + +A shared `sha1_of_source` helper (streaming, 64 KiB chunks) centralises the hashing convention in `infrahub_sdk.file_handler`. From 0fdfc0120db1008a1c4d5b3195713b1821e19574 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Wed, 22 Apr 2026 15:28:42 +0100 Subject: [PATCH 15/21] docs(sdk): clarify UploadResult.checksum docstring and add fixed fragment Co-Authored-By: Claude Sonnet 4.6 --- changelog/+idempotent-file-ops-unsaved-node.fixed.md | 1 + infrahub_sdk/node/node.py | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 changelog/+idempotent-file-ops-unsaved-node.fixed.md diff --git a/changelog/+idempotent-file-ops-unsaved-node.fixed.md b/changelog/+idempotent-file-ops-unsaved-node.fixed.md new file mode 100644 index 00000000..f22d4914 --- /dev/null +++ b/changelog/+idempotent-file-ops-unsaved-node.fixed.md @@ -0,0 +1 @@ +Fixed `InfrahubNode.download_file(skip_if_unchanged=True)` on an unsaved node: previously, a node whose local `dest` happened to match the node's in-memory `checksum.value` could silently return `0` without a network round-trip. The method now enforces the saved-node check before the skip-check short-circuit, raising `ValueError("Cannot download file for a node that hasn't been saved yet.")` as it does in the non-skip path. diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index 55b97fb8..af2fe8ff 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -48,10 +48,14 @@ class UploadResult: Returned by :meth:`InfrahubNode.upload_if_changed` and its sync twin. ``uploaded`` tells the caller whether a network transfer actually - happened; ``checksum`` carries the SHA-1 the server holds after the - operation, or ``None`` when no server checksum is available (either - the node was unsaved and nothing was transferred, or the save did not - return a checksum value). + happened; ``checksum`` carries the SHA-1 of the content held on the + server after the operation — on skip paths that is the server's + pre-existing value, on upload paths it is the locally-computed SHA-1 + used as a proxy (which matches what a standard CoreFileObject server + stores, since the server computes SHA-1 of received bytes). ``None`` + only when no server checksum was available (either the node was + unsaved and nothing was transferred, or the save returned no checksum + value). """ uploaded: bool From 65f7c711f31669e255c0d0bf35526a2af8223425 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Wed, 22 Apr 2026 17:45:51 +0100 Subject: [PATCH 16/21] style(sdk): apply ruff format to new idempotent file operations --- infrahub_sdk/file_handler.py | 4 +--- infrahub_sdk/node/node.py | 28 ++++++---------------------- tests/unit/sdk/test_file_object.py | 4 +--- 3 files changed, 8 insertions(+), 28 deletions(-) diff --git a/infrahub_sdk/file_handler.py b/infrahub_sdk/file_handler.py index 79d5fd27..b5a557b9 100644 --- a/infrahub_sdk/file_handler.py +++ b/infrahub_sdk/file_handler.py @@ -58,9 +58,7 @@ def sha1_of_source(source: bytes | Path | BinaryIO) -> str: source.seek(start) return hasher.hexdigest() - raise TypeError( - f"sha1_of_source expects bytes, Path, or BinaryIO; got {type(source).__name__}" - ) + raise TypeError(f"sha1_of_source expects bytes, Path, or BinaryIO; got {type(source).__name__}") @dataclass diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index af2fe8ff..64bb3cbe 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -881,9 +881,7 @@ async def matches_local_checksum(self, source: bytes | Path | BinaryIO) -> bool: ValueError: Node has no server-side checksum yet (unsaved or file never attached). """ - self._validate_file_object_support( - message=MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE - ) + self._validate_file_object_support(message=MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE) server_checksum = self.checksum # type: ignore[attr-defined] if server_checksum.value is None: # type: ignore[union-attr] @@ -924,9 +922,7 @@ async def upload_if_changed( ValueError: ``source`` is ``bytes`` or ``BinaryIO`` and no ``name`` was supplied. """ - self._validate_file_object_support( - message=UPLOAD_IF_CHANGED_FEATURE_NOT_SUPPORTED_MESSAGE - ) + self._validate_file_object_support(message=UPLOAD_IF_CHANGED_FEATURE_NOT_SUPPORTED_MESSAGE) resolved_name: str | None = name if resolved_name is None and isinstance(source, Path): @@ -936,11 +932,7 @@ async def upload_if_changed( # Short-circuit only if we have a server checksum to compare against. server_checksum = getattr(self, "checksum", None) - have_server_state = ( - bool(self.id) - and server_checksum is not None - and server_checksum.value is not None - ) + have_server_state = bool(self.id) and server_checksum is not None and server_checksum.value is not None # Compute digest before staging — source may only be readable once. local_digest = sha1_of_source(source) @@ -1890,9 +1882,7 @@ def matches_local_checksum(self, source: bytes | Path | BinaryIO) -> bool: FeatureNotSupportedError: Node is not a ``CoreFileObject``. ValueError: Node has no server-side checksum yet. """ - self._validate_file_object_support( - message=MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE - ) + self._validate_file_object_support(message=MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE) server_checksum = self.checksum # type: ignore[attr-defined] if server_checksum.value is None: # type: ignore[union-attr] @@ -1924,9 +1914,7 @@ def upload_if_changed( FeatureNotSupportedError: Node is not a ``CoreFileObject``. ValueError: Bytes/BinaryIO source without ``name``. """ - self._validate_file_object_support( - message=UPLOAD_IF_CHANGED_FEATURE_NOT_SUPPORTED_MESSAGE - ) + self._validate_file_object_support(message=UPLOAD_IF_CHANGED_FEATURE_NOT_SUPPORTED_MESSAGE) resolved_name: str | None = name if resolved_name is None and isinstance(source, Path): @@ -1936,11 +1924,7 @@ def upload_if_changed( # Short-circuit only if we have a server checksum to compare against. server_checksum = getattr(self, "checksum", None) - have_server_state = ( - bool(self.id) - and server_checksum is not None - and server_checksum.value is not None - ) + have_server_state = bool(self.id) and server_checksum is not None and server_checksum.value is not None # Compute digest before staging — source may only be readable once. local_digest = sha1_of_source(source) diff --git a/tests/unit/sdk/test_file_object.py b/tests/unit/sdk/test_file_object.py index 373e9781..bd7c556c 100644 --- a/tests/unit/sdk/test_file_object.py +++ b/tests/unit/sdk/test_file_object.py @@ -314,9 +314,7 @@ def test_is_frozen(self) -> None: @pytest.mark.parametrize("client_type", client_types) class TestMatchesLocalChecksum: - async def test_bytes_match( - self, client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI - ) -> None: + async def test_bytes_match(self, client_type: str, clients: BothClients, file_object_schema: NodeSchemaAPI) -> None: payload = b"matching content" digest = hashlib.sha1(payload, usedforsecurity=False).hexdigest() From 4e2d5a940d3d530258df2de0a403c04e012f1abb Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Thu, 23 Apr 2026 22:47:32 +0100 Subject: [PATCH 17/21] refactor(sdk): rename UploadResult.uploaded to was_uploaded per review Co-Authored-By: Claude Sonnet 4.6 --- infrahub_sdk/node/node.py | 16 ++++++++-------- tests/unit/sdk/test_file_object.py | 20 ++++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index 64bb3cbe..e8bf83a9 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -47,7 +47,7 @@ class UploadResult: """Outcome of an idempotent upload attempt. Returned by :meth:`InfrahubNode.upload_if_changed` and its sync twin. - ``uploaded`` tells the caller whether a network transfer actually + ``was_uploaded`` tells the caller whether a network transfer actually happened; ``checksum`` carries the SHA-1 of the content held on the server after the operation — on skip paths that is the server's pre-existing value, on upload paths it is the locally-computed SHA-1 @@ -58,7 +58,7 @@ class UploadResult: value). """ - uploaded: bool + was_uploaded: bool checksum: str | None @@ -912,8 +912,8 @@ async def upload_if_changed( ``BinaryIO`` sources. Returns: - :class:`UploadResult` with ``uploaded=False`` (skipped) or - ``uploaded=True`` (transfer occurred), and the resulting server + :class:`UploadResult` with ``was_uploaded=False`` (skipped) or + ``was_uploaded=True`` (transfer occurred), and the resulting server checksum (``None`` only when no server checksum was available after the operation). @@ -938,7 +938,7 @@ async def upload_if_changed( local_digest = sha1_of_source(source) if have_server_state and local_digest == server_checksum.value: # type: ignore[union-attr] - return UploadResult(uploaded=False, checksum=server_checksum.value) # type: ignore[union-attr] + return UploadResult(was_uploaded=False, checksum=server_checksum.value) # type: ignore[union-attr] # Either no server state, or checksum mismatched — stage + save. if isinstance(source, Path): @@ -949,7 +949,7 @@ async def upload_if_changed( await self.save() - return UploadResult(uploaded=True, checksum=local_digest) + return UploadResult(was_uploaded=True, checksum=local_digest) async def delete(self, timeout: int | None = None, request_context: RequestContext | None = None) -> None: input_data = {"data": {"id": self.id}} @@ -1930,7 +1930,7 @@ def upload_if_changed( local_digest = sha1_of_source(source) if have_server_state and local_digest == server_checksum.value: # type: ignore[union-attr] - return UploadResult(uploaded=False, checksum=server_checksum.value) # type: ignore[union-attr] + return UploadResult(was_uploaded=False, checksum=server_checksum.value) # type: ignore[union-attr] # Either no server state, or checksum mismatched — stage + save. if isinstance(source, Path): @@ -1941,7 +1941,7 @@ def upload_if_changed( self.save() - return UploadResult(uploaded=True, checksum=local_digest) + return UploadResult(was_uploaded=True, checksum=local_digest) def delete(self, timeout: int | None = None, request_context: RequestContext | None = None) -> None: input_data = {"data": {"id": self.id}} diff --git a/tests/unit/sdk/test_file_object.py b/tests/unit/sdk/test_file_object.py index bd7c556c..7b815615 100644 --- a/tests/unit/sdk/test_file_object.py +++ b/tests/unit/sdk/test_file_object.py @@ -297,19 +297,19 @@ async def test_node_download_file_unsaved_node_raises( class TestUploadResult: - def test_carries_uploaded_and_checksum(self) -> None: - result = UploadResult(uploaded=True, checksum="abc123") - assert result.uploaded is True + def test_carries_was_uploaded_and_checksum(self) -> None: + result = UploadResult(was_uploaded=True, checksum="abc123") + assert result.was_uploaded is True assert result.checksum == "abc123" def test_checksum_optional(self) -> None: - result = UploadResult(uploaded=False, checksum=None) + result = UploadResult(was_uploaded=False, checksum=None) assert result.checksum is None def test_is_frozen(self) -> None: - result = UploadResult(uploaded=True, checksum="abc") + result = UploadResult(was_uploaded=True, checksum="abc") with pytest.raises(AttributeError): # FrozenInstanceError is an AttributeError on all supported versions - result.uploaded = False # type: ignore[misc] + result.was_uploaded = False # type: ignore[misc] @pytest.mark.parametrize("client_type", client_types) @@ -440,7 +440,7 @@ async def test_skips_when_checksum_matches( result = node.upload_if_changed(source=payload, name="f.bin") assert isinstance(result, UploadResult) - assert result.uploaded is False + assert result.was_uploaded is False assert result.checksum == digest # No HTTP request should have been issued. assert httpx_mock.get_requests() == [] @@ -469,7 +469,7 @@ async def test_uploads_when_checksum_differs( else: result = node.upload_if_changed(source=new_content, name="f.bin") - assert result.uploaded is True + assert result.was_uploaded is True # Post-save checksum is the locally computed SHA-1 of the uploaded content. assert result.checksum == expected_digest @@ -492,7 +492,7 @@ async def test_uploads_when_node_unsaved( else: result = node.upload_if_changed(source=b"initial content", name=FILE_NAME) - assert result.uploaded is True + assert result.was_uploaded is True assert result.checksum is not None async def test_derives_name_from_path( @@ -521,7 +521,7 @@ async def test_derives_name_from_path( else: result = node.upload_if_changed(source=target) - assert result.uploaded is True + assert result.was_uploaded is True async def test_requires_name_for_bytes( self, From 93fe70507835088460114ed63251ca4ae8d05597 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Thu, 23 Apr 2026 22:47:48 +0100 Subject: [PATCH 18/21] test(sdk): drop tautological UploadResult frozen test per review Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/sdk/test_file_object.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/unit/sdk/test_file_object.py b/tests/unit/sdk/test_file_object.py index 7b815615..53706f5c 100644 --- a/tests/unit/sdk/test_file_object.py +++ b/tests/unit/sdk/test_file_object.py @@ -306,11 +306,6 @@ def test_checksum_optional(self) -> None: result = UploadResult(was_uploaded=False, checksum=None) assert result.checksum is None - def test_is_frozen(self) -> None: - result = UploadResult(was_uploaded=True, checksum="abc") - with pytest.raises(AttributeError): # FrozenInstanceError is an AttributeError on all supported versions - result.was_uploaded = False # type: ignore[misc] - @pytest.mark.parametrize("client_type", client_types) class TestMatchesLocalChecksum: From 18e1572e67c0c475beffaf0dd0f5f24e2d54b4c6 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Thu, 23 Apr 2026 22:47:59 +0100 Subject: [PATCH 19/21] docs(sdk): rewrite unsaved-node fix fragment in user-impact terms Co-Authored-By: Claude Sonnet 4.6 --- changelog/+idempotent-file-ops-unsaved-node.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/+idempotent-file-ops-unsaved-node.fixed.md b/changelog/+idempotent-file-ops-unsaved-node.fixed.md index f22d4914..2e7ccecf 100644 --- a/changelog/+idempotent-file-ops-unsaved-node.fixed.md +++ b/changelog/+idempotent-file-ops-unsaved-node.fixed.md @@ -1 +1 @@ -Fixed `InfrahubNode.download_file(skip_if_unchanged=True)` on an unsaved node: previously, a node whose local `dest` happened to match the node's in-memory `checksum.value` could silently return `0` without a network round-trip. The method now enforces the saved-node check before the skip-check short-circuit, raising `ValueError("Cannot download file for a node that hasn't been saved yet.")` as it does in the non-skip path. +Fixed `InfrahubNode.download_file(skip_if_unchanged=True)` silently returning `0` bytes-written on nodes that had never been saved. Previously, if the local file at `dest` happened to have the same SHA-1 as whatever was cached on the unsaved node's `checksum.value`, callers got back a success-looking `0` result instead of a clear failure — which could mask the fact that the node had no server counterpart at all. Calling `download_file` on an unsaved node now raises `ValueError` consistently, whether or not `skip_if_unchanged` is set. From 9b8ebc190dde0920e2053e1ca2d566f9d1789055 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Thu, 23 Apr 2026 22:48:30 +0100 Subject: [PATCH 20/21] test(sdk): add positive-path HTTP assertions to upload/download idempotency tests Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/sdk/test_file_object.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/unit/sdk/test_file_object.py b/tests/unit/sdk/test_file_object.py index 53706f5c..d8cdcef7 100644 --- a/tests/unit/sdk/test_file_object.py +++ b/tests/unit/sdk/test_file_object.py @@ -467,6 +467,11 @@ async def test_uploads_when_checksum_differs( assert result.was_uploaded is True # Post-save checksum is the locally computed SHA-1 of the uploaded content. assert result.checksum == expected_digest + # Positive-path HTTP verification: the update mutation must have been dispatched. + requests = mock_node_update_with_file.get_requests() + assert len(requests) > 0 + # At least one request should be a POST to the GraphQL endpoint (the update mutation). + assert any(r.method == "POST" for r in requests) async def test_uploads_when_node_unsaved( self, @@ -628,6 +633,12 @@ async def test_downloads_when_local_differs( assert bytes_written == len(FILE_CONTENT) assert dest.read_bytes() == FILE_CONTENT + # Positive-path HTTP verification: the GET to the storage endpoint must have fired. + download_requests = [ + r for r in mock_download_file_to_disk.get_requests() + if r.method == "GET" and "/api/storage/files/" in r.url.path + ] + assert len(download_requests) == 1 async def test_downloads_when_dest_missing( self, From 5269fb6869d26b965c42a8e17d857dcf6a48d953 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Thu, 23 Apr 2026 22:49:41 +0100 Subject: [PATCH 21/21] docs(sdk): clarify that idempotency checks use cached node checksum Co-Authored-By: Claude Sonnet 4.6 --- infrahub_sdk/node/node.py | 26 ++++++++++++++++++++++---- tests/unit/sdk/test_file_object.py | 3 ++- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index e8bf83a9..ddbc6498 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -56,6 +56,12 @@ class UploadResult: only when no server checksum was available (either the node was unsaved and nothing was transferred, or the save returned no checksum value). + + The comparison used by ``upload_if_changed`` reads the node's + ``checksum`` attribute, which was populated when the node was + fetched via ``client.get(...)``. A server-side change to the file + between the fetch and the call will not be detected unless the + caller re-fetches the node first. """ was_uploaded: bool @@ -818,8 +824,11 @@ async def download_file( file content will be returned as bytes. skip_if_unchanged: When ``True``, compute the SHA-1 of the file at ``dest`` (which must be provided) and compare against the - node's server checksum. If they match, return ``0`` without - hitting the network. + node's ``checksum`` attribute. If they match, return ``0`` + without hitting the network. The ``checksum`` is the value + loaded when this node was fetched — a later server-side + change to the file will not be detected unless the caller + re-fetches the node first. Returns: If ``dest`` is None: The file content as bytes. @@ -869,6 +878,12 @@ async def matches_local_checksum(self, source: bytes | Path | BinaryIO) -> bool: and hashing ``source`` themselves, so the hashing convention stays centralised in the SDK. + The comparison is against the ``checksum`` attribute as loaded + when this node was retrieved from the server. If the server's + file has been replaced since the node was fetched, this method + will not see that change — re-fetch the node to refresh the + checksum before comparing. + Args: source: Local content to hash and compare. Accepts the same shapes as :func:`infrahub_sdk.file_handler.sha1_of_source`. @@ -1823,8 +1838,11 @@ def download_file( file content will be returned as bytes. skip_if_unchanged: When ``True``, compute the SHA-1 of the file at ``dest`` (which must be provided) and compare against the - node's server checksum. If they match, return ``0`` without - hitting the network. + node's ``checksum`` attribute. If they match, return ``0`` + without hitting the network. The ``checksum`` is the value + loaded when this node was fetched — a later server-side + change to the file will not be detected unless the caller + re-fetches the node first. Returns: If ``dest`` is None: The file content as bytes. diff --git a/tests/unit/sdk/test_file_object.py b/tests/unit/sdk/test_file_object.py index d8cdcef7..c2c4cec3 100644 --- a/tests/unit/sdk/test_file_object.py +++ b/tests/unit/sdk/test_file_object.py @@ -635,7 +635,8 @@ async def test_downloads_when_local_differs( assert dest.read_bytes() == FILE_CONTENT # Positive-path HTTP verification: the GET to the storage endpoint must have fired. download_requests = [ - r for r in mock_download_file_to_disk.get_requests() + r + for r in mock_download_file_to_disk.get_requests() if r.method == "GET" and "/api/storage/files/" in r.url.path ] assert len(download_requests) == 1