From 109186223aed74905b37c6d77f66fdce1034b4a1 Mon Sep 17 00:00:00 2001 From: Alex Luck Date: Mon, 19 Oct 2026 13:57:21 -0700 Subject: [PATCH 1/4] python(feat): add artifact types and low-level wrapper --- .../_internal/low_level_wrappers/artifacts.py | 258 ++++++++++++ python/lib/sift_client/sift_types/__init__.py | 20 + python/lib/sift_client/sift_types/artifact.py | 375 ++++++++++++++++++ 3 files changed, 653 insertions(+) create mode 100644 python/lib/sift_client/_internal/low_level_wrappers/artifacts.py create mode 100644 python/lib/sift_client/sift_types/artifact.py diff --git a/python/lib/sift_client/_internal/low_level_wrappers/artifacts.py b/python/lib/sift_client/_internal/low_level_wrappers/artifacts.py new file mode 100644 index 000000000..965bed567 --- /dev/null +++ b/python/lib/sift_client/_internal/low_level_wrappers/artifacts.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from sift.artifacts.v1.artifacts_pb2 import ( + ArchiveArtifactRequest, + CreateArtifactResponse, + GetArtifactRequest, + GetArtifactResponse, + ListArtifactsRequest, + ListArtifactsResponse, + ListArtifactVersionsRequest, + ListArtifactVersionsResponse, + PromoteArtifactVersionRequest, + PromoteArtifactVersionResponse, + UnarchiveArtifactRequest, + UpdateArtifactResponse, +) +from sift.artifacts.v1.artifacts_pb2_grpc import ArtifactServiceStub + +from sift_client._internal.low_level_wrappers.base import DEFAULT_PAGE_SIZE, LowLevelClientBase +from sift_client.sift_types.artifact import ( + Artifact, + ArtifactCreate, + ArtifactUpdate, + ArtifactVersion, +) +from sift_client.transport import WithGrpcClient + +if TYPE_CHECKING: + from sift_client.transport.grpc_transport import GrpcClient + +# The update mask path that declares new bytes for the version the update writes. +REMOTE_FILE_ID_PATH = "artifact_version.remote_file_id" + + +class ArtifactsLowLevelClient(LowLevelClientBase, WithGrpcClient): + """Low-level client for the ArtifactsAPI. + + A thin wrapper around the autogenerated bindings for the ArtifactService. + """ + + def __init__(self, grpc_client: GrpcClient): + """Initialize the ArtifactsLowLevelClient. + + Args: + grpc_client: The gRPC client to use for making API calls. + """ + super().__init__(grpc_client) + + async def create_artifact(self, create: ArtifactCreate) -> Artifact: + """Create an artifact and its first version. + + Args: + create: The artifact to create. + + Returns: + The new Artifact, resolved to version 1. + """ + request = create.to_proto() + response = await self._grpc_client.get_stub(ArtifactServiceStub).CreateArtifact(request) + return Artifact._from_proto(cast("CreateArtifactResponse", response).artifact) + + async def update_artifact(self, update: ArtifactUpdate, *, new_file: bool = False) -> Artifact: + """Write a new version that carries the fields the update sets. + + Args: + update: The fields to write. Its resource ID names the artifact. + new_file: Declare that new bytes will be uploaded for the new version, so the + previous bytes and their SOURCE and DERIVED_FROM links do not carry forward. + + Returns: + The Artifact, resolved to the new version. + """ + request, mask = update.to_proto_with_mask() + if new_file: + mask.paths.append(REMOTE_FILE_ID_PATH) + request.update_mask.CopyFrom(mask) + response = await self._grpc_client.get_stub(ArtifactServiceStub).UpdateArtifact(request) + return Artifact._from_proto(cast("UpdateArtifactResponse", response).artifact) + + async def get_artifact( + self, artifact_id: str, artifact_version_id: str | None = None + ) -> Artifact: + """Get an artifact, resolved to its latest version unless one is pinned. + + Args: + artifact_id: The artifact to read. + artifact_version_id: The version to resolve to instead of the latest. + + Returns: + The Artifact. + """ + request = GetArtifactRequest( + artifact_id=artifact_id, artifact_version_id=artifact_version_id + ) + response = await self._grpc_client.get_stub(ArtifactServiceStub).GetArtifact(request) + return Artifact._from_proto(cast("GetArtifactResponse", response).artifact) + + async def list_artifacts( + self, + *, + page_size: int | None = DEFAULT_PAGE_SIZE, + page_token: str | None = None, + query_filter: str | None = None, + order_by: str | None = None, + ) -> tuple[list[Artifact], str]: + """List one page of artifacts, each resolved to its latest version. + + Args: + page_size: The maximum number of artifacts to return. + page_token: A page token for pagination. + query_filter: A CEL filter string. + order_by: How to order the retrieved artifacts. + + Returns: + A tuple of (artifacts, next_page_token). + """ + request_kwargs: dict[str, Any] = {} + if page_size is not None: + request_kwargs["page_size"] = page_size + if page_token is not None: + request_kwargs["page_token"] = page_token + if query_filter is not None: + request_kwargs["filter"] = query_filter + if order_by is not None: + request_kwargs["order_by"] = order_by + + request = ListArtifactsRequest(**request_kwargs) + response = await self._grpc_client.get_stub(ArtifactServiceStub).ListArtifacts(request) + response = cast("ListArtifactsResponse", response) + return [Artifact._from_proto(a) for a in response.artifacts], response.next_page_token + + async def list_all_artifacts( + self, + *, + query_filter: str | None = None, + order_by: str | None = None, + max_results: int | None = None, + page_size: int | None = DEFAULT_PAGE_SIZE, + ) -> list[Artifact]: + """List all artifacts matching the given query. + + Args: + query_filter: A CEL filter string. + order_by: How to order the retrieved artifacts. + max_results: The maximum number of artifacts to return. + page_size: The number of artifacts to fetch per request. + + Returns: + The matching Artifacts. + """ + return await self._handle_pagination( + self.list_artifacts, + kwargs={"query_filter": query_filter}, + page_size=page_size, + order_by=order_by, + max_results=max_results, + ) + + async def list_artifact_versions( + self, + *, + page_size: int | None = DEFAULT_PAGE_SIZE, + page_token: str | None = None, + query_filter: str | None = None, + order_by: str | None = None, + artifact_id: str | None = None, + ) -> tuple[list[ArtifactVersion], str]: + """List one page of artifact versions. + + Args: + page_size: The maximum number of versions to return. + page_token: A page token for pagination. + query_filter: A CEL filter string, evaluated against each version. + order_by: How to order the retrieved versions. + artifact_id: Only this artifact's versions. Unset searches every artifact. + + Returns: + A tuple of (versions, next_page_token). + """ + request_kwargs: dict[str, Any] = {} + if page_size is not None: + request_kwargs["page_size"] = page_size + if page_token is not None: + request_kwargs["page_token"] = page_token + if query_filter is not None: + request_kwargs["filter"] = query_filter + if order_by is not None: + request_kwargs["order_by"] = order_by + if artifact_id is not None: + request_kwargs["artifact_id"] = artifact_id + + request = ListArtifactVersionsRequest(**request_kwargs) + response = await self._grpc_client.get_stub(ArtifactServiceStub).ListArtifactVersions( + request + ) + response = cast("ListArtifactVersionsResponse", response) + return [ArtifactVersion._from_proto(v) for v in response.versions], response.next_page_token + + async def list_all_artifact_versions( + self, + *, + query_filter: str | None = None, + order_by: str | None = None, + artifact_id: str | None = None, + max_results: int | None = None, + page_size: int | None = DEFAULT_PAGE_SIZE, + ) -> list[ArtifactVersion]: + """List all artifact versions matching the given query. + + Args: + query_filter: A CEL filter string, evaluated against each version. + order_by: How to order the retrieved versions. + artifact_id: Only this artifact's versions. Unset searches every artifact. + max_results: The maximum number of versions to return. + page_size: The number of versions to fetch per request. + + Returns: + The matching ArtifactVersions. + """ + return await self._handle_pagination( + self.list_artifact_versions, + kwargs={"query_filter": query_filter, "artifact_id": artifact_id}, + page_size=page_size, + order_by=order_by, + max_results=max_results, + ) + + async def promote_artifact_version( + self, artifact_id: str, artifact_version_id: str + ) -> Artifact: + """Write a new latest version with the content of an earlier version. + + Args: + artifact_id: The artifact that owns the version. + artifact_version_id: The version whose content to promote. + + Returns: + The Artifact, resolved to the version the promotion wrote. + """ + request = PromoteArtifactVersionRequest( + artifact_id=artifact_id, artifact_version_id=artifact_version_id + ) + response = await self._grpc_client.get_stub(ArtifactServiceStub).PromoteArtifactVersion( + request + ) + return Artifact._from_proto(cast("PromoteArtifactVersionResponse", response).artifact) + + async def archive_artifact(self, artifact_id: str) -> None: + """Archive an artifact.""" + request = ArchiveArtifactRequest(artifact_id=artifact_id) + await self._grpc_client.get_stub(ArtifactServiceStub).ArchiveArtifact(request) + + async def unarchive_artifact(self, artifact_id: str) -> None: + """Unarchive an artifact.""" + request = UnarchiveArtifactRequest(artifact_id=artifact_id) + await self._grpc_client.get_stub(ArtifactServiceStub).UnarchiveArtifact(request) diff --git a/python/lib/sift_client/sift_types/__init__.py b/python/lib/sift_client/sift_types/__init__.py index 4cc53e68f..9ee9363cf 100644 --- a/python/lib/sift_client/sift_types/__init__.py +++ b/python/lib/sift_client/sift_types/__init__.py @@ -144,6 +144,17 @@ AnnotationUpdate, PhaseCreate, ) +from sift_client.sift_types.artifact import ( + Artifact, + ArtifactCreate, + ArtifactCreatedVia, + ArtifactEntityType, + ArtifactLink, + ArtifactLinkRelation, + ArtifactType, + ArtifactUpdate, + ArtifactVersion, +) from sift_client.sift_types.asset import Asset, AssetUpdate from sift_client.sift_types.calculated_channel import ( CalculatedChannel, @@ -282,6 +293,15 @@ "AnnotationState", "AnnotationType", "AnnotationUpdate", + "Artifact", + "ArtifactCreate", + "ArtifactCreatedVia", + "ArtifactEntityType", + "ArtifactLink", + "ArtifactLinkRelation", + "ArtifactType", + "ArtifactUpdate", + "ArtifactVersion", "Asset", "AssetUpdate", "CalculatedChannel", diff --git a/python/lib/sift_client/sift_types/artifact.py b/python/lib/sift_client/sift_types/artifact.py new file mode 100644 index 000000000..ae9e176be --- /dev/null +++ b/python/lib/sift_client/sift_types/artifact.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from enum import Enum +from typing import TYPE_CHECKING, BinaryIO, ClassVar, TypeVar + +from pydantic import BaseModel, model_validator +from sift.artifacts.v1.artifacts_pb2 import ( + ArtifactCreatedVia as ArtifactCreatedViaProto, +) +from sift.artifacts.v1.artifacts_pb2 import ( + ArtifactDetails as ArtifactDetailsProto, +) +from sift.artifacts.v1.artifacts_pb2 import ( + ArtifactEntityType as ArtifactEntityTypeProto, +) +from sift.artifacts.v1.artifacts_pb2 import ( + ArtifactLinkInput as ArtifactLinkInputProto, +) +from sift.artifacts.v1.artifacts_pb2 import ( + ArtifactLinkRelation as ArtifactLinkRelationProto, +) +from sift.artifacts.v1.artifacts_pb2 import ( + ArtifactStorageClass as ArtifactStorageClassProto, +) +from sift.artifacts.v1.artifacts_pb2 import ( + ArtifactVersion as ArtifactVersionProto, +) +from sift.artifacts.v1.artifacts_pb2 import ( + CreateArtifactRequest as CreateArtifactRequestProto, +) +from sift.artifacts.v1.artifacts_pb2 import ( + UpdateArtifactRequest as UpdateArtifactRequestProto, +) + +from sift_client.sift_types._base import ( + BaseType, + MappingHelper, + ModelCreate, + ModelCreateUpdateBase, + ModelUpdate, +) +from sift_client.util.metadata import metadata_dict_to_proto, metadata_proto_to_dict + +if TYPE_CHECKING: + from pathlib import Path + + from sift_client.client import SiftClient + from sift_client.sift_types.user import User + + +class ArtifactType(Enum): + """How the Sift app presents an artifact's file.""" + + FILE = ArtifactStorageClassProto.ARTIFACT_STORAGE_CLASS_FILE # 1 + """Bytes in a remote file that the Sift app can preview by MIME type.""" + BLOB = ArtifactStorageClassProto.ARTIFACT_STORAGE_CLASS_BLOB # 3 + """Bytes in a remote file that the Sift app offers only as a download.""" + + +class ArtifactCreatedVia(Enum): + """The surface that wrote an artifact.""" + + AGENT = ArtifactCreatedViaProto.ARTIFACT_CREATED_VIA_AGENT # 1 + CANVAS = ArtifactCreatedViaProto.ARTIFACT_CREATED_VIA_CANVAS # 2 + UPLOAD = ArtifactCreatedViaProto.ARTIFACT_CREATED_VIA_UPLOAD # 3 + + +class ArtifactLinkRelation(Enum): + """How a link relates an artifact version to another entity. + + `ATTACHED_TO` links carry forward to each new version. `SOURCE` and `DERIVED_FROM` + links describe the bytes, so they carry forward only while the bytes do. + """ + + ATTACHED_TO = ArtifactLinkRelationProto.ARTIFACT_LINK_RELATION_ATTACHED_TO # 1 + """Where the artifact surfaces, such as a conversation or canvas.""" + SOURCE = ArtifactLinkRelationProto.ARTIFACT_LINK_RELATION_SOURCE # 2 + """What the artifact was produced from, such as a run or asset.""" + DERIVED_FROM = ArtifactLinkRelationProto.ARTIFACT_LINK_RELATION_DERIVED_FROM # 3 + """Another artifact this one was derived from.""" + + +class ArtifactEntityType(Enum): + """The kind of entity an artifact link points at.""" + + CONVERSATION = ArtifactEntityTypeProto.ARTIFACT_ENTITY_TYPE_CONVERSATION # 1 + CANVAS = ArtifactEntityTypeProto.ARTIFACT_ENTITY_TYPE_CANVAS # 2 + RUN = ArtifactEntityTypeProto.ARTIFACT_ENTITY_TYPE_RUN # 3 + ASSET = ArtifactEntityTypeProto.ARTIFACT_ENTITY_TYPE_ASSET # 4 + ARTIFACT = ArtifactEntityTypeProto.ARTIFACT_ENTITY_TYPE_ARTIFACT # 5 + TOOL_USE = ArtifactEntityTypeProto.ARTIFACT_ENTITY_TYPE_TOOL_USE # 6 + + +EnumT = TypeVar("EnumT", bound=Enum) + + +def _enum_or_none(enum_cls: type[EnumT], value: int) -> EnumT | None: + """Map a proto enum value to `enum_cls`, or None for a value the client does not model.""" + try: + return enum_cls(value) + except ValueError: + return None + + +class ArtifactLink(BaseModel): + """A link to write on an artifact version. + + Links are write-only: the service stores them, and filters can match on them, but + the client cannot read them back. + + Attributes: + relation: How the artifact relates to the entity. + entity_type: The kind of entity. + entity_id: The entity's ID, such as a run ID. + """ + + relation: ArtifactLinkRelation + entity_type: ArtifactEntityType + entity_id: str + + +def _artifact_link_to_proto(**kwargs) -> ArtifactLinkInputProto: + """Convert an artifact link dict (from model_dump) into its proto form.""" + relation = kwargs["relation"] + entity_type = kwargs["entity_type"] + return ArtifactLinkInputProto( + relation=relation.value if isinstance(relation, ArtifactLinkRelation) else relation, + entity_type=( + entity_type.value if isinstance(entity_type, ArtifactEntityType) else entity_type + ), + entity_id=kwargs["entity_id"], + ) + + +class ArtifactVersion(BaseType[ArtifactVersionProto, "ArtifactVersion"]): + """One version of an artifact. `id_` is the version ID.""" + + artifact_id: str + version: int + title: str | None + summary: str | None + remote_file_id: str | None + file_name: str | None + file_mime_type: str | None + metadata: dict[str, str | float | bool] + created_date: datetime + is_current: bool + """Whether this is the version `get` returns without a pinned version.""" + + @classmethod + def _from_proto( + cls, proto: ArtifactVersionProto, sift_client: SiftClient | None = None + ) -> ArtifactVersion: + return cls( + proto=proto, + id_=proto.artifact_version_id, + artifact_id=proto.artifact_id, + version=proto.version, + title=proto.title if proto.HasField("title") else None, + summary=proto.summary if proto.HasField("summary") else None, + remote_file_id=proto.remote_file_id if proto.HasField("remote_file_id") else None, + file_name=proto.file_name if proto.HasField("file_name") else None, + file_mime_type=proto.file_mime_type if proto.HasField("file_mime_type") else None, + metadata=metadata_proto_to_dict(list(proto.metadata)), + created_date=proto.created_date.ToDatetime(tzinfo=timezone.utc), + is_current=proto.is_current, + _client=sift_client, + ) + + @property + def artifact(self) -> Artifact: + """Fetch the artifact, resolved to this version.""" + return self.client.artifacts.get( + artifact_id=self.artifact_id, artifact_version_id=self._id_or_error + ) + + def promote(self) -> Artifact: + """Write a new latest version that copies this version's content. + + Returns: + The artifact, resolved to the new version that the promotion wrote. + """ + return self.client.artifacts.versions.promote(version=self) + + def download(self, output_path: str | Path) -> None: + """Download this version's file to a local path.""" + self.client.artifacts.download(artifact=self, output_path=output_path) + + def read_bytes(self) -> bytes: + """Read this version's file into memory. See `ArtifactsAPI.read_bytes`.""" + return self.client.artifacts.read_bytes(artifact=self) + + +class Artifact(BaseType[ArtifactDetailsProto, "Artifact"]): + """A versioned file, resolved to one of its versions. + + The container fields are the same for every version. Title, summary, file, and + metadata live on `artifact_version`, which is the latest version unless the + artifact was fetched with a pinned version. + """ + + organization_id: str + created_by_user_id: str + created_date: datetime + artifact_type: ArtifactType | None + """None for a storage class the client does not model.""" + created_via: ArtifactCreatedVia | None + current_version_id: str + """The ID of the artifact's latest version.""" + artifact_version: ArtifactVersion + is_archived: bool + archived_date: datetime | None + + @classmethod + def _from_proto( + cls, proto: ArtifactDetailsProto, sift_client: SiftClient | None = None + ) -> Artifact: + artifact = proto.artifact + return cls( + proto=proto, + id_=artifact.artifact_id, + organization_id=artifact.organization_id, + created_by_user_id=artifact.created_by_user_id, + created_date=artifact.created_date.ToDatetime(tzinfo=timezone.utc), + artifact_type=_enum_or_none(ArtifactType, artifact.storage_class), + created_via=_enum_or_none(ArtifactCreatedVia, artifact.created_via), + current_version_id=artifact.current_version_id, + artifact_version=ArtifactVersion._from_proto(proto.artifact_version, sift_client), + is_archived=artifact.HasField("archived_date"), + archived_date=( + artifact.archived_date.ToDatetime(tzinfo=timezone.utc) + if artifact.HasField("archived_date") + else None + ), + _client=sift_client, + ) + + def _apply_client_to_instance(self, client: SiftClient) -> None: + super()._apply_client_to_instance(client) + self.artifact_version._apply_client_to_instance(client) + + @property + def versions(self) -> list[ArtifactVersion]: + """Fetch every version of this artifact, newest first.""" + return self.client.artifacts.versions.list_(artifact=self) + + @property + def created_by(self) -> User: + """Fetch the User that created this artifact.""" + return self.client.users.get(user_id=self.created_by_user_id) + + def update( + self, + update: ArtifactUpdate | dict, + *, + file: str | Path | bytes | BinaryIO | None = None, + file_name: str | None = None, + ) -> Artifact: + """Write a new version of the artifact. See `ArtifactsAPI.update`. + + Args: + update: The fields to change. + file: New bytes for the new version: a path, bytes, or a binary file object. + file_name: The stored file name. Required when `file` is bytes or a file object. + + Returns: + The artifact, resolved to the new version. + """ + updated = self.client.artifacts.update(self, update, file=file, file_name=file_name) + self._update(updated) + return self + + def archive(self) -> Artifact: + """Archive the artifact.""" + updated = self.client.artifacts.archive(self) + self._update(updated) + return self + + def unarchive(self) -> Artifact: + """Unarchive the artifact.""" + updated = self.client.artifacts.unarchive(self) + self._update(updated) + return self + + def download(self, output_path: str | Path) -> None: + """Download the file of `artifact_version` to a local path.""" + self.client.artifacts.download(artifact=self, output_path=output_path) + + def read_bytes(self) -> bytes: + """Read the file of `artifact_version` into memory. See `ArtifactsAPI.read_bytes`.""" + return self.client.artifacts.read_bytes(artifact=self) + + +class ArtifactBase(ModelCreateUpdateBase): + """Base class for Artifact create and update models.""" + + title: str | None = None + summary: str | None = None + metadata: dict[str, str | float | bool] | None = None + links: list[ArtifactLink] | None = None + + +class ArtifactCreate(ArtifactBase, ModelCreate[CreateArtifactRequestProto]): + """Create model for Artifact. + + `links` are written at version 1, for example `SOURCE` links to the runs the file + was produced from. + """ + + created_via: ArtifactCreatedVia | None = None + """The surface that wrote the artifact. Defaults to `UPLOAD`.""" + artifact_type: ArtifactType | None = None + """The service defaults to `FILE`.""" + + _to_proto_helpers: ClassVar[dict[str, MappingHelper]] = { + "artifact_type": MappingHelper(proto_attr_path="storage_class"), + "metadata": MappingHelper( + proto_attr_path="metadata", + converter=metadata_dict_to_proto, + ), + "links": MappingHelper( + proto_attr_path="links", + converter=_artifact_link_to_proto, # type: ignore[arg-type] + ), + } + + @model_validator(mode="after") + def _default_created_via(self) -> ArtifactCreate: + # The service would record AGENT, and an AGENT artifact's links name no user. + # Assigning marks the field as set, so `to_proto` sends it. + if self.created_via is None: + self.created_via = ArtifactCreatedVia.UPLOAD + return self + + def _get_proto_class(self) -> type[CreateArtifactRequestProto]: + return CreateArtifactRequestProto + + +class ArtifactUpdate(ArtifactBase, ModelUpdate[UpdateArtifactRequestProto]): + """Update model for Artifact. + + Each update writes a new version. Fields left unset are copied from the latest + version. `metadata` replaces the latest version's metadata as a whole. `links` are + added to the links that carry forward. + """ + + _to_proto_helpers: ClassVar[dict[str, MappingHelper]] = { + "title": MappingHelper( + proto_attr_path="artifact.artifact_version.title", + update_field="artifact_version.title", + ), + "summary": MappingHelper( + proto_attr_path="artifact.artifact_version.summary", + update_field="artifact_version.summary", + ), + "metadata": MappingHelper( + proto_attr_path="artifact.artifact_version.metadata", + update_field="artifact_version.metadata", + converter=metadata_dict_to_proto, + ), + "links": MappingHelper( + proto_attr_path="links", + update_field="links", + converter=_artifact_link_to_proto, # type: ignore[arg-type] + ), + } + + def _get_proto_class(self) -> type[UpdateArtifactRequestProto]: + return UpdateArtifactRequestProto + + def _add_resource_id_to_proto(self, proto_msg: UpdateArtifactRequestProto): + if self._resource_id is None: + raise ValueError("Resource ID must be set before adding to proto") + proto_msg.artifact_id = self._resource_id From 513c30e0cea6d26557dc2105d86aeb7dd3d8321f Mon Sep 17 00:00:00 2001 From: Alex Luck Date: Mon, 19 Oct 2026 13:57:21 -0700 Subject: [PATCH 2/4] python(feat): add artifacts resource to SiftClient --- .../_internal/low_level_wrappers/upload.py | 62 +- python/lib/sift_client/_internal/util/file.py | 20 + python/lib/sift_client/_tests/conftest.py | 1 + python/lib/sift_client/client.py | 7 + python/lib/sift_client/resources/__init__.py | 7 + python/lib/sift_client/resources/artifacts.py | 636 ++++++++++++++++++ .../sift_client/resources/file_attachments.py | 2 +- .../resources/sync_stubs/__init__.py | 9 + .../resources/sync_stubs/__init__.pyi | 362 +++++++++- .../lib/sift_client/resources/test_results.py | 2 +- python/lib/sift_client/util/util.py | 4 + 11 files changed, 1088 insertions(+), 24 deletions(-) create mode 100644 python/lib/sift_client/resources/artifacts.py diff --git a/python/lib/sift_client/_internal/low_level_wrappers/upload.py b/python/lib/sift_client/_internal/low_level_wrappers/upload.py index c15ed650c..7fd3b7762 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/upload.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/upload.py @@ -2,8 +2,9 @@ import logging import mimetypes +from contextlib import nullcontext from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, BinaryIO, ContextManager from requests_toolbelt import MultipartEncoder @@ -40,7 +41,7 @@ class UploadLowLevelClient(LowLevelClientBase, WithRestClient): # Upload a file remote_file_id = await upload_client.upload_attachment( - path="path/to/file.mp4", + source="path/to/file.mp4", entity_id="run_12345", entity_type="runs", description="Video of test run", @@ -61,48 +62,50 @@ def __init__(self, rest_client: RestClient): async def upload_attachment( self, - path: str | Path, + source: str | Path | BinaryIO, entity_id: str, entity_type: str, metadata: dict[str, Any] | None = None, description: str | None = None, organization_id: str | None = None, + file_name: str | None = None, ) -> str: """Upload a file attachment to an entity. Args: - path: Path to the file to upload. + source: Path to the file to upload, or an open binary file such as `io.BytesIO`. entity_id: The ID of the entity to attach the file to. entity_type: The type of entity (e.g., "runs", "annotations", "annotation_logs"). metadata: Optional metadata for the file (e.g., video/image metadata). description: Optional description of the file. organization_id: Optional organization ID. + file_name: The stored file name when `source` is a file object; its extension sets + the MIME type. A path upload uses the file's own name. Returns: The remote file ID of the uploaded file. Raises: - ValueError: If the path doesn't point to a regular file or MIME type cannot be determined. + ValueError: If the path doesn't point to a regular file, or a file object has no + file_name. Exception: If the upload fails. """ - posix_path = Path(path) if isinstance(path, str) else path - - if not posix_path.is_file(): - raise ValueError(f"Provided path, '{path}', does not point to a regular file.") - - file_name, mimetype, content_encoding = self._mime_and_content_type_from_path(posix_path) - - if not mimetype: - extension = posix_path.suffix - if extension: - mimetype = f"application/x-{extension.lstrip('.')}" - else: - mimetype = "application/octet-stream" # fallback to generic 'binary data' MIME type + upload: Path | BinaryIO + if isinstance(source, (str, Path)): + upload = Path(source) + if not upload.is_file(): + raise ValueError(f"Provided path, '{source}', does not point to a regular file.") + file_name, mimetype, content_encoding = self._resolve_mime(upload) + else: + if not file_name: + raise ValueError("file_name is required when uploading a file object.") + upload = source + _, mimetype, content_encoding = self._resolve_mime(Path(file_name)) # Run the synchronous file upload in a thread pool to avoid blocking the event loop return await run_sync_function( self._upload_file_sync, - posix_path, + upload, file_name, mimetype, content_encoding, @@ -115,7 +118,7 @@ async def upload_attachment( def _upload_file_sync( self, - path: Path, + source: Path | BinaryIO, file_name: str, mimetype: str, content_encoding: str | None, @@ -129,7 +132,12 @@ def _upload_file_sync( This is called from a thread pool to avoid blocking the async event loop. """ - with open(path, "rb") as file: + opened: ContextManager[BinaryIO] + if isinstance(source, Path): + opened = open(source, "rb") + else: + opened = nullcontext(source) + with opened as file: form_fields: dict[str, Any] = { "entityId": entity_id, "entityType": entity_type, @@ -179,6 +187,18 @@ def _upload_file_sync( response_data = response.json() return response_data.get("remoteFile", {}).get("remoteFileId") + @classmethod + def _resolve_mime(cls, path: Path) -> tuple[str, str, str | None]: + """Like `_mime_and_content_type_from_path`, with a fallback when the type is unknown.""" + file_name, mimetype, content_encoding = cls._mime_and_content_type_from_path(path) + if not mimetype: + extension = path.suffix + if extension: + mimetype = f"application/x-{extension.lstrip('.')}" + else: + mimetype = "application/octet-stream" # fallback to generic 'binary data' MIME type + return file_name, mimetype, content_encoding + @staticmethod def _mime_and_content_type_from_path(path: Path) -> tuple[str, str | None, str | None]: """Determine the MIME type and content encoding from a file path. diff --git a/python/lib/sift_client/_internal/util/file.py b/python/lib/sift_client/_internal/util/file.py index 5a985672d..69cb05d27 100644 --- a/python/lib/sift_client/_internal/util/file.py +++ b/python/lib/sift_client/_internal/util/file.py @@ -145,6 +145,26 @@ def download_file( return output_path +def read_url_bytes(signed_url: str, *, rest_client: RestClient) -> bytes: + """Read a presigned URL's content into memory. + + Args: + signed_url: The URL to read. + rest_client: The SDK rest client to use for the request. + + Returns: + The response body. + + Raises: + requests.HTTPError: If the request fails. + """ + # Presigned URLs carry their own auth, so strip the session's Authorization header. + headers: dict[str, str | None] = {"Authorization": None} + with rest_client.get(signed_url, headers=headers) as response: + response.raise_for_status() + return response.content + + def extract_zip( zip_path: Path, output_dir: Path, diff --git a/python/lib/sift_client/_tests/conftest.py b/python/lib/sift_client/_tests/conftest.py index c1054ecc6..dd84d4ab7 100644 --- a/python/lib/sift_client/_tests/conftest.py +++ b/python/lib/sift_client/_tests/conftest.py @@ -80,6 +80,7 @@ def mock_client(): client = MagicMock(spec=SiftClient) # Configure the mock to have the necessary API attributes client.annotations = MagicMock() + client.artifacts = MagicMock() client.assets = MagicMock() client.reports = MagicMock() client.runs = MagicMock() diff --git a/python/lib/sift_client/client.py b/python/lib/sift_client/client.py index dafb2cd3e..2a4979748 100644 --- a/python/lib/sift_client/client.py +++ b/python/lib/sift_client/client.py @@ -11,6 +11,8 @@ from sift_client.resources import ( AnnotationsAPI, AnnotationsAPIAsync, + ArtifactsAPI, + ArtifactsAPIAsync, AssetsAPI, AssetsAPIAsync, CalculatedChannelsAPI, @@ -112,6 +114,9 @@ class SiftClient( annotations: AnnotationsAPI """Instance of the Annotations API for making synchronous requests.""" + artifacts: ArtifactsAPI + """Instance of the Artifacts API for making synchronous requests.""" + assets: AssetsAPI """Instance of the Assets API for making synchronous requests.""" @@ -255,6 +260,7 @@ def __init__( self.ping = PingAPI(self) self.annotations = AnnotationsAPI(self) + self.artifacts = ArtifactsAPI(self) self.assets = AssetsAPI(self) self.calculated_channels = CalculatedChannelsAPI(self) self.campaigns = CampaignsAPI(self) @@ -280,6 +286,7 @@ def __init__( self.async_ = AsyncAPIs( ping=PingAPIAsync(self), annotations=AnnotationsAPIAsync(self), + artifacts=ArtifactsAPIAsync(self), assets=AssetsAPIAsync(self), calculated_channels=CalculatedChannelsAPIAsync(self), campaigns=CampaignsAPIAsync(self), diff --git a/python/lib/sift_client/resources/__init__.py b/python/lib/sift_client/resources/__init__.py index 65bb284fa..1bbb942db 100644 --- a/python/lib/sift_client/resources/__init__.py +++ b/python/lib/sift_client/resources/__init__.py @@ -151,6 +151,7 @@ async def main(): """ from sift_client.resources.annotations import AnnotationLogsAPIAsync, AnnotationsAPIAsync +from sift_client.resources.artifacts import ArtifactsAPIAsync, ArtifactVersionsAPIAsync from sift_client.resources.assets import AssetsAPIAsync from sift_client.resources.calculated_channels import CalculatedChannelsAPIAsync from sift_client.resources.campaigns import CampaignsAPIAsync @@ -195,6 +196,8 @@ async def main(): from sift_client.resources.sync_stubs import ( AnnotationLogsAPI, AnnotationsAPI, + ArtifactsAPI, + ArtifactVersionsAPI, AssetsAPI, CalculatedChannelsAPI, CampaignsAPI, @@ -237,6 +240,10 @@ async def main(): "AnnotationLogsAPIAsync", "AnnotationsAPI", "AnnotationsAPIAsync", + "ArtifactsAPI", + "ArtifactsAPIAsync", + "ArtifactVersionsAPI", + "ArtifactVersionsAPIAsync", "AssetsAPI", "AssetsAPIAsync", "AutoRegisterStreamingClient", diff --git a/python/lib/sift_client/resources/artifacts.py b/python/lib/sift_client/resources/artifacts.py new file mode 100644 index 000000000..2b43cda32 --- /dev/null +++ b/python/lib/sift_client/resources/artifacts.py @@ -0,0 +1,636 @@ +from __future__ import annotations + +import io +import json +from pathlib import Path +from typing import TYPE_CHECKING, BinaryIO, Sequence + +from sift_client._internal.low_level_wrappers.artifacts import ArtifactsLowLevelClient +from sift_client._internal.low_level_wrappers.remote_files import RemoteFilesLowLevelClient +from sift_client._internal.low_level_wrappers.upload import UploadLowLevelClient +from sift_client._internal.util.executor import run_sync_function +from sift_client._internal.util.file import download_file, read_url_bytes +from sift_client.resources._base import ResourceBase +from sift_client.sift_types.artifact import ( + Artifact, + ArtifactCreate, + ArtifactCreatedVia, + ArtifactEntityType, + ArtifactLinkRelation, + ArtifactType, + ArtifactUpdate, + ArtifactVersion, +) +from sift_client.sift_types.asset import Asset +from sift_client.sift_types.run import Run +from sift_client.sift_types.user import User +from sift_client.util import cel_utils as cel + +if TYPE_CHECKING: + from datetime import datetime + + from sift_client.client import SiftClient + +# The remote-files entity type that holds the bytes of an artifact version. +ARTIFACT_VERSION_ENTITY_TYPE = "artifact_versions" + + +class _ArtifactsResourceBase(ResourceBase): + """Filters shared by the artifact and artifact version listings.""" + + def _build_artifact_cel_filters( + self, + *, + title: str | None = None, + titles: list[str] | None = None, + title_contains: str | None = None, + # self ids + artifact_ids: list[str] | list[Artifact] | None = None, + # created ranges + created_after: datetime | None = None, + created_before: datetime | None = None, + # created users + created_by: User | str | None = None, + # metadata + metadata: dict[str, str | float | bool] | None = None, + # artifact specific + sources: list[Run | Asset] | None = None, + derived_from: list[Artifact | str] | None = None, + related_to: list[Run | Asset | Artifact] | None = None, + file_name: str | None = None, + created_via: ArtifactCreatedVia | None = None, + artifact_type: ArtifactType | None = None, + ) -> list[str]: + filter_parts = [ + *self._build_name_cel_filters( + name=title, names=titles, name_contains=title_contains, field="title" + ), + *self._build_time_cel_filters( + created_after=created_after, + created_before=created_before, + created_by=created_by._id_or_error if isinstance(created_by, User) else created_by, + ), + ] + if artifact_ids: + ids = [a._id_or_error if isinstance(a, Artifact) else a for a in artifact_ids] + filter_parts.append(cel.in_("artifact_id", ids)) + if metadata: + filter_parts.extend(_metadata_equals(key, value) for key, value in metadata.items()) + if sources: + filter_parts.append(_links_to(sources, ArtifactLinkRelation.SOURCE)) + if derived_from: + filter_parts.append(_links_to(derived_from, ArtifactLinkRelation.DERIVED_FROM)) + if related_to: + filter_parts.append(_links_to(related_to)) + if file_name: + filter_parts.append(cel.equals("file_name", file_name)) + if created_via is not None: + filter_parts.append(cel.equals("created_via", created_via.name)) + if artifact_type is not None: + filter_parts.append(cel.equals("storage_class", artifact_type.name)) + return filter_parts + + +class ArtifactVersionsAPIAsync(_ArtifactsResourceBase): + """High-level API for an artifact's version history. + + Every create or update writes a new version. Reachable as `client.artifacts.versions`. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the ArtifactVersionsAPI. + + Args: + sift_client: The Sift client to use. + """ + super().__init__(sift_client) + self._low_level_client = ArtifactsLowLevelClient(grpc_client=self.client.grpc_client) + + async def list_( + self, + *, + artifact: str | Artifact | None = None, + title: str | None = None, + titles: list[str] | None = None, + title_contains: str | None = None, + # self ids + artifact_ids: list[str] | list[Artifact] | None = None, + # created ranges + created_after: datetime | None = None, + created_before: datetime | None = None, + # created users + created_by: User | str | None = None, + # metadata + metadata: dict[str, str | float | bool] | None = None, + # artifact specific + sources: list[Run | Asset] | None = None, + derived_from: list[Artifact | str] | None = None, + related_to: list[Run | Asset | Artifact] | None = None, + file_name: str | None = None, + created_via: ArtifactCreatedVia | None = None, + artifact_type: ArtifactType | None = None, + # common filters + include_archived: bool = False, + filter_query: str | None = None, + order_by: str | None = None, + limit: int | None = None, + page_size: int | None = None, + ) -> list[ArtifactVersion]: + """List artifact versions, newest first by default. + + Takes the same filters as `ArtifactsAPI.list_`, evaluated against each version, so a + filter can match an earlier version that the latest no longer matches. + `ArtifactVersion.is_current` marks each artifact's current version. + + Args: + artifact: Only this Artifact or artifact ID's versions. If None, searches the + versions of every artifact. + title: Exact title of the version. + titles: Filter to versions with any of these titles. + title_contains: Partial title of the version. + artifact_ids: Filter to versions of these Artifacts or artifact IDs. + created_after: Filter to versions of artifacts created after this datetime. + created_before: Filter to versions of artifacts created before this datetime. + created_by: Filter to versions of artifacts created by this User or user ID. + metadata: Filter to versions whose metadata has all of these key-value pairs. + sources: Filter to versions produced from any of these Runs or Assets. + derived_from: Filter to versions derived from any of these Artifacts or + artifact IDs. + related_to: Filter to versions linked to any of these Runs, Assets, or + Artifacts, by any relation. + file_name: Exact file name of the version. + created_via: Filter to versions of artifacts written by this surface. + artifact_type: Filter to versions of artifacts of this type. + include_archived: If True, include versions of archived artifacts. A named + `artifact` lists its versions even when it is archived. + filter_query: Explicit CEL query with the `ArtifactsAPI.list_` fields, + evaluated against each version. + order_by: Field and direction to order results by, over `created_date`, + `version`, and `title`. The service default is `created_date desc`. + limit: Maximum number of versions to return. If None, returns all matches. + page_size: Number of results to fetch per request. + + Returns: + A list of ArtifactVersion objects that match the filter criteria. + """ + filter_parts = self._build_artifact_cel_filters( + title=title, + titles=titles, + title_contains=title_contains, + artifact_ids=artifact_ids, + created_after=created_after, + created_before=created_before, + created_by=created_by, + metadata=metadata, + sources=sources, + derived_from=derived_from, + related_to=related_to, + file_name=file_name, + created_via=created_via, + artifact_type=artifact_type, + ) + if include_archived and artifact is None: + filter_parts.append(cel.equals("include_archived", True)) + filter_parts.extend(self._build_common_cel_filters(filter_query=filter_query)) + query_filter = cel.and_(*filter_parts) + + versions = await self._low_level_client.list_all_artifact_versions( + query_filter=query_filter or None, + order_by=order_by, + artifact_id=None if artifact is None else _artifact_id(artifact), + max_results=limit, + **({"page_size": page_size} if page_size is not None else {}), + ) + return self._apply_client_to_instances(versions) + + async def promote(self, *, version: ArtifactVersion) -> Artifact: + """Write a new latest version that copies an earlier version. + + Copies the title, summary, metadata, file, and `SOURCE` and `DERIVED_FROM` links of + `version`. `ATTACHED_TO` links stay as on the latest version. Only the artifact's + creator can promote, and promoting the latest version fails. + + Args: + version: The ArtifactVersion to promote. + + Returns: + The Artifact, resolved to the version the promotion wrote. + """ + promoted = await self._low_level_client.promote_artifact_version( + artifact_id=version.artifact_id, artifact_version_id=version._id_or_error + ) + return self._apply_client_to_instance(promoted) + + +class ArtifactsAPIAsync(_ArtifactsResourceBase): + """High-level API for interacting with artifacts. + + An artifact is a versioned file, such as a plot or a report, that Sift stores and + shows in the app. Links record where the artifact surfaces (`ATTACHED_TO`) and what + it was produced from (`SOURCE`), so you can find artifacts by the runs and assets + they link to. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the ArtifactsAPI. + + Args: + sift_client: The Sift client to use. + """ + super().__init__(sift_client) + self._low_level_client = ArtifactsLowLevelClient(grpc_client=self.client.grpc_client) + self._remote_files_client = RemoteFilesLowLevelClient(grpc_client=self.client.grpc_client) + self._upload_client = UploadLowLevelClient(rest_client=self.client.rest_client) + self.versions = ArtifactVersionsAPIAsync(sift_client) + + async def get(self, *, artifact_id: str, artifact_version_id: str | None = None) -> Artifact: + """Get an artifact. + + Args: + artifact_id: The artifact to get. + artifact_version_id: Resolve to this version instead of the latest. + + Returns: + The Artifact. + """ + artifact = await self._low_level_client.get_artifact( + artifact_id=artifact_id, artifact_version_id=artifact_version_id + ) + return self._apply_client_to_instance(artifact) + + async def list_( + self, + *, + title: str | None = None, + titles: list[str] | None = None, + title_contains: str | None = None, + # self ids + artifact_ids: list[str] | list[Artifact] | None = None, + # created ranges + created_after: datetime | None = None, + created_before: datetime | None = None, + # created users + created_by: User | str | None = None, + # metadata + metadata: dict[str, str | float | bool] | None = None, + # artifact specific + sources: list[Run | Asset] | None = None, + derived_from: list[Artifact | str] | None = None, + related_to: list[Run | Asset | Artifact] | None = None, + file_name: str | None = None, + created_via: ArtifactCreatedVia | None = None, + artifact_type: ArtifactType | None = None, + # common filters + include_archived: bool = False, + filter_query: str | None = None, + order_by: str | None = None, + limit: int | None = None, + page_size: int | None = None, + ) -> list[Artifact]: + """List artifacts, each resolved to its latest version. + + `sources`, `derived_from`, and `related_to` match the latest version's links. For + other fields of the linked entity, such as a run name, use `filter_query`. + + Args: + title: Exact title of the latest version. + titles: Filter to artifacts whose latest version has any of these titles. + title_contains: Partial title of the latest version. + artifact_ids: Filter to these Artifacts or artifact IDs. + created_after: Filter to artifacts created after this datetime. + created_before: Filter to artifacts created before this datetime. + created_by: Filter to artifacts created by this User or user ID. + metadata: Filter to artifacts whose latest version has all of these key-value + pairs. + sources: Filter to artifacts produced from any of these Runs or Assets. + derived_from: Filter to artifacts derived from any of these Artifacts or + artifact IDs. + related_to: Filter to artifacts linked to any of these Runs, Assets, or + Artifacts, by any relation. + file_name: Exact file name of the latest version. + created_via: Filter to artifacts written by this surface. + artifact_type: Filter to artifacts of this type. + include_archived: If True, include archived artifacts in results. + filter_query: Explicit CEL query to filter artifacts. Links filter with a + comprehension over `runs` (relation, run_id, name, description, client_key, + start_time, stop_time), `assets` (relation, asset_id, name), `canvases` + (relation, canvas_id, name, description), or `links` (relation, + entity_type, entity_id). + order_by: Field and direction to order results by, over `created_date`, + `version_created_date`, `archived_date`, `title`, `version`, and + `file_type`. The service default is `created_date` ascending. + limit: Maximum number of artifacts to return. If None, returns all matches. + page_size: Number of results to fetch per request. + + Returns: + A list of Artifact objects that match the filter criteria. + + Example: + Artifacts produced from a run whose name contains "thermal": + + client.artifacts.list_( + filter_query='runs.exists(r, r.relation == "SOURCE" && r.name.contains("thermal"))' + ) + """ + filter_parts = self._build_artifact_cel_filters( + title=title, + titles=titles, + title_contains=title_contains, + artifact_ids=artifact_ids, + created_after=created_after, + created_before=created_before, + created_by=created_by, + metadata=metadata, + sources=sources, + derived_from=derived_from, + related_to=related_to, + file_name=file_name, + created_via=created_via, + artifact_type=artifact_type, + ) + if include_archived: + # The service excludes archived artifacts unless the filter names this directive. + filter_parts.append(cel.equals("include_archived", True)) + filter_parts.extend(self._build_common_cel_filters(filter_query=filter_query)) + query_filter = cel.and_(*filter_parts) + + artifacts = await self._low_level_client.list_all_artifacts( + query_filter=query_filter or None, + order_by=order_by, + max_results=limit, + **({"page_size": page_size} if page_size is not None else {}), + ) + return self._apply_client_to_instances(artifacts) + + async def find(self, **kwargs) -> Artifact | None: + """Find one artifact. Takes the same arguments as `list_`. + + Raises if more than one matches. + + Args: + **kwargs: Keyword arguments to pass to `list_`. + + Returns: + The Artifact found or None. + """ + artifacts = await self.list_(**kwargs) + if len(artifacts) > 1: + raise ValueError(f"Multiple ({len(artifacts)}) artifacts found for query") + elif len(artifacts) == 1: + return artifacts[0] + return None + + async def create( + self, + create: ArtifactCreate | dict, + *, + file: str | Path | bytes | BinaryIO, + file_name: str | None = None, + ) -> Artifact: + """Create an artifact from a file. + + Creates the artifact and its first version, then uploads the file to that version. + + Args: + create: The artifact's title, summary, metadata, links, and surface. + file: The file to upload: a path, bytes, or a binary file object such as + `io.BytesIO`. + file_name: The stored file name when `file` is bytes or a file object. A path + upload uses the file's own name. + + Returns: + The created Artifact, resolved to version 1. + + Raises: + ValueError: If `file` is a path that is not a regular file, or `file_name` is + missing for bytes, not a bare name, or given with a path. + + Example: + Publish bytes built in memory, without writing a file first: + + client.artifacts.create( + {"title": "Peak temperatures"}, + file=json.dumps(peaks).encode(), + file_name="peak_temperatures.json", + ) + """ + if isinstance(create, dict): + create = ArtifactCreate.model_validate(create) + _check_file(file, file_name) + + created = await self._low_level_client.create_artifact(create=create) + return await self._upload_and_get(created, file=file, file_name=file_name) + + async def update( + self, + artifact: str | Artifact, + update: ArtifactUpdate | dict, + *, + file: str | Path | bytes | BinaryIO | None = None, + file_name: str | None = None, + ) -> Artifact: + """Write a new version of an artifact. + + `ArtifactUpdate` says how each field carries forward. With `file`, the new version + gets new bytes, and its only `SOURCE` and `DERIVED_FROM` links are the ones in + `update.links`. + + Args: + artifact: The Artifact or artifact ID to update. + update: The fields to change. + file: New bytes for the new version: a path, bytes, or a binary file object. + file_name: The stored file name when `file` is bytes or a file object. A path + upload uses the file's own name. + + Returns: + The Artifact, resolved to the new version. + + Raises: + ValueError: If the update sets no field and has no `file`, or `file` and + `file_name` are invalid as for `create`. + """ + if isinstance(update, dict): + update = ArtifactUpdate.model_validate(update) + if file is None and not update.model_dump(exclude_unset=True, exclude_none=True): + raise ValueError("update needs at least one field or a file") + if file is not None: + _check_file(file, file_name) + update.resource_id = _artifact_id(artifact) + + updated = await self._low_level_client.update_artifact(update, new_file=file is not None) + if file is None: + return self._apply_client_to_instance(updated) + return await self._upload_and_get(updated, file=file, file_name=file_name) + + async def archive(self, artifact: str | Artifact) -> Artifact: + """Archive an artifact. Its versions and files stay in place. + + Args: + artifact: The Artifact or artifact ID to archive. + + Returns: + The archived Artifact. + """ + artifact_id = _artifact_id(artifact) + await self._low_level_client.archive_artifact(artifact_id) + return await self.get(artifact_id=artifact_id) + + async def unarchive(self, artifact: str | Artifact) -> Artifact: + """Unarchive an artifact. + + Args: + artifact: The Artifact or artifact ID to unarchive. + + Returns: + The unarchived Artifact. + """ + artifact_id = _artifact_id(artifact) + await self._low_level_client.unarchive_artifact(artifact_id) + return await self.get(artifact_id=artifact_id) + + async def get_download_url(self, *, artifact: str | Artifact | ArtifactVersion) -> str: + """Get a download URL for an artifact version's file. + + Args: + artifact: An ArtifactVersion, an Artifact (uses its `artifact_version`), or an + artifact ID (uses the latest version). + + Returns: + The download URL. + + Raises: + ValueError: If the version has no file. + """ + version = await self._resolve_version(artifact) + if version.remote_file_id is None: + raise ValueError(f"Artifact version {version.id_} has no file") + return await self._remote_files_client.get_remote_file_download_url( + remote_file_id=version.remote_file_id + ) + + async def download( + self, *, artifact: str | Artifact | ArtifactVersion, output_path: str | Path + ) -> None: + """Download an artifact version's file to a local path. + + Args: + artifact: An ArtifactVersion, an Artifact (uses its `artifact_version`), or an + artifact ID (uses the latest version). + output_path: The path to download the file to. + + Raises: + ValueError: If the version has no file. + """ + url = await self.get_download_url(artifact=artifact) + await run_sync_function( + lambda: download_file(url, Path(output_path), rest_client=self.client.rest_client) + ) + + async def read_bytes(self, *, artifact: str | Artifact | ArtifactVersion) -> bytes: + """Read an artifact version's file into memory. + + This holds the whole file in memory. Use `download` for large files. + + Args: + artifact: An ArtifactVersion, an Artifact (uses its `artifact_version`), or an + artifact ID (uses the latest version). + + Returns: + The file's bytes. + + Raises: + ValueError: If the version has no file. + + Example: + config = json.loads(client.artifacts.read_bytes(artifact=artifact)) + """ + url = await self.get_download_url(artifact=artifact) + return await run_sync_function( + lambda: read_url_bytes(url, rest_client=self.client.rest_client) + ) + + async def _resolve_version(self, artifact: str | Artifact | ArtifactVersion) -> ArtifactVersion: + if isinstance(artifact, ArtifactVersion): + return artifact + if isinstance(artifact, Artifact): + return artifact.artifact_version + return (await self.get(artifact_id=artifact)).artifact_version + + async def _upload_and_get( + self, artifact: Artifact, *, file: str | Path | bytes | BinaryIO, file_name: str | None + ) -> Artifact: + """Upload the file to the version `artifact` resolves to, then fetch that version. + + The version becomes the artifact's current version once the upload commits. + """ + version_id = artifact.artifact_version._id_or_error + await self._upload_client.upload_attachment( + source=io.BytesIO(file) if isinstance(file, bytes) else file, + entity_id=version_id, + entity_type=ARTIFACT_VERSION_ENTITY_TYPE, + file_name=file_name, + ) + return await self.get(artifact_id=artifact._id_or_error, artifact_version_id=version_id) + + +def _artifact_id(artifact: str | Artifact) -> str: + return artifact._id_or_error if isinstance(artifact, Artifact) else artifact + + +def _metadata_equals(key: str, value: str | float | bool) -> str: + """Match one metadata key. The service takes the key as a quoted index, `metadata["key"]`.""" + quoted_key = json.dumps(key) + if isinstance(value, (bool, str)): + return cel.equals(f"metadata[{quoted_key}]", value) + return f"metadata[{quoted_key}] == {float(value)}" + + +def _links_to( + entities: Sequence[Run | Asset | Artifact | str], + relation: ArtifactLinkRelation | None = None, +) -> str: + """Match a version linked to any of `entities`, by `relation` when one is given. + + A string is an artifact ID, and is accepted only for DERIVED_FROM, where the entity + type cannot be anything else. + """ + targets = [] + for entity in entities: + if isinstance(entity, Run): + entity_type, entity_id = ArtifactEntityType.RUN, entity._id_or_error + elif isinstance(entity, Asset): + entity_type, entity_id = ArtifactEntityType.ASSET, entity._id_or_error + elif isinstance(entity, Artifact): + entity_type, entity_id = ArtifactEntityType.ARTIFACT, entity._id_or_error + elif isinstance(entity, str) and relation == ArtifactLinkRelation.DERIVED_FROM: + entity_type, entity_id = ArtifactEntityType.ARTIFACT, entity + else: + raise TypeError( + f"Cannot filter by a link to {entity!r}; pass a Run, Asset, or Artifact" + ) + targets.append( + cel.parens( + cel.and_( + cel.equals("l.entity_type", entity_type.name), + cel.equals("l.entity_id", entity_id), + ) + ) + ) + match = cel.parens(cel.or_(*targets)) + if relation is not None: + match = cel.and_(cel.equals("l.relation", relation.name), match) + return f"links.exists(l, {match})" + + +def _check_file(file: str | Path | bytes | BinaryIO, file_name: str | None) -> None: + """Reject a bad file before any RPC, so a failed upload cannot strand a new version.""" + if isinstance(file, (str, Path)): + if file_name is not None: + raise ValueError("file_name applies only when file is bytes or a file object") + if not Path(file).is_file(): + raise ValueError(f"Provided path, '{file}', does not point to a regular file.") + return + if not file_name: + raise ValueError("file_name is required when file is bytes or a file object") + if Path(file_name).name != file_name: + raise ValueError(f"file_name must be a bare file name, not a path: {file_name!r}") diff --git a/python/lib/sift_client/resources/file_attachments.py b/python/lib/sift_client/resources/file_attachments.py index 89084ac3f..b630f7cc7 100644 --- a/python/lib/sift_client/resources/file_attachments.py +++ b/python/lib/sift_client/resources/file_attachments.py @@ -267,7 +267,7 @@ async def upload( The uploaded FileAttachment. """ remote_file_id = await self._upload_client.upload_attachment( - path=path, + source=path, entity_id=entity._id_or_error, entity_type=entity._get_entity_type_name(), metadata=metadata, diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.py b/python/lib/sift_client/resources/sync_stubs/__init__.py index 00c204e9f..17e77faf8 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.py +++ b/python/lib/sift_client/resources/sync_stubs/__init__.py @@ -6,6 +6,8 @@ from sift_client.resources import ( AnnotationLogsAPIAsync, AnnotationsAPIAsync, + ArtifactsAPIAsync, + ArtifactVersionsAPIAsync, AssetsAPIAsync, CalculatedChannelsAPIAsync, CampaignsAPIAsync, @@ -41,6 +43,11 @@ AnnotationsAPI = generate_sync_api( AnnotationsAPIAsync, "AnnotationsAPI", nested_resources={"logs": AnnotationLogsAPI} ) +# ArtifactVersionsAPI must be generated before ArtifactsAPI so it can be nested under it. +ArtifactVersionsAPI = generate_sync_api(ArtifactVersionsAPIAsync, "ArtifactVersionsAPI") +ArtifactsAPI = generate_sync_api( + ArtifactsAPIAsync, "ArtifactsAPI", nested_resources={"versions": ArtifactVersionsAPI} +) AssetsAPI = generate_sync_api(AssetsAPIAsync, "AssetsAPI") CalculatedChannelsAPI = generate_sync_api(CalculatedChannelsAPIAsync, "CalculatedChannelsAPI") CampaignsAPI = generate_sync_api(CampaignsAPIAsync, "CampaignsAPI") @@ -111,6 +118,8 @@ __all__ = [ "AnnotationLogsAPI", "AnnotationsAPI", + "ArtifactVersionsAPI", + "ArtifactsAPI", "AssetsAPI", "CalculatedChannelsAPI", "CampaignsAPI", diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 792947a47..b4389466d 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -9,7 +9,7 @@ if TYPE_CHECKING: from collections.abc import Iterable from datetime import datetime, timedelta from pathlib import Path - from typing import TYPE_CHECKING, Any, Sequence + from typing import TYPE_CHECKING, Any, BinaryIO, Sequence import pandas as pd import pyarrow as pa @@ -28,6 +28,14 @@ if TYPE_CHECKING: AnnotationType, AnnotationUpdate, ) + from sift_client.sift_types.artifact import ( + Artifact, + ArtifactCreate, + ArtifactCreatedVia, + ArtifactType, + ArtifactUpdate, + ArtifactVersion, + ) from sift_client.sift_types.asset import Asset, AssetUpdate from sift_client.sift_types.calculated_channel import ( CalculatedChannel, @@ -427,6 +435,358 @@ class AnnotationsAPI: """Nested AnnotationLogsAPI for making synchronous requests.""" ... +class ArtifactVersionsAPI: + """Sync counterpart to `ArtifactVersionsAPIAsync`. + + High-level API for an artifact's version history. + + Every create or update writes a new version. Reachable as `client.artifacts.versions`. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the ArtifactVersionsAPI. + + Args: + sift_client: The Sift client to use. + """ + ... + + def _run(self, coro): ... + def list_( + self, + *, + artifact: str | Artifact | None = None, + title: str | None = None, + titles: list[str] | None = None, + title_contains: str | None = None, + artifact_ids: list[str] | list[Artifact] | None = None, + created_after: datetime | None = None, + created_before: datetime | None = None, + created_by: User | str | None = None, + metadata: dict[str, str | float | bool] | None = None, + sources: list[Run | Asset] | None = None, + derived_from: list[Artifact | str] | None = None, + related_to: list[Run | Asset | Artifact] | None = None, + file_name: str | None = None, + created_via: ArtifactCreatedVia | None = None, + artifact_type: ArtifactType | None = None, + include_archived: bool = False, + filter_query: str | None = None, + order_by: str | None = None, + limit: int | None = None, + page_size: int | None = None, + ) -> list[ArtifactVersion]: + """List artifact versions, newest first by default. + + Takes the same filters as `ArtifactsAPI.list_`, evaluated against each version, so a + filter can match an earlier version that the latest no longer matches. + `ArtifactVersion.is_current` marks each artifact's current version. + + Args: + artifact: Only this Artifact or artifact ID's versions. If None, searches the + versions of every artifact. + title: Exact title of the version. + titles: Filter to versions with any of these titles. + title_contains: Partial title of the version. + artifact_ids: Filter to versions of these Artifacts or artifact IDs. + created_after: Filter to versions of artifacts created after this datetime. + created_before: Filter to versions of artifacts created before this datetime. + created_by: Filter to versions of artifacts created by this User or user ID. + metadata: Filter to versions whose metadata has all of these key-value pairs. + sources: Filter to versions produced from any of these Runs or Assets. + derived_from: Filter to versions derived from any of these Artifacts or + artifact IDs. + related_to: Filter to versions linked to any of these Runs, Assets, or + Artifacts, by any relation. + file_name: Exact file name of the version. + created_via: Filter to versions of artifacts written by this surface. + artifact_type: Filter to versions of artifacts of this type. + include_archived: If True, include versions of archived artifacts. A named + `artifact` lists its versions even when it is archived. + filter_query: Explicit CEL query with the `ArtifactsAPI.list_` fields, + evaluated against each version. + order_by: Field and direction to order results by, over `created_date`, + `version`, and `title`. The service default is `created_date desc`. + limit: Maximum number of versions to return. If None, returns all matches. + page_size: Number of results to fetch per request. + + Returns: + A list of ArtifactVersion objects that match the filter criteria. + """ + ... + + def promote(self, *, version: ArtifactVersion) -> Artifact: + """Write a new latest version that copies an earlier version. + + Copies the title, summary, metadata, file, and `SOURCE` and `DERIVED_FROM` links of + `version`. `ATTACHED_TO` links stay as on the latest version. Only the artifact's + creator can promote, and promoting the latest version fails. + + Args: + version: The ArtifactVersion to promote. + + Returns: + The Artifact, resolved to the version the promotion wrote. + """ + ... + +class ArtifactsAPI: + """Sync counterpart to `ArtifactsAPIAsync`. + + High-level API for interacting with artifacts. + + An artifact is a versioned file, such as a plot or a report, that Sift stores and + shows in the app. Links record where the artifact surfaces (`ATTACHED_TO`) and what + it was produced from (`SOURCE`), so you can find artifacts by the runs and assets + they link to. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the ArtifactsAPI. + + Args: + sift_client: The Sift client to use. + """ + ... + + def _run(self, coro): ... + def archive(self, artifact: str | Artifact) -> Artifact: + """Archive an artifact. Its versions and files stay in place. + + Args: + artifact: The Artifact or artifact ID to archive. + + Returns: + The archived Artifact. + """ + ... + + def create( + self, + create: ArtifactCreate | dict, + *, + file: str | Path | bytes | BinaryIO, + file_name: str | None = None, + ) -> Artifact: + """Create an artifact from a file. + + Creates the artifact and its first version, then uploads the file to that version. + + Args: + create: The artifact's title, summary, metadata, links, and surface. + file: The file to upload: a path, bytes, or a binary file object such as + `io.BytesIO`. + file_name: The stored file name when `file` is bytes or a file object. A path + upload uses the file's own name. + + Returns: + The created Artifact, resolved to version 1. + + Raises: + ValueError: If `file` is a path that is not a regular file, or `file_name` is + missing for bytes, not a bare name, or given with a path. + + Example: + Publish bytes built in memory, without writing a file first: + + client.artifacts.create( + {"title": "Peak temperatures"}, + file=json.dumps(peaks).encode(), + file_name="peak_temperatures.json", + ) + """ + ... + + def download( + self, *, artifact: str | Artifact | ArtifactVersion, output_path: str | Path + ) -> None: + """Download an artifact version's file to a local path. + + Args: + artifact: An ArtifactVersion, an Artifact (uses its `artifact_version`), or an + artifact ID (uses the latest version). + output_path: The path to download the file to. + + Raises: + ValueError: If the version has no file. + """ + ... + + def find(self, **kwargs) -> Artifact | None: + """Find one artifact. Takes the same arguments as `list_`. + + Raises if more than one matches. + + Args: + **kwargs: Keyword arguments to pass to `list_`. + + Returns: + The Artifact found or None. + """ + ... + + def get(self, *, artifact_id: str, artifact_version_id: str | None = None) -> Artifact: + """Get an artifact. + + Args: + artifact_id: The artifact to get. + artifact_version_id: Resolve to this version instead of the latest. + + Returns: + The Artifact. + """ + ... + + def get_download_url(self, *, artifact: str | Artifact | ArtifactVersion) -> str: + """Get a download URL for an artifact version's file. + + Args: + artifact: An ArtifactVersion, an Artifact (uses its `artifact_version`), or an + artifact ID (uses the latest version). + + Returns: + The download URL. + + Raises: + ValueError: If the version has no file. + """ + ... + + def list_( + self, + *, + title: str | None = None, + titles: list[str] | None = None, + title_contains: str | None = None, + artifact_ids: list[str] | list[Artifact] | None = None, + created_after: datetime | None = None, + created_before: datetime | None = None, + created_by: User | str | None = None, + metadata: dict[str, str | float | bool] | None = None, + sources: list[Run | Asset] | None = None, + derived_from: list[Artifact | str] | None = None, + related_to: list[Run | Asset | Artifact] | None = None, + file_name: str | None = None, + created_via: ArtifactCreatedVia | None = None, + artifact_type: ArtifactType | None = None, + include_archived: bool = False, + filter_query: str | None = None, + order_by: str | None = None, + limit: int | None = None, + page_size: int | None = None, + ) -> list[Artifact]: + """List artifacts, each resolved to its latest version. + + `sources`, `derived_from`, and `related_to` match the latest version's links. For + other fields of the linked entity, such as a run name, use `filter_query`. + + Args: + title: Exact title of the latest version. + titles: Filter to artifacts whose latest version has any of these titles. + title_contains: Partial title of the latest version. + artifact_ids: Filter to these Artifacts or artifact IDs. + created_after: Filter to artifacts created after this datetime. + created_before: Filter to artifacts created before this datetime. + created_by: Filter to artifacts created by this User or user ID. + metadata: Filter to artifacts whose latest version has all of these key-value + pairs. + sources: Filter to artifacts produced from any of these Runs or Assets. + derived_from: Filter to artifacts derived from any of these Artifacts or + artifact IDs. + related_to: Filter to artifacts linked to any of these Runs, Assets, or + Artifacts, by any relation. + file_name: Exact file name of the latest version. + created_via: Filter to artifacts written by this surface. + artifact_type: Filter to artifacts of this type. + include_archived: If True, include archived artifacts in results. + filter_query: Explicit CEL query to filter artifacts. Links filter with a + comprehension over `runs` (relation, run_id, name, description, client_key, + start_time, stop_time), `assets` (relation, asset_id, name), `canvases` + (relation, canvas_id, name, description), or `links` (relation, + entity_type, entity_id). + order_by: Field and direction to order results by, over `created_date`, + `version_created_date`, `archived_date`, `title`, `version`, and + `file_type`. The service default is `created_date` ascending. + limit: Maximum number of artifacts to return. If None, returns all matches. + page_size: Number of results to fetch per request. + + Returns: + A list of Artifact objects that match the filter criteria. + + Example: + Artifacts produced from a run whose name contains "thermal": + + client.artifacts.list_( + filter_query='runs.exists(r, r.relation == "SOURCE" && r.name.contains("thermal"))' + ) + """ + ... + + def read_bytes(self, *, artifact: str | Artifact | ArtifactVersion) -> bytes: + """Read an artifact version's file into memory. + + This holds the whole file in memory. Use `download` for large files. + + Args: + artifact: An ArtifactVersion, an Artifact (uses its `artifact_version`), or an + artifact ID (uses the latest version). + + Returns: + The file's bytes. + + Raises: + ValueError: If the version has no file. + + Example: + config = json.loads(client.artifacts.read_bytes(artifact=artifact)) + """ + ... + + def unarchive(self, artifact: str | Artifact) -> Artifact: + """Unarchive an artifact. + + Args: + artifact: The Artifact or artifact ID to unarchive. + + Returns: + The unarchived Artifact. + """ + ... + + def update( + self, + artifact: str | Artifact, + update: ArtifactUpdate | dict, + *, + file: str | Path | bytes | BinaryIO | None = None, + file_name: str | None = None, + ) -> Artifact: + """Write a new version of an artifact. + + `ArtifactUpdate` says how each field carries forward. With `file`, the new version + gets new bytes, and its only `SOURCE` and `DERIVED_FROM` links are the ones in + `update.links`. + + Args: + artifact: The Artifact or artifact ID to update. + update: The fields to change. + file: New bytes for the new version: a path, bytes, or a binary file object. + file_name: The stored file name when `file` is bytes or a file object. A path + upload uses the file's own name. + + Returns: + The Artifact, resolved to the new version. + + Raises: + ValueError: If the update sets no field and has no `file`, or `file` and + `file_name` are invalid as for `create`. + """ + ... + @property + def versions(self) -> ArtifactVersionsAPI: + """Nested ArtifactVersionsAPI for making synchronous requests.""" + ... + class AssetsAPI: """Sync counterpart to `AssetsAPIAsync`. diff --git a/python/lib/sift_client/resources/test_results.py b/python/lib/sift_client/resources/test_results.py index 987d7c92d..7ae0f08aa 100644 --- a/python/lib/sift_client/resources/test_results.py +++ b/python/lib/sift_client/resources/test_results.py @@ -70,7 +70,7 @@ async def import_(self, test_file: str | Path) -> TestReport: # Generate a temporary UUID for the test report. The report service will override this with the created report ID. temp_uuid = str(uuid.uuid4()) remote_file_id = await self._upload_client.upload_attachment( - path=test_file, + source=test_file, entity_id=temp_uuid, entity_type="test_reports", ) diff --git a/python/lib/sift_client/util/util.py b/python/lib/sift_client/util/util.py index a9da1b920..b693e3f8c 100644 --- a/python/lib/sift_client/util/util.py +++ b/python/lib/sift_client/util/util.py @@ -5,6 +5,7 @@ if TYPE_CHECKING: from sift_client.resources import ( AnnotationsAPIAsync, + ArtifactsAPIAsync, AssetsAPIAsync, CalculatedChannelsAPIAsync, CampaignsAPIAsync, @@ -36,6 +37,9 @@ class AsyncAPIs(NamedTuple): annotations: AnnotationsAPIAsync """Instance of the Annotations API for making asynchronous requests.""" + artifacts: ArtifactsAPIAsync + """Instance of the Artifacts API for making asynchronous requests.""" + assets: AssetsAPIAsync """Instance of the Assets API for making asynchronous requests.""" From 5eb84dde549df0791f532d591a46a1b578494230 Mon Sep 17 00:00:00 2001 From: Alex Luck Date: Mon, 19 Oct 2026 13:57:22 -0700 Subject: [PATCH 3/4] python(test): add artifacts tests --- .../low_level_wrappers/test_artifacts.py | 201 ++++++++ .../low_level_wrappers/test_upload.py | 64 ++- .../_tests/_internal/util/test_file.py | 17 +- .../_tests/resources/test_artifacts.py | 445 ++++++++++++++++++ .../_tests/sift_types/test_artifact.py | 276 +++++++++++ 5 files changed, 997 insertions(+), 6 deletions(-) create mode 100644 python/lib/sift_client/_tests/_internal/low_level_wrappers/test_artifacts.py create mode 100644 python/lib/sift_client/_tests/resources/test_artifacts.py create mode 100644 python/lib/sift_client/_tests/sift_types/test_artifact.py diff --git a/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_artifacts.py b/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_artifacts.py new file mode 100644 index 000000000..95687c4bc --- /dev/null +++ b/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_artifacts.py @@ -0,0 +1,201 @@ +"""Tests for the artifacts low-level wrapper.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from google.protobuf.timestamp_pb2 import Timestamp +from sift.artifacts.v1 import artifacts_pb2 + +from sift_client._internal.low_level_wrappers.artifacts import ArtifactsLowLevelClient +from sift_client.sift_types import ArtifactCreate, ArtifactUpdate + + +def _client_with_stub(stub: MagicMock) -> ArtifactsLowLevelClient: + grpc = MagicMock() + grpc.get_stub.return_value = stub + return ArtifactsLowLevelClient(grpc) + + +def _details(artifact_id: str = "a1", version_id: str = "v1") -> artifacts_pb2.ArtifactDetails: + return artifacts_pb2.ArtifactDetails( + artifact=artifacts_pb2.Artifact( + artifact_id=artifact_id, current_version_id=version_id, created_date=Timestamp() + ), + artifact_version=artifacts_pb2.ArtifactVersion( + artifact_version_id=version_id, artifact_id=artifact_id, created_date=Timestamp() + ), + ) + + +def _version(version_id: str) -> artifacts_pb2.ArtifactVersion: + return artifacts_pb2.ArtifactVersion( + artifact_version_id=version_id, artifact_id="a1", created_date=Timestamp() + ) + + +class TestCreateArtifact: + @pytest.mark.asyncio + async def test_sends_create_request(self): + stub = MagicMock() + stub.CreateArtifact = AsyncMock( + return_value=artifacts_pb2.CreateArtifactResponse(artifact=_details()) + ) + client = _client_with_stub(stub) + + artifact = await client.create_artifact(ArtifactCreate(title="t")) + + request = stub.CreateArtifact.call_args[0][0] + assert request.title == "t" + assert artifact.id_ == "a1" + assert artifact.artifact_version.id_ == "v1" + + +class TestUpdateArtifact: + @pytest.mark.asyncio + async def test_keeps_bytes_by_default(self): + stub = MagicMock() + stub.UpdateArtifact = AsyncMock( + return_value=artifacts_pb2.UpdateArtifactResponse(artifact=_details(version_id="v2")) + ) + client = _client_with_stub(stub) + update = ArtifactUpdate(title="t") + update.resource_id = "a1" + + artifact = await client.update_artifact(update) + + request = stub.UpdateArtifact.call_args[0][0] + assert request.artifact_id == "a1" + assert list(request.update_mask.paths) == ["artifact_version.title"] + assert artifact.artifact_version.id_ == "v2" + + @pytest.mark.asyncio + async def test_new_file_declares_remote_file_id(self): + stub = MagicMock() + stub.UpdateArtifact = AsyncMock( + return_value=artifacts_pb2.UpdateArtifactResponse(artifact=_details()) + ) + client = _client_with_stub(stub) + update = ArtifactUpdate() + update.resource_id = "a1" + + await client.update_artifact(update, new_file=True) + + request = stub.UpdateArtifact.call_args[0][0] + assert list(request.update_mask.paths) == ["artifact_version.remote_file_id"] + + +class TestGetArtifact: + @pytest.mark.asyncio + async def test_latest(self): + stub = MagicMock() + stub.GetArtifact = AsyncMock( + return_value=artifacts_pb2.GetArtifactResponse(artifact=_details()) + ) + client = _client_with_stub(stub) + + await client.get_artifact("a1") + + request = stub.GetArtifact.call_args[0][0] + assert request.artifact_id == "a1" + assert not request.HasField("artifact_version_id") + + @pytest.mark.asyncio + async def test_pinned_version(self): + stub = MagicMock() + stub.GetArtifact = AsyncMock( + return_value=artifacts_pb2.GetArtifactResponse(artifact=_details()) + ) + client = _client_with_stub(stub) + + await client.get_artifact("a1", artifact_version_id="v1") + + assert stub.GetArtifact.call_args[0][0].artifact_version_id == "v1" + + +class TestListAllArtifacts: + @pytest.mark.asyncio + async def test_follows_pagination_and_passes_query(self): + stub = MagicMock() + stub.ListArtifacts = AsyncMock( + side_effect=[ + artifacts_pb2.ListArtifactsResponse( + artifacts=[_details("a1")], next_page_token="tok" + ), + artifacts_pb2.ListArtifactsResponse(artifacts=[_details("a2")]), + ] + ) + client = _client_with_stub(stub) + + artifacts = await client.list_all_artifacts( + query_filter="title == 't'", order_by="created_date desc" + ) + + assert [a.id_ for a in artifacts] == ["a1", "a2"] + first, second = (call[0][0] for call in stub.ListArtifacts.call_args_list) + assert first.filter == "title == 't'" + assert first.order_by == "created_date desc" + assert second.page_token == "tok" + + +class TestListAllArtifactVersions: + @pytest.mark.asyncio + async def test_unset_artifact_searches_all(self): + stub = MagicMock() + stub.ListArtifactVersions = AsyncMock( + return_value=artifacts_pb2.ListArtifactVersionsResponse( + versions=[_version("v2"), _version("v1")] + ) + ) + client = _client_with_stub(stub) + + versions = await client.list_all_artifact_versions(query_filter="version > 1") + + request = stub.ListArtifactVersions.call_args[0][0] + assert request.artifact_id == "" + assert request.filter == "version > 1" + assert [v.id_ for v in versions] == ["v2", "v1"] + + @pytest.mark.asyncio + async def test_named_artifact(self): + stub = MagicMock() + stub.ListArtifactVersions = AsyncMock( + return_value=artifacts_pb2.ListArtifactVersionsResponse() + ) + client = _client_with_stub(stub) + + await client.list_all_artifact_versions(artifact_id="a1", max_results=5) + + request = stub.ListArtifactVersions.call_args[0][0] + assert request.artifact_id == "a1" + assert request.page_size == 5 + + +class TestSimpleCalls: + @pytest.mark.asyncio + async def test_promote(self): + stub = MagicMock() + stub.PromoteArtifactVersion = AsyncMock( + return_value=artifacts_pb2.PromoteArtifactVersionResponse( + artifact=_details(version_id="v3") + ) + ) + client = _client_with_stub(stub) + + artifact = await client.promote_artifact_version("a1", "v1") + + request = stub.PromoteArtifactVersion.call_args[0][0] + assert (request.artifact_id, request.artifact_version_id) == ("a1", "v1") + assert artifact.artifact_version.id_ == "v3" + + @pytest.mark.asyncio + async def test_archive_and_unarchive(self): + stub = MagicMock() + stub.ArchiveArtifact = AsyncMock() + stub.UnarchiveArtifact = AsyncMock() + client = _client_with_stub(stub) + + await client.archive_artifact("a1") + await client.unarchive_artifact("a1") + + assert stub.ArchiveArtifact.call_args[0][0].artifact_id == "a1" + assert stub.UnarchiveArtifact.call_args[0][0].artifact_id == "a1" diff --git a/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_upload.py b/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_upload.py index c9b511174..ae004558a 100644 --- a/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_upload.py +++ b/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_upload.py @@ -1,7 +1,8 @@ """Tests for UploadLowLevelClient functionality.""" +import io from pathlib import Path -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -17,7 +18,7 @@ async def test_known_mime_type(self, tmp_path): client = UploadLowLevelClient.__new__(UploadLowLevelClient) with patch.object(client, "_upload_file_sync", return_value="remote-file-123") as mock: - await client.upload_attachment(path=test_file, entity_id="e1", entity_type="runs") + await client.upload_attachment(source=test_file, entity_id="e1", entity_type="runs") _, _, mimetype, *_ = mock.call_args[0] assert mimetype == "video/mp4" @@ -30,7 +31,7 @@ async def test_unknown_extension_falls_back_to_application_x_ext(self, tmp_path) client = UploadLowLevelClient.__new__(UploadLowLevelClient) with patch.object(client, "_upload_file_sync", return_value="remote-file-123") as mock: - await client.upload_attachment(path=test_file, entity_id="e1", entity_type="runs") + await client.upload_attachment(source=test_file, entity_id="e1", entity_type="runs") _, _, mimetype, *_ = mock.call_args[0] assert mimetype == "application/x-pcapng" @@ -43,7 +44,7 @@ async def test_no_extension_falls_back_to_octet_stream(self, tmp_path): client = UploadLowLevelClient.__new__(UploadLowLevelClient) with patch.object(client, "_upload_file_sync", return_value="remote-file-123") as mock: - await client.upload_attachment(path=test_file, entity_id="e1", entity_type="runs") + await client.upload_attachment(source=test_file, entity_id="e1", entity_type="runs") _, _, mimetype, *_ = mock.call_args[0] assert mimetype == "application/octet-stream" @@ -56,7 +57,60 @@ async def test_file_name_preserved(self, tmp_path): client = UploadLowLevelClient.__new__(UploadLowLevelClient) with patch.object(client, "_upload_file_sync", return_value="remote-file-123") as mock: - await client.upload_attachment(path=test_file, entity_id="e1", entity_type="runs") + await client.upload_attachment(source=test_file, entity_id="e1", entity_type="runs") file_name, *_ = mock.call_args[0] assert file_name == Path(test_file) + + +class TestUploadFileObject: + @pytest.mark.asyncio + async def test_uses_file_name_for_mime_type(self): + client = UploadLowLevelClient.__new__(UploadLowLevelClient) + data = io.BytesIO(b"fake data") + + with patch.object(client, "_upload_file_sync", return_value="remote-file-123") as mock: + remote_file_id = await client.upload_attachment( + source=data, + file_name="peaks.parquet", + entity_id="v1", + entity_type="artifact_versions", + ) + + source, file_name, mimetype, *_ = mock.call_args[0] + assert remote_file_id == "remote-file-123" + assert source is data + assert file_name == "peaks.parquet" + assert mimetype == "application/vnd.apache.parquet" + + @pytest.mark.asyncio + async def test_file_object_needs_file_name(self): + client = UploadLowLevelClient.__new__(UploadLowLevelClient) + + with pytest.raises(ValueError, match="file_name is required"): + await client.upload_attachment( + source=io.BytesIO(b"x"), entity_id="v1", entity_type="artifact_versions" + ) + + def test_posts_file_object_without_opening_a_path(self): + client = UploadLowLevelClient.__new__(UploadLowLevelClient) + client._rest_client = MagicMock() + client._rest_client.post.return_value.status_code = 200 + client._rest_client.post.return_value.json.return_value = { + "remoteFile": {"remoteFileId": "rf1"} + } + + remote_file_id = client._upload_file_sync( + io.BytesIO(b"a,b\n1,2\n"), + "t.csv", + "text/csv", + None, + "v1", + "artifact_versions", + None, + None, + None, + ) + + assert remote_file_id == "rf1" + client._rest_client.post.assert_called_once() diff --git a/python/lib/sift_client/_tests/_internal/util/test_file.py b/python/lib/sift_client/_tests/_internal/util/test_file.py index 1d14014f0..80cdbac02 100644 --- a/python/lib/sift_client/_tests/_internal/util/test_file.py +++ b/python/lib/sift_client/_tests/_internal/util/test_file.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from sift_client._internal.util.file import download_file +from sift_client._internal.util.file import download_file, read_url_bytes class _FakeResponse: @@ -17,6 +17,10 @@ def raise_for_status(self) -> None: ... def iter_content(self, chunk_size: int): return iter(self._chunks) + @property + def content(self) -> bytes: + return b"".join(self._chunks) + def __enter__(self): return self @@ -93,3 +97,14 @@ def test_extra_headers_can_override_authorization_strip(self, tmp_path): ) assert captured["headers"]["Authorization"] == "Bearer proxy-token" + + +class TestReadUrlBytes: + def test_returns_body_without_session_auth(self): + rest_client, captured = _install_rest_client_stub(_FakeResponse([b"hello ", b"world"])) + + data = read_url_bytes("https://example.test/signed", rest_client=rest_client) + + assert data == b"hello world" + assert captured["url"] == "https://example.test/signed" + assert captured["headers"] == {"Authorization": None} diff --git a/python/lib/sift_client/_tests/resources/test_artifacts.py b/python/lib/sift_client/_tests/resources/test_artifacts.py new file mode 100644 index 000000000..717250d74 --- /dev/null +++ b/python/lib/sift_client/_tests/resources/test_artifacts.py @@ -0,0 +1,445 @@ +"""Pytest tests for the Artifacts API. + +These tests demonstrate and validate the usage of the ArtifactsAPI including: +- Filter building for artifacts and version history +- Create and update with a file upload +- Download of a version's file +- An integration flow against a live Sift instance +""" + +from __future__ import annotations + +import io +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from google.protobuf.timestamp_pb2 import Timestamp +from sift.artifacts.v1 import artifacts_pb2 + +from sift_client.resources import ArtifactsAPI, ArtifactsAPIAsync, ArtifactVersionsAPI +from sift_client.sift_types import ( + Artifact, + ArtifactCreatedVia, + ArtifactEntityType, + ArtifactLink, + ArtifactLinkRelation, + ArtifactType, + ArtifactVersion, +) +from sift_client.sift_types.asset import Asset +from sift_client.sift_types.run import Run + + +def _artifact(version_id: str = "v1", remote_file_id: str | None = None) -> Artifact: + return Artifact._from_proto( + artifacts_pb2.ArtifactDetails( + artifact=artifacts_pb2.Artifact( + artifact_id="a1", current_version_id=version_id, created_date=Timestamp() + ), + artifact_version=artifacts_pb2.ArtifactVersion( + artifact_version_id=version_id, + artifact_id="a1", + remote_file_id=remote_file_id, + created_date=Timestamp(), + ), + ) + ) + + +def _run(run_id: str) -> Run: + run = MagicMock(spec=Run) + run._id_or_error = run_id + return run + + +def _asset(asset_id: str) -> Asset: + asset = MagicMock(spec=Asset) + asset._id_or_error = asset_id + return asset + + +@pytest.fixture +def artifacts_api(): + client = MagicMock() + with patch("sift_client.resources.artifacts.ArtifactsLowLevelClient", autospec=True), patch( + "sift_client.resources.artifacts.RemoteFilesLowLevelClient", autospec=True + ), patch("sift_client.resources.artifacts.UploadLowLevelClient", autospec=True): + api = ArtifactsAPIAsync(client) + api._low_level_client.get_artifact = AsyncMock(return_value=_artifact("v1", "rf1")) + api._low_level_client.create_artifact = AsyncMock(return_value=_artifact("v1")) + api._low_level_client.update_artifact = AsyncMock(return_value=_artifact("v2")) + api._low_level_client.list_all_artifacts = AsyncMock(return_value=[]) + api._low_level_client.list_all_artifact_versions = AsyncMock(return_value=[]) + api._low_level_client.archive_artifact = AsyncMock() + api._low_level_client.unarchive_artifact = AsyncMock() + api._low_level_client.promote_artifact_version = AsyncMock(return_value=_artifact("v3")) + api._upload_client.upload_attachment = AsyncMock(return_value="rf1") + api._remote_files_client.get_remote_file_download_url = AsyncMock(return_value="https://x") + return api + + +class TestListFilters: + @pytest.mark.asyncio + async def test_no_arguments_sends_no_filter(self, artifacts_api): + await artifacts_api.list_() + + kwargs = artifacts_api._low_level_client.list_all_artifacts.call_args.kwargs + assert kwargs["query_filter"] is None + + @pytest.mark.asyncio + async def test_combines_filters(self, artifacts_api): + await artifacts_api.list_( + title_contains="thermal", + created_via=ArtifactCreatedVia.CANVAS, + artifact_type=ArtifactType.FILE, + include_archived=True, + filter_query='runs.exists(r, r.relation == "SOURCE" && r.name.contains("hot"))', + order_by="created_date desc", + limit=10, + ) + + kwargs = artifacts_api._low_level_client.list_all_artifacts.call_args.kwargs + assert kwargs["query_filter"] == ( + "title.contains('thermal') && created_via == 'CANVAS' && storage_class == 'FILE'" + " && include_archived == true" + ' && runs.exists(r, r.relation == "SOURCE" && r.name.contains("hot"))' + ) + assert kwargs["order_by"] == "created_date desc" + assert kwargs["max_results"] == 10 + + @pytest.mark.asyncio + async def test_ids_and_creation(self, artifacts_api): + created_after = datetime(2026, 1, 1, tzinfo=timezone.utc) + + await artifacts_api.list_( + artifact_ids=["a1", "a2"], + created_after=created_after, + created_by="u1", + file_name="plot.png", + ) + + query = artifacts_api._low_level_client.list_all_artifacts.call_args.kwargs["query_filter"] + assert query.split(" && ") == [ + "created_date > timestamp('2026-01-01T00:00:00+00:00')", + "created_by_user_id == 'u1'", + "artifact_id in ['a1','a2']", + "file_name == 'plot.png'", + ] + + @pytest.mark.asyncio + async def test_titles_and_metadata(self, artifacts_api): + await artifacts_api.list_( + titles=["a", "b"], metadata={"campaign": "qual-2", "rig": 4, "passed": True} + ) + + query = artifacts_api._low_level_client.list_all_artifacts.call_args.kwargs["query_filter"] + assert query.split(" && ") == [ + "title in ['a','b']", + "metadata[\"campaign\"] == 'qual-2'", + 'metadata["rig"] == 4.0', + 'metadata["passed"] == true', + ] + + @pytest.mark.asyncio + async def test_sources_match_any_entity_by_relation(self, artifacts_api): + await artifacts_api.list_(sources=[_run("r1"), _asset("as1")]) + + query = artifacts_api._low_level_client.list_all_artifacts.call_args.kwargs["query_filter"] + assert query == ( + "links.exists(l, l.relation == 'SOURCE'" + " && ((l.entity_type == 'RUN' && l.entity_id == 'r1')" + " || (l.entity_type == 'ASSET' && l.entity_id == 'as1')))" + ) + + @pytest.mark.asyncio + async def test_derived_from_takes_artifacts_or_ids(self, artifacts_api): + await artifacts_api.list_(derived_from=[_artifact(), "a2"]) + + query = artifacts_api._low_level_client.list_all_artifacts.call_args.kwargs["query_filter"] + assert query == ( + "links.exists(l, l.relation == 'DERIVED_FROM'" + " && ((l.entity_type == 'ARTIFACT' && l.entity_id == 'a1')" + " || (l.entity_type == 'ARTIFACT' && l.entity_id == 'a2')))" + ) + + @pytest.mark.asyncio + async def test_related_to_ignores_relation(self, artifacts_api): + await artifacts_api.list_(related_to=[_run("r1")]) + + query = artifacts_api._low_level_client.list_all_artifacts.call_args.kwargs["query_filter"] + assert query == "links.exists(l, ((l.entity_type == 'RUN' && l.entity_id == 'r1')))" + + @pytest.mark.asyncio + async def test_unsupported_link_target_rejected(self, artifacts_api): + with pytest.raises(TypeError, match="Cannot filter by a link"): + await artifacts_api.list_(related_to=["r1"]) # type: ignore[list-item] + + @pytest.mark.asyncio + async def test_find_raises_on_many(self, artifacts_api): + artifacts_api._low_level_client.list_all_artifacts.return_value = [ + _artifact(), + _artifact(), + ] + + with pytest.raises(ValueError, match="Multiple"): + await artifacts_api.find(title="t") + + +class TestListVersions: + @pytest.mark.asyncio + async def test_search_all_with_archived(self, artifacts_api): + await artifacts_api.versions.list_(include_archived=True, filter_query="version > 1") + + kwargs = artifacts_api._low_level_client.list_all_artifact_versions.call_args.kwargs + assert kwargs["artifact_id"] is None + assert kwargs["query_filter"] == "include_archived == true && version > 1" + + @pytest.mark.asyncio + async def test_takes_the_list_filters(self, artifacts_api): + await artifacts_api.versions.list_( + title_contains="thermal", sources=[_run("r1")], artifact_type=ArtifactType.BLOB + ) + + query = artifacts_api._low_level_client.list_all_artifact_versions.call_args.kwargs[ + "query_filter" + ] + assert query == ( + "title.contains('thermal') && links.exists(l, l.relation == 'SOURCE'" + " && ((l.entity_type == 'RUN' && l.entity_id == 'r1'))) && storage_class == 'BLOB'" + ) + + @pytest.mark.asyncio + async def test_named_artifact_skips_archive_directive(self, artifacts_api): + # The service always lists a named artifact's versions, archived or not. + await artifacts_api.versions.list_(artifact=_artifact(), include_archived=True) + + kwargs = artifacts_api._low_level_client.list_all_artifact_versions.call_args.kwargs + assert kwargs["artifact_id"] == "a1" + assert kwargs["query_filter"] is None + + +class TestCreate: + @pytest.mark.asyncio + async def test_uploads_path_to_new_version(self, artifacts_api, tmp_path): + path = tmp_path / "plot.png" + path.write_bytes(b"png") + links = [ + ArtifactLink( + relation=ArtifactLinkRelation.SOURCE, + entity_type=ArtifactEntityType.RUN, + entity_id="run1", + ) + ] + + artifact = await artifacts_api.create({"title": "Plot", "links": links}, file=path) + + create = artifacts_api._low_level_client.create_artifact.call_args.kwargs["create"] + assert create.title == "Plot" + assert create.links == links + assert create.created_via is ArtifactCreatedVia.UPLOAD + artifacts_api._upload_client.upload_attachment.assert_awaited_once_with( + source=path, entity_id="v1", entity_type="artifact_versions", file_name=None + ) + artifacts_api._low_level_client.get_artifact.assert_awaited_once_with( + artifact_id="a1", artifact_version_id="v1" + ) + assert artifact.artifact_version.remote_file_id == "rf1" + + @pytest.mark.asyncio + async def test_uploads_bytes_under_file_name(self, artifacts_api): + await artifacts_api.create({"title": "Notes"}, file=b"hello", file_name="notes.txt") + + kwargs = artifacts_api._upload_client.upload_attachment.call_args.kwargs + assert kwargs["source"].read() == b"hello" + assert kwargs["file_name"] == "notes.txt" + assert kwargs["entity_id"] == "v1" + + @pytest.mark.asyncio + async def test_uploads_file_object(self, artifacts_api): + data = io.BytesIO(b"a,b\n1,2\n") + + await artifacts_api.create({}, file=data, file_name="table.csv") + + kwargs = artifacts_api._upload_client.upload_attachment.call_args.kwargs + assert kwargs["source"] is data + assert kwargs["file_name"] == "table.csv" + + @pytest.mark.asyncio + async def test_file_object_needs_file_name(self, artifacts_api): + with pytest.raises(ValueError, match="file_name is required"): + await artifacts_api.create({}, file=io.BytesIO(b"x")) + + @pytest.mark.asyncio + async def test_bytes_need_file_name(self, artifacts_api): + with pytest.raises(ValueError, match="file_name is required"): + await artifacts_api.create({}, file=b"hello") + + @pytest.mark.asyncio + async def test_file_name_rejected_for_paths(self, artifacts_api, tmp_path): + with pytest.raises(ValueError, match="only when file is bytes"): + await artifacts_api.create({}, file=tmp_path / "a.txt", file_name="b.txt") + + @pytest.mark.asyncio + async def test_file_name_must_be_bare(self, artifacts_api): + with pytest.raises(ValueError, match="bare file name"): + await artifacts_api.create({}, file=b"x", file_name="../escape.txt") + artifacts_api._low_level_client.create_artifact.assert_not_awaited() + + @pytest.mark.asyncio + async def test_missing_path_rejected_before_create(self, artifacts_api, tmp_path): + with pytest.raises(ValueError, match="does not point to a regular file"): + await artifacts_api.create({}, file=tmp_path / "missing.png") + artifacts_api._low_level_client.create_artifact.assert_not_awaited() + + @pytest.mark.asyncio + async def test_keeps_explicit_created_via(self, artifacts_api): + await artifacts_api.create( + {"created_via": ArtifactCreatedVia.CANVAS}, file=b"x", file_name="x.txt" + ) + + create = artifacts_api._low_level_client.create_artifact.call_args.kwargs["create"] + assert create.created_via is ArtifactCreatedVia.CANVAS + + +class TestUpdate: + @pytest.mark.asyncio + async def test_fields_only(self, artifacts_api): + artifact = await artifacts_api.update("a1", {"title": "New"}) + + call = artifacts_api._low_level_client.update_artifact.call_args + assert call.args[0].resource_id == "a1" + assert call.kwargs["new_file"] is False + artifacts_api._upload_client.upload_attachment.assert_not_awaited() + assert artifact.artifact_version.id_ == "v2" + + @pytest.mark.asyncio + async def test_new_file(self, artifacts_api, tmp_path): + path = tmp_path / "plot.png" + path.write_bytes(b"png") + + await artifacts_api.update(_artifact(), {}, file=path) + + assert artifacts_api._low_level_client.update_artifact.call_args.kwargs["new_file"] is True + artifacts_api._upload_client.upload_attachment.assert_awaited_once_with( + source=path, entity_id="v2", entity_type="artifact_versions", file_name=None + ) + artifacts_api._low_level_client.get_artifact.assert_awaited_once_with( + artifact_id="a1", artifact_version_id="v2" + ) + + @pytest.mark.asyncio + async def test_empty_update_rejected(self, artifacts_api): + with pytest.raises(ValueError, match="at least one field"): + await artifacts_api.update("a1", {}) + + +class TestGetArchivePromote: + @pytest.mark.asyncio + async def test_pinned_get(self, artifacts_api): + await artifacts_api.get(artifact_id="a1", artifact_version_id="v1") + + artifacts_api._low_level_client.get_artifact.assert_awaited_once_with( + artifact_id="a1", artifact_version_id="v1" + ) + + @pytest.mark.asyncio + async def test_archive_refetches(self, artifacts_api): + artifact = await artifacts_api.archive(_artifact()) + + artifacts_api._low_level_client.archive_artifact.assert_awaited_once_with("a1") + artifacts_api._low_level_client.get_artifact.assert_awaited_once_with( + artifact_id="a1", artifact_version_id=None + ) + assert artifact.client is artifacts_api.client + + @pytest.mark.asyncio + async def test_unarchive_refetches(self, artifacts_api): + await artifacts_api.unarchive("a1") + + artifacts_api._low_level_client.unarchive_artifact.assert_awaited_once_with("a1") + artifacts_api._low_level_client.get_artifact.assert_awaited_once() + + @pytest.mark.asyncio + async def test_promote(self, artifacts_api): + version = _artifact("v1").artifact_version + + promoted = await artifacts_api.versions.promote(version=version) + + artifacts_api._low_level_client.promote_artifact_version.assert_awaited_once_with( + artifact_id="a1", artifact_version_id="v1" + ) + assert promoted.artifact_version.id_ == "v3" + assert promoted.artifact_version.client is artifacts_api.client + + +class TestReadInMemory: + @pytest.mark.asyncio + async def test_read_bytes(self, artifacts_api): + with patch( + "sift_client.resources.artifacts.read_url_bytes", return_value=b"data" + ) as read_url_bytes: + data = await artifacts_api.read_bytes(artifact=_artifact("v1", "rf1").artifact_version) + + assert data == b"data" + assert read_url_bytes.call_args.args == ("https://x",) + + +class TestDownload: + @pytest.mark.asyncio + async def test_artifact_id_resolves_latest_version(self, artifacts_api): + url = await artifacts_api.get_download_url(artifact="a1") + + assert url == "https://x" + artifacts_api._remote_files_client.get_remote_file_download_url.assert_awaited_once_with( + remote_file_id="rf1" + ) + + @pytest.mark.asyncio + async def test_version_without_file(self, artifacts_api): + version = _artifact("v1").artifact_version + + with pytest.raises(ValueError, match="has no file"): + await artifacts_api.get_download_url(artifact=version) + + @pytest.mark.asyncio + async def test_download_writes_to_output_path(self, artifacts_api, tmp_path): + with patch("sift_client.resources.artifacts.download_file") as download_file: + await artifacts_api.download( + artifact=_artifact("v1", "rf1"), output_path=tmp_path / "out" + ) + + download_file.assert_called_once() + assert download_file.call_args.args == ("https://x", tmp_path / "out") + + +@pytest.mark.integration +def test_client_binding(sift_client): + assert isinstance(sift_client.artifacts, ArtifactsAPI) + assert isinstance(sift_client.artifacts.versions, ArtifactVersionsAPI) + assert isinstance(sift_client.async_.artifacts, ArtifactsAPIAsync) + + +@pytest.mark.integration +def test_publish_find_and_version(sift_client): + stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f") + title = f"sift-client-test-artifact-{stamp}" + artifact = sift_client.artifacts.create( + {"title": title, "created_via": ArtifactCreatedVia.UPLOAD}, + file=b"first", + file_name="first.txt", + ) + try: + assert artifact.artifact_version.version == 1 + assert artifact.artifact_version.file_name == "first.txt" + assert [a.id_ for a in sift_client.artifacts.list_(title=title)] == [artifact.id_] + + artifact.update({"summary": "second"}, file=b"second", file_name="second.txt") + assert artifact.artifact_version.version == 2 + + versions = sift_client.artifacts.versions.list_(artifact=artifact) + assert [v.version for v in versions] == [2, 1] + assert [v.is_current for v in versions] == [True, False] + assert isinstance(versions[0], ArtifactVersion) + finally: + sift_client.artifacts.archive(artifact) diff --git a/python/lib/sift_client/_tests/sift_types/test_artifact.py b/python/lib/sift_client/_tests/sift_types/test_artifact.py new file mode 100644 index 000000000..1728fbce6 --- /dev/null +++ b/python/lib/sift_client/_tests/sift_types/test_artifact.py @@ -0,0 +1,276 @@ +"""Tests for sift_types.Artifact models.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from unittest.mock import MagicMock + +import pytest +from sift.artifacts.v1 import artifacts_pb2 + +from sift_client._internal.time import to_timestamp_pb +from sift_client.sift_types import ( + Artifact, + ArtifactCreate, + ArtifactCreatedVia, + ArtifactEntityType, + ArtifactLink, + ArtifactLinkRelation, + ArtifactType, + ArtifactUpdate, + ArtifactVersion, +) +from sift_client.util.metadata import metadata_dict_to_proto + +NOW = datetime(2026, 1, 1, tzinfo=timezone.utc) + + +def _version_proto(**overrides) -> artifacts_pb2.ArtifactVersion: + fields: dict[str, Any] = dict( + artifact_version_id="v2", + artifact_id="a1", + version=2, + title="Thermal summary", + remote_file_id="rf1", + file_name="summary.png", + file_mime_type="image/png", + created_date=to_timestamp_pb(NOW), + metadata=metadata_dict_to_proto({"campaign": "x"}), + is_current=True, + ) + fields.update(overrides) + return artifacts_pb2.ArtifactVersion(**fields) + + +def _details_proto(**artifact_overrides) -> artifacts_pb2.ArtifactDetails: + fields: dict[str, Any] = dict( + artifact_id="a1", + organization_id="org1", + created_by_user_id="u1", + created_date=to_timestamp_pb(NOW), + storage_class=artifacts_pb2.ARTIFACT_STORAGE_CLASS_FILE, + created_via=artifacts_pb2.ARTIFACT_CREATED_VIA_UPLOAD, + current_version_id="v2", + ) + fields.update(artifact_overrides) + return artifacts_pb2.ArtifactDetails( + artifact=artifacts_pb2.Artifact(**fields), artifact_version=_version_proto() + ) + + +def _bound(model, client): + model._apply_client_to_instance(client) + return model + + +SOURCE_RUN = ArtifactLink( + relation=ArtifactLinkRelation.SOURCE, entity_type=ArtifactEntityType.RUN, entity_id="run1" +) + + +class TestArtifactVersion: + """Unit tests for ArtifactVersion._from_proto.""" + + def test_from_proto(self): + version = ArtifactVersion._from_proto(_version_proto()) + + assert version.id_ == "v2" + assert version.artifact_id == "a1" + assert version.version == 2 + assert version.title == "Thermal summary" + assert version.summary is None + assert version.remote_file_id == "rf1" + assert version.file_name == "summary.png" + assert version.metadata == {"campaign": "x"} + assert version.created_date == NOW + assert version.is_current is True + + def test_unset_optionals_are_none(self): + version = ArtifactVersion._from_proto( + artifacts_pb2.ArtifactVersion( + artifact_version_id="v1", artifact_id="a1", created_date=to_timestamp_pb(NOW) + ) + ) + + assert version.title is None + assert version.remote_file_id is None + assert version.file_name is None + assert version.file_mime_type is None + assert version.is_current is False + + +class TestArtifact: + """Unit tests for Artifact._from_proto and its helpers.""" + + def test_from_proto(self): + artifact = Artifact._from_proto(_details_proto()) + + assert artifact.id_ == "a1" + assert artifact.organization_id == "org1" + assert artifact.artifact_type is ArtifactType.FILE + assert artifact.created_via is ArtifactCreatedVia.UPLOAD + assert artifact.current_version_id == "v2" + assert artifact.artifact_version.id_ == "v2" + assert artifact.archived_date is None + assert artifact.is_archived is False + + def test_unmodeled_enum_values_read_as_none(self): + artifact = Artifact._from_proto( + _details_proto( + storage_class=artifacts_pb2.ARTIFACT_STORAGE_CLASS_STRUCTURED, + created_via=artifacts_pb2.ARTIFACT_CREATED_VIA_UNSPECIFIED, + ) + ) + + assert artifact.artifact_type is None + assert artifact.created_via is None + + def test_archived(self): + artifact = Artifact._from_proto(_details_proto(archived_date=to_timestamp_pb(NOW))) + + assert artifact.archived_date == NOW + assert artifact.is_archived is True + + def test_client_reaches_the_version(self, mock_client): + artifact = Artifact._from_proto(_details_proto()) + + artifact._apply_client_to_instance(mock_client) + + assert artifact.artifact_version.client is mock_client + + def test_versions_property(self, mock_client): + artifact = _bound(Artifact._from_proto(_details_proto()), mock_client) + mock_client.artifacts.versions.list_.return_value = [] + + assert artifact.versions == [] + mock_client.artifacts.versions.list_.assert_called_once_with(artifact=artifact) + + def test_created_by(self, mock_client): + artifact = _bound(Artifact._from_proto(_details_proto()), mock_client) + + mock_client.users = MagicMock() + + assert artifact.created_by is mock_client.users.get.return_value + mock_client.users.get.assert_called_once_with(user_id="u1") + + def test_update_refreshes_in_place(self, mock_client): + artifact = _bound(Artifact._from_proto(_details_proto()), mock_client) + newer = _bound(Artifact._from_proto(_details_proto(current_version_id="v3")), mock_client) + mock_client.artifacts.update.return_value = newer + + result = artifact.update({"title": "new"}, file=b"data", file_name="a.txt") + + mock_client.artifacts.update.assert_called_once_with( + artifact, {"title": "new"}, file=b"data", file_name="a.txt" + ) + assert result is artifact + assert artifact.current_version_id == "v3" + + def test_archive_refreshes_in_place(self, mock_client): + artifact = _bound(Artifact._from_proto(_details_proto()), mock_client) + mock_client.artifacts.archive.return_value = Artifact._from_proto( + _details_proto(archived_date=to_timestamp_pb(NOW)) + ) + + artifact.archive() + + assert artifact.is_archived is True + + def test_unarchive_refreshes_in_place(self, mock_client): + artifact = _bound( + Artifact._from_proto(_details_proto(archived_date=to_timestamp_pb(NOW))), mock_client + ) + mock_client.artifacts.unarchive.return_value = Artifact._from_proto(_details_proto()) + + artifact.unarchive() + + mock_client.artifacts.unarchive.assert_called_once_with(artifact) + assert artifact.is_archived is False + + def test_version_artifact_is_pinned(self, mock_client): + version = _bound(ArtifactVersion._from_proto(_version_proto()), mock_client) + + assert version.artifact is mock_client.artifacts.get.return_value + mock_client.artifacts.get.assert_called_once_with( + artifact_id="a1", artifact_version_id="v2" + ) + + def test_version_promote_and_download(self, mock_client, tmp_path): + version = _bound(ArtifactVersion._from_proto(_version_proto()), mock_client) + + version.promote() + version.download(tmp_path / "out.png") + + mock_client.artifacts.versions.promote.assert_called_once_with(version=version) + assert version.read_bytes() is mock_client.artifacts.read_bytes.return_value + mock_client.artifacts.read_bytes.assert_called_once_with(artifact=version) + mock_client.artifacts.download.assert_called_once_with( + artifact=version, output_path=tmp_path / "out.png" + ) + + +class TestArtifactCreate: + """Unit tests for ArtifactCreate.to_proto.""" + + def test_to_proto(self): + proto = ArtifactCreate( + title="Thermal summary", + summary="Peak temperatures", + metadata={"campaign": "x"}, + links=[SOURCE_RUN], + created_via=ArtifactCreatedVia.UPLOAD, + artifact_type=ArtifactType.BLOB, + ).to_proto() + + assert proto.title == "Thermal summary" + assert proto.summary == "Peak temperatures" + assert proto.created_via == artifacts_pb2.ARTIFACT_CREATED_VIA_UPLOAD + assert proto.storage_class == artifacts_pb2.ARTIFACT_STORAGE_CLASS_BLOB + assert proto.metadata[0].key.name == "campaign" + assert len(proto.links) == 1 + assert proto.links[0].relation == artifacts_pb2.ARTIFACT_LINK_RELATION_SOURCE + assert proto.links[0].entity_type == artifacts_pb2.ARTIFACT_ENTITY_TYPE_RUN + assert proto.links[0].entity_id == "run1" + + def test_unset_optionals_stay_unset(self): + proto = ArtifactCreate().to_proto() + + assert not proto.HasField("title") + assert proto.created_via == artifacts_pb2.ARTIFACT_CREATED_VIA_UPLOAD + assert not proto.HasField("storage_class") + assert len(proto.links) == 0 + + +class TestArtifactUpdate: + """Unit tests for ArtifactUpdate.to_proto_with_mask.""" + + def test_mask_names_artifact_version_paths(self): + update = ArtifactUpdate(title="New", summary="Updated", metadata={"campaign": "y"}) + update.resource_id = "a1" + + proto, mask = update.to_proto_with_mask() + + assert proto.artifact_id == "a1" + assert proto.artifact.artifact_version.title == "New" + assert proto.artifact.artifact_version.summary == "Updated" + assert proto.artifact.artifact_version.metadata[0].string_value == "y" + assert list(mask.paths) == [ + "artifact_version.title", + "artifact_version.summary", + "artifact_version.metadata", + ] + + def test_links_are_request_level(self): + update = ArtifactUpdate(links=[SOURCE_RUN]) + update.resource_id = "a1" + + proto, mask = update.to_proto_with_mask() + + assert list(mask.paths) == ["links"] + assert proto.links[0].entity_id == "run1" + assert not proto.HasField("artifact") + + def test_requires_resource_id(self): + with pytest.raises(ValueError, match="Resource ID"): + ArtifactUpdate(title="New").to_proto_with_mask() From a424ec93607ec50f76856cbd0f900a3ab5ce5784 Mon Sep 17 00:00:00 2001 From: Alex Luck Date: Mon, 19 Oct 2026 13:59:01 -0700 Subject: [PATCH 4/4] python(docs): add artifacts changelog fragment --- python/changelog.d/845.feat.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 python/changelog.d/845.feat.md diff --git a/python/changelog.d/845.feat.md b/python/changelog.d/845.feat.md new file mode 100644 index 000000000..8b50df86c --- /dev/null +++ b/python/changelog.d/845.feat.md @@ -0,0 +1 @@ +Added `client.artifacts` for finding artifacts by the runs and assets they link to, browsing version history through `client.artifacts.versions`, and downloading any version's file.