Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0cd636f
feat(sdk): add sha1_of_source helper for checksum comparison
minitriga Apr 22, 2026
f199281
test(sdk): cover sha1_of_source BinaryIO position rewind from non-zer…
minitriga Apr 22, 2026
1790610
feat(sdk): add UploadResult dataclass for idempotent uploads
minitriga Apr 22, 2026
1c7978b
docs(sdk): clarify UploadResult.checksum None cases per review
minitriga Apr 22, 2026
296fd5f
feat(sdk): add async matches_local_checksum to InfrahubNode
minitriga Apr 22, 2026
702917f
refactor(sdk): apply Task 3 review feedback to matches_local_checksum
minitriga Apr 22, 2026
12011d0
feat(sdk): add sync matches_local_checksum to InfrahubNodeSync
minitriga Apr 22, 2026
dfd3123
feat(sdk): add async upload_if_changed with SHA-1 idempotency
minitriga Apr 22, 2026
78fd071
refactor(sdk): apply Task 5 review feedback to upload_if_changed
minitriga Apr 22, 2026
8f8457e
feat(sdk): add sync upload_if_changed to InfrahubNodeSync
minitriga Apr 22, 2026
a3a5a6a
feat(sdk): add skip_if_unchanged to async download_file
minitriga Apr 22, 2026
721ff33
fix(sdk): enforce unsaved-node check before skip_if_unchanged short-c…
minitriga Apr 22, 2026
442fe0a
feat(sdk): add skip_if_unchanged to sync download_file
minitriga Apr 22, 2026
1abc22f
docs(sdk): add changelog fragment for idempotent file operations
minitriga Apr 22, 2026
0fdfc01
docs(sdk): clarify UploadResult.checksum docstring and add fixed frag…
minitriga Apr 22, 2026
65f7c71
style(sdk): apply ruff format to new idempotent file operations
minitriga Apr 22, 2026
4e2d5a9
refactor(sdk): rename UploadResult.uploaded to was_uploaded per review
minitriga Apr 23, 2026
93fe705
test(sdk): drop tautological UploadResult frozen test per review
minitriga Apr 23, 2026
18e1572
docs(sdk): rewrite unsaved-node fix fragment in user-impact terms
minitriga Apr 23, 2026
9b8ebc1
test(sdk): add positive-path HTTP assertions to upload/download idemp…
minitriga Apr 23, 2026
5269fb6
docs(sdk): clarify that idempotency checks use cached node checksum
minitriga Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/+idempotent-file-ops-unsaved-node.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct me if I'm wrong, but skip_if_unchanged did not exist before this PR. I still don't really understand what this is saying, but I don't think it can be "fixed" and reference a new kwarg

7 changes: 7 additions & 0 deletions changelog/+idempotent-file-ops.added.md
Original file line number Diff line number Diff line change
@@ -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`.
47 changes: 47 additions & 0 deletions infrahub_sdk/file_handler.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import hashlib
from dataclasses import dataclass
from io import BytesIO
from pathlib import Path
Expand All @@ -13,6 +14,52 @@
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:
Expand Down
7 changes: 6 additions & 1 deletion infrahub_sdk/node/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE,
HFID_STR_SEPARATOR,
IP_TYPES,
MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE,
PROPERTIES_FLAG,
PROPERTIES_OBJECT,
SAFE_VALUE,
UPLOAD_IF_CHANGED_FEATURE_NOT_SUPPORTED_MESSAGE,
)
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
Expand All @@ -23,9 +25,11 @@
"ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE",
"HFID_STR_SEPARATOR",
"IP_TYPES",
"MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE",
"PROPERTIES_FLAG",
"PROPERTIES_OBJECT",
"SAFE_VALUE",
"UPLOAD_IF_CHANGED_FEATURE_NOT_SUPPORTED_MESSAGE",
"Attribute",
"InfrahubNode",
"InfrahubNodeBase",
Expand All @@ -37,5 +41,6 @@
"RelationshipManager",
"RelationshipManagerBase",
"RelationshipManagerSync",
"UploadResult",
"parse_human_friendly_id",
]
6 changes: 6 additions & 0 deletions infrahub_sdk/node/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@
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"
)
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."

Expand Down
Loading
Loading