diff --git a/python/lib/sift_client/_internal/low_level_wrappers/remote_files.py b/python/lib/sift_client/_internal/low_level_wrappers/remote_files.py index fda3a693a..5d062aa97 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/remote_files.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/remote_files.py @@ -22,7 +22,7 @@ if TYPE_CHECKING: from sift_client.client import SiftClient - from sift_client.sift_types.remote_file import RemoteFile, RemoteFileUpdate + from sift_client.sift_types.file_attachment import FileAttachment, RemoteFileUpdate class RemoteFilesLowLevelClient(LowLevelClientBase, WithGrpcClient): @@ -41,7 +41,7 @@ def __init__(self, grpc_client: GrpcClient): async def get_remote_file( self, remote_file_id: str, sift_client: SiftClient | None = None - ) -> RemoteFile: + ) -> FileAttachment: """Get a remote file by ID. Args: @@ -51,12 +51,12 @@ async def get_remote_file( Returns: The RemoteFile. """ - from sift_client.sift_types.remote_file import RemoteFile + from sift_client.sift_types.file_attachment import FileAttachment request = GetRemoteFileRequest(remote_file_id=remote_file_id) response = await self._grpc_client.get_stub(RemoteFileServiceStub).GetRemoteFile(request) grpc_remote_file = cast("GetRemoteFileResponse", response).remote_file - return RemoteFile._from_proto(grpc_remote_file, sift_client) + return FileAttachment._from_proto(grpc_remote_file, sift_client) async def list_all_remote_files( self, @@ -65,7 +65,7 @@ async def list_all_remote_files( max_results: int | None = None, page_size: int | None = None, sift_client: SiftClient | None = None, - ) -> list[RemoteFile]: + ) -> list[FileAttachment]: """List all remote files matching the given query. Args: @@ -93,7 +93,7 @@ async def list_remote_files( query_filter: str | None = None, order_by: str | None = None, sift_client: SiftClient | None = None, - ) -> tuple[list[RemoteFile], str]: + ) -> tuple[list[FileAttachment], str]: """List remote files with pagination support. Args: @@ -106,7 +106,7 @@ async def list_remote_files( Returns: A tuple of (list of RemoteFiles, next_page_token). """ - from sift_client.sift_types.remote_file import RemoteFile + from sift_client.sift_types.file_attachment import FileAttachment request_kwargs: dict[str, Any] = {} if page_size is not None: @@ -122,12 +122,12 @@ async def list_remote_files( response = await self._grpc_client.get_stub(RemoteFileServiceStub).ListRemoteFiles(request) response = cast("ListRemoteFilesResponse", response) return [ - RemoteFile._from_proto(rf, sift_client) for rf in response.remote_files + FileAttachment._from_proto(rf, sift_client) for rf in response.remote_files ], response.next_page_token async def update_remote_file( self, update: RemoteFileUpdate, sift_client: SiftClient | None = None - ) -> RemoteFile: + ) -> FileAttachment: """Update a remote file. Args: @@ -137,13 +137,13 @@ async def update_remote_file( Returns: The updated RemoteFile. """ - from sift_client.sift_types.remote_file import RemoteFile + from sift_client.sift_types.file_attachment import FileAttachment grpc_remote_file, update_mask = update.to_proto_with_mask() request = UpdateRemoteFileRequest(remote_file=grpc_remote_file, update_mask=update_mask) response = await self._grpc_client.get_stub(RemoteFileServiceStub).UpdateRemoteFile(request) updated_grpc_remote_file = cast("UpdateRemoteFileResponse", response).remote_file - return RemoteFile._from_proto(updated_grpc_remote_file, sift_client) + return FileAttachment._from_proto(updated_grpc_remote_file, sift_client) async def delete_remote_file(self, remote_file_id: str) -> None: """Delete a remote file. diff --git a/python/lib/sift_client/client.py b/python/lib/sift_client/client.py index 2a2252ef8..c269e499d 100644 --- a/python/lib/sift_client/client.py +++ b/python/lib/sift_client/client.py @@ -22,6 +22,7 @@ TestResultsAPI, TestResultsAPIAsync, ) +from sift_client.resources.sync_stubs import FileAttachmentsAPI from sift_client.transport import ( GrpcClient, GrpcConfig, @@ -146,6 +147,8 @@ def __init__( self.runs = RunsAPI(self) self.tags = TagsAPI(self) self.test_results = TestResultsAPI(self) + self.file_attachments = FileAttachmentsAPI(self) + # Accessor for the asynchronous APIs self.async_ = AsyncAPIs( ping=PingAPIAsync(self), @@ -158,6 +161,7 @@ def __init__( runs=RunsAPIAsync(self), tags=TagsAPIAsync(self), test_results=TestResultsAPIAsync(self), + file_attachments=FileAttachmentsAPIAsync(self), ) @property diff --git a/python/lib/sift_client/resources/__init__.py b/python/lib/sift_client/resources/__init__.py index 968fabdb3..0c8d856c6 100644 --- a/python/lib/sift_client/resources/__init__.py +++ b/python/lib/sift_client/resources/__init__.py @@ -160,6 +160,7 @@ async def main(): from sift_client.resources.runs import RunsAPIAsync from sift_client.resources.tags import TagsAPIAsync from sift_client.resources.test_results import TestResultsAPIAsync +from sift_client.resources.file_attachments import FileAttachmentsAPIAsync # ruff: noqa All imports needs to be imported before sync_stubs to avoid circular import from sift_client.resources.sync_stubs import ( @@ -172,6 +173,7 @@ async def main(): RunsAPI, TagsAPI, TestResultsAPI, + # FileAttachmentsAPI ) __all__ = [ @@ -194,4 +196,6 @@ async def main(): "TagsAPIAsync", "TestResultsAPI", "TestResultsAPIAsync", + "FileAttachmentsAPIAsync", + # "FileAttachmentsAPI" ] diff --git a/python/lib/sift_client/resources/file_attachments.py b/python/lib/sift_client/resources/file_attachments.py new file mode 100644 index 000000000..331e0030e --- /dev/null +++ b/python/lib/sift_client/resources/file_attachments.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from sift_client._internal.low_level_wrappers.remote_files import RemoteFilesLowLevelClient +from sift_client.resources._base import ResourceBase + +if TYPE_CHECKING: + from sift_client.client import SiftClient + from sift_client.sift_types.file_attachment import FileAttachment + + +class FileAttachmentsAPIAsync(ResourceBase): + """High-level API for interacting with file attachments (remote files). + + This class provides a Pythonic, notebook-friendly interface for interacting with the AssetsAPI. + It handles automatic handling of gRPC services, seamless type conversion, and clear error handling. + + All methods in this class use the Asset class from the low-level wrapper, which is a user-friendly + representation of an asset using standard Python data structures and types. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the AssetsAPI. + + Args: + sift_client: The Sift client to use. + """ + super().__init__(sift_client) + self._low_level_client = RemoteFilesLowLevelClient(grpc_client=self.client.grpc_client) + + def get( + self, *, file_id: str | None = None, client_key: str | None = None + ) -> FileAttachment: ... + + def list_(self) -> list[FileAttachment]: ... + + def find(self) -> FileAttachment: ... + + def update(self) -> FileAttachment | list[FileAttachment]: ... + + def delete(self) -> None: ... + + def download(self, output_path: str | Path) -> None: ... diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.py b/python/lib/sift_client/resources/sync_stubs/__init__.py index 412238b60..cf3879278 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.py +++ b/python/lib/sift_client/resources/sync_stubs/__init__.py @@ -7,6 +7,7 @@ AssetsAPIAsync, CalculatedChannelsAPIAsync, ChannelsAPIAsync, + FileAttachmentsAPIAsync, PingAPIAsync, ReportsAPIAsync, RulesAPIAsync, @@ -24,11 +25,13 @@ ReportsAPI = generate_sync_api(ReportsAPIAsync, "ReportsAPI") TagsAPI = generate_sync_api(TagsAPIAsync, "TagsAPI") TestResultsAPI = generate_sync_api(TestResultsAPIAsync, "TestResultsAPI") +FileAttachmentsAPI = generate_sync_api(FileAttachmentsAPIAsync, "FileAttachmentsAPI") __all__ = [ "AssetsAPI", "CalculatedChannelsAPI", "ChannelsAPI", + "FileAttachmentsAPI", "PingAPI", "ReportsAPI", "RulesAPI", diff --git a/python/lib/sift_client/sift_types/_mixins/__init__.py b/python/lib/sift_client/sift_types/_mixins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/lib/sift_client/sift_types/_mixins/file_attachments.py b/python/lib/sift_client/sift_types/_mixins/file_attachments.py new file mode 100644 index 000000000..50a99076a --- /dev/null +++ b/python/lib/sift_client/sift_types/_mixins/file_attachments.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +if TYPE_CHECKING: + from sift_client.sift_types._base import BaseType + from sift_client.sift_types.file_attachment import FileAttachment + + +class FileAttachmentsMixin: + """Mixin for sift_types that support file attachments (remote files). + + This mixin assumes the class also inherits from BaseType, which provides: + - id_: str | None + - client: SiftClient property + + The entity type is automatically determined from the class name: + - Asset -> ENTITY_TYPE_ASSET + - Run -> ENTITY_TYPE_RUN + - TestReport -> ENTITY_TYPE_TEST_REPORT + """ + + # Mapping of class names to entity types + _ENTITY_TYPE_MAP: ClassVar[dict[str, str]] = { + "Asset": "ENTITY_TYPE_ASSET", + "Run": "ENTITY_TYPE_RUN", + "TestReport": "ENTITY_TYPE_TEST_REPORT", + } + + def _get_entity_type_name(self) -> str: + """Get the entity type for filtering based on the class name. + + Returns: + The entity type string (e.g., 'ENTITY_TYPE_ASSET', 'ENTITY_TYPE_RUN') + + Raises: + ValueError: If the class name is not in the entity type mapping. + """ + class_name = self.__class__.__name__ + entity_type = self._ENTITY_TYPE_MAP.get(class_name) + + if not entity_type: + raise ValueError( + f"{class_name} is not configured for attachments. " + f"Add it to FileAttachmentsMixin._ENTITY_TYPE_MAP" + ) + + return entity_type + + @property + def attachments(self: BaseType) -> list[FileAttachment]: + # Type ignore because mixin assumes BaseType attributes (client, id_) + return self.client.file_attachments.list( + entity_type=self._get_entity_type_name(), + entity_id=self.id_, + ) + + def delete_attachment( + self: BaseType, file_attachment: FileAttachment | list[FileAttachment] + ) -> FileAttachment: + return self.client.file_attachments.add( + entity_type=self._get_entity_type_name(), + entity=self.id_, + file_attachment=file_attachment, + ) diff --git a/python/lib/sift_client/sift_types/asset.py b/python/lib/sift_client/sift_types/asset.py index d42b0bb18..046dda452 100644 --- a/python/lib/sift_client/sift_types/asset.py +++ b/python/lib/sift_client/sift_types/asset.py @@ -6,17 +6,17 @@ from sift.assets.v1.assets_pb2 import Asset as AssetProto from sift_client.sift_types._base import BaseType, MappingHelper, ModelUpdate +from sift_client.sift_types._mixins.file_attachments import AttachmentsMixin from sift_client.sift_types.tag import Tag from sift_client.util.metadata import metadata_dict_to_proto, metadata_proto_to_dict if TYPE_CHECKING: from sift_client.client import SiftClient from sift_client.sift_types.channel import Channel - from sift_client.sift_types.remote_file import RemoteFile from sift_client.sift_types.run import Run -class Asset(BaseType[AssetProto, "Asset"]): +class Asset(BaseType[AssetProto, "Asset"], AttachmentsMixin): """Model of the Sift Asset.""" # Required fields @@ -89,38 +89,6 @@ def update(self, update: AssetUpdate | dict) -> Asset: self._update(updated_asset) return self - async def remote_files(self) -> list[RemoteFile]: - """Get the remote files associated with this asset. - - Returns: - A list of RemoteFile objects attached to this asset. - """ - from sift_client._internal.low_level_wrappers import RemoteFilesLowLevelClient - from sift_client.util import cel_utils as cel - - low_level_client = RemoteFilesLowLevelClient(self.client.grpc_client) - - # Build CEL filter for entity_id and entity_type - filter_expr = cel.and_( - cel.equals("entity_id", self.id_), cel.equals("entity_type", "ENTITY_TYPE_ASSET") - ) - - return await low_level_client.list_all_remote_files(query_filter=filter_expr) - - async def remote_file(self, file_id: str) -> RemoteFile: - """Get a specific remote file by ID. - - Args: - file_id: The ID of the remote file to retrieve. - - Returns: - The RemoteFile object. - """ - from sift_client._internal.low_level_wrappers import RemoteFilesLowLevelClient - - low_level_client = RemoteFilesLowLevelClient(self.client.grpc_client) - return await low_level_client.get_remote_file(file_id) - @classmethod def _from_proto(cls, proto: AssetProto, sift_client: SiftClient | None = None) -> Asset: return cls( diff --git a/python/lib/sift_client/sift_types/remote_file.py b/python/lib/sift_client/sift_types/file_attachment.py similarity index 97% rename from python/lib/sift_client/sift_types/remote_file.py rename to python/lib/sift_client/sift_types/file_attachment.py index ed5437a6c..7f4cc1667 100644 --- a/python/lib/sift_client/sift_types/remote_file.py +++ b/python/lib/sift_client/sift_types/file_attachment.py @@ -56,7 +56,7 @@ def from_proto_value(proto_value: int) -> RemoteFileEntityType: return RemoteFileEntityType(proto_value) -class RemoteFile(BaseType[RemoteFileProto, "RemoteFile"]): +class FileAttachment(BaseType[RemoteFileProto, "RemoteFile"]): """Model of the Sift RemoteFile.""" organization_id: str @@ -76,7 +76,7 @@ class RemoteFile(BaseType[RemoteFileProto, "RemoteFile"]): @classmethod def _from_proto( cls, proto: RemoteFileProto, sift_client: SiftClient | None = None - ) -> RemoteFile: + ) -> FileAttachment: return cls( id_=proto.remote_file_id, organization_id=proto.organization_id, @@ -126,7 +126,7 @@ def delete(self) -> None: remote_files_client.delete_remote_file(remote_file_id=self.id_), loop ).result() - def update(self, update: RemoteFileUpdate | dict) -> RemoteFile: + def update(self, update: RemoteFileUpdate | dict) -> FileAttachment: """Update the remote file.""" from sift_client._internal.low_level_wrappers import RemoteFilesLowLevelClient diff --git a/python/lib/sift_client/sift_types/run.py b/python/lib/sift_client/sift_types/run.py index 9de400f77..0c687789b 100644 --- a/python/lib/sift_client/sift_types/run.py +++ b/python/lib/sift_client/sift_types/run.py @@ -14,16 +14,16 @@ ModelCreateUpdateBase, ModelUpdate, ) +from sift_client.sift_types._mixins.file_attachments import FileAttachmentsMixin from sift_client.sift_types.tag import Tag from sift_client.util.metadata import metadata_dict_to_proto, metadata_proto_to_dict if TYPE_CHECKING: from sift_client.client import SiftClient from sift_client.sift_types.asset import Asset - from sift_client.sift_types.remote_file import RemoteFile -class Run(BaseType[RunProto, "Run"]): +class Run(BaseType[RunProto, "Run"], FileAttachmentsMixin): """Run model representing a data collection run.""" # Required fields @@ -118,38 +118,6 @@ def update(self, update: RunUpdate | dict) -> Run: self._update(updated_run) return self - async def remote_files(self) -> list[RemoteFile]: - """Get the remote files associated with this run. - - Returns: - A list of RemoteFile objects attached to this run. - """ - from sift_client._internal.low_level_wrappers import RemoteFilesLowLevelClient - from sift_client.util import cel_utils as cel - - low_level_client = RemoteFilesLowLevelClient(self.client.grpc_client) - - # Build CEL filter for entity_id and entity_type - filter_expr = cel.and_( - cel.equals("entity_id", self.id_), cel.equals("entity_type", "ENTITY_TYPE_RUN") - ) - - return await low_level_client.list_all_remote_files(query_filter=filter_expr) - - async def remote_file(self, file_id: str) -> RemoteFile: - """Get a specific remote file by ID. - - Args: - file_id: The ID of the remote file to retrieve. - - Returns: - The RemoteFile object. - """ - from sift_client._internal.low_level_wrappers import RemoteFilesLowLevelClient - - low_level_client = RemoteFilesLowLevelClient(self.client.grpc_client) - return await low_level_client.get_remote_file(file_id) - def stop(self) -> Run: """Stop the run.""" self.client.runs.stop(run=self) diff --git a/python/lib/sift_client/sift_types/test_report.py b/python/lib/sift_client/sift_types/test_report.py index 84a26e8ef..17f384101 100644 --- a/python/lib/sift_client/sift_types/test_report.py +++ b/python/lib/sift_client/sift_types/test_report.py @@ -33,11 +33,11 @@ ModelCreateUpdateBase, ModelUpdate, ) +from sift_client.sift_types._mixins.file_attachments import AttachmentsMixin from sift_client.util.metadata import metadata_dict_to_proto, metadata_proto_to_dict if TYPE_CHECKING: from sift_client.client import SiftClient - from sift_client.sift_types.remote_file import RemoteFile class TestStatus(Enum): @@ -514,7 +514,7 @@ def _to_proto(self) -> ErrorInfoProto: ) -class TestReport(BaseType[TestReportProto, "TestReport"]): +class TestReport(BaseType[TestReportProto, "TestReport"], AttachmentsMixin): """TestReport model representing a test report.""" status: TestStatus @@ -606,38 +606,6 @@ def unarchive(self) -> TestReport: self._update(updated_test_report) return self - async def remote_files(self) -> list[RemoteFile]: - """Get the remote files associated with this test report. - - Returns: - A list of RemoteFile objects attached to this test report. - """ - from sift_client._internal.low_level_wrappers import RemoteFilesLowLevelClient - from sift_client.util import cel_utils as cel - - low_level_client = RemoteFilesLowLevelClient(self.client.grpc_client) - - # Build CEL filter for entity_id and entity_type - filter_expr = cel.and_( - cel.equals("entity_id", self.id_), cel.equals("entity_type", "ENTITY_TYPE_TEST_REPORT") - ) - - return await low_level_client.list_all_remote_files(query_filter=filter_expr) - - async def remote_file(self, file_id: str) -> RemoteFile: - """Get a specific remote file by ID. - - Args: - file_id: The ID of the remote file to retrieve. - - Returns: - The RemoteFile object. - """ - from sift_client._internal.low_level_wrappers import RemoteFilesLowLevelClient - - low_level_client = RemoteFilesLowLevelClient(self.client.grpc_client) - return await low_level_client.get_remote_file(file_id) - @property def steps(self) -> list[TestStep]: # type: ignore """Get the TestSteps for the TestReport.""" diff --git a/python/lib/sift_client/util/util.py b/python/lib/sift_client/util/util.py index 143c04e52..f808c8f2d 100644 --- a/python/lib/sift_client/util/util.py +++ b/python/lib/sift_client/util/util.py @@ -7,6 +7,7 @@ AssetsAPIAsync, CalculatedChannelsAPIAsync, ChannelsAPIAsync, + FileAttachmentsAPIAsync, IngestionAPIAsync, PingAPIAsync, ReportsAPIAsync, @@ -50,6 +51,9 @@ class AsyncAPIs(NamedTuple): test_results: TestResultsAPIAsync """Instance of the Test Results API for making asynchronous requests.""" + file_attachments: FileAttachmentsAPIAsync + """Instance of the File Attachments API for making asynchronous requests.""" + def count_non_none(*args: Any) -> int: """Count the number of non-none arguments."""