From 09b826d73395636e9d32a3ec61f5d49e2136ccdf Mon Sep 17 00:00:00 2001 From: Alex Luck Date: Fri, 7 Nov 2025 14:42:16 -0800 Subject: [PATCH 1/3] pseudocode --- .../low_level_wrappers/remote_files.py | 22 ++--- python/lib/sift_client/client.py | 4 + python/lib/sift_client/resources/__init__.py | 4 + .../sift_client/resources/file_attachments.py | 45 ++++++++++ .../resources/sync_stubs/__init__.py | 3 + .../sift_types/_mixins/__init__.py | 0 .../sift_types/_mixins/file_attachments.py | 74 +++++++++++++++ .../sift_client/sift_types/_mixins/usage.md | 90 +++++++++++++++++++ python/lib/sift_client/sift_types/asset.py | 36 +------- .../{remote_file.py => file_attachment.py} | 6 +- python/lib/sift_client/sift_types/run.py | 36 +------- .../lib/sift_client/sift_types/test_report.py | 36 +------- python/lib/sift_client/util/util.py | 4 + 13 files changed, 244 insertions(+), 116 deletions(-) create mode 100644 python/lib/sift_client/resources/file_attachments.py create mode 100644 python/lib/sift_client/sift_types/_mixins/__init__.py create mode 100644 python/lib/sift_client/sift_types/_mixins/file_attachments.py create mode 100644 python/lib/sift_client/sift_types/_mixins/usage.md rename python/lib/sift_client/sift_types/{remote_file.py => file_attachment.py} (97%) 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..11a452262 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, "AttachmentsAPI") __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..eeb5282b9 --- /dev/null +++ b/python/lib/sift_client/sift_types/_mixins/file_attachments.py @@ -0,0 +1,74 @@ +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 AttachmentsMixin: + """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 AttachmentsMixin._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 add_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, + ) + + 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/_mixins/usage.md b/python/lib/sift_client/sift_types/_mixins/usage.md new file mode 100644 index 000000000..940ea1b3c --- /dev/null +++ b/python/lib/sift_client/sift_types/_mixins/usage.md @@ -0,0 +1,90 @@ +# AttachmentsMixin Usage + +The `AttachmentsMixin` provides a standardized way to add file attachment functionality to Sift types. + +## Features + +The mixin adds two methods to any class that uses it: + +1. **`async get_attachments()`** - Async method to fetch attachments +2. **`attachments`** - Sync property that fetches attachments using the client's event loop + +## Usage + +### For Sift Type Models + +The mixin automatically determines the entity type based on the class name. Simply inherit from `AttachmentsMixin`: + +```python +from sift_client.sift_types._mixins.file_attachments import AttachmentsMixin + + +class Asset(BaseType[AssetProto, "Asset"], AttachmentsMixin): + """Asset model - automatically mapped to ENTITY_TYPE_ASSET.""" + + # ... rest of the model fields +``` + +### Adding New Models + +To add attachment support to a new model: + +1. Inherit from `AttachmentsMixin` +2. Add the class name mapping to `AttachmentsMixin._ENTITY_TYPE_MAP` in `_mixins.py` + +```python +# In _mixins.py +_ENTITY_TYPE_MAP: ClassVar[dict[str, str]] = { + "Asset": "ENTITY_TYPE_ASSET", + "Run": "ENTITY_TYPE_RUN", + "TestReport": "ENTITY_TYPE_TEST_REPORT", + "MyNewModel": "ENTITY_TYPE_MY_NEW_MODEL", # Add your model here +} +``` + +### Examples + +#### Async Usage + +```python +# In an async context +asset = await client.assets.get(asset_id="...") +attachments = await asset.get_attachments() +for file in attachments: + print(f"File: {file.file_name}") +``` + +#### Sync Usage (Property) + +```python +# In a sync context +asset = client.assets.get(asset_id="...") +attachments = asset.attachments # Uses the property +for file in attachments: + print(f"File: {file.file_name}") +``` + +## Current Implementations + +The following Sift types currently use `AttachmentsMixin`: + +- **Asset** - `ENTITY_TYPE_ASSET` +- **Run** - `ENTITY_TYPE_RUN` +- **TestReport** - `ENTITY_TYPE_TEST_REPORT` + +## How It Works + +1. The mixin maintains a `_ENTITY_TYPE_MAP` dictionary mapping class names to entity types +2. When `get_attachments()` is called, it looks up the class name in the map +3. The async method uses the low-level `RemoteFilesLowLevelClient` to query for files +4. It builds a CEL filter based on the entity ID and the mapped entity type +5. The sync property `attachments` uses `asyncio.run_coroutine_threadsafe()` to run the async method on the client's dedicated event loop +6. This ensures all async operations happen in the same event loop context (see architecture notes) + +## Architecture Notes + +The mixin follows the established pattern where: +- Low-level clients are purely async +- High-level APIs provide both sync and async versions +- Sync methods use `asyncio.run_coroutine_threadsafe()` with the client's dedicated event loop +- This prevents "Task got Future attached to a different loop" errors 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..eb93347c1 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 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.asset import Asset - from sift_client.sift_types.remote_file import RemoteFile -class Run(BaseType[RunProto, "Run"]): +class Run(BaseType[RunProto, "Run"], AttachmentsMixin): """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.""" From 68b84e1ee95f1a8d43d4b004f7ca4a073e1ddac4 Mon Sep 17 00:00:00 2001 From: Alex Luck Date: Fri, 7 Nov 2025 14:46:19 -0800 Subject: [PATCH 2/3] remove usage --- .../sift_client/sift_types/_mixins/usage.md | 90 ------------------- 1 file changed, 90 deletions(-) delete mode 100644 python/lib/sift_client/sift_types/_mixins/usage.md diff --git a/python/lib/sift_client/sift_types/_mixins/usage.md b/python/lib/sift_client/sift_types/_mixins/usage.md deleted file mode 100644 index 940ea1b3c..000000000 --- a/python/lib/sift_client/sift_types/_mixins/usage.md +++ /dev/null @@ -1,90 +0,0 @@ -# AttachmentsMixin Usage - -The `AttachmentsMixin` provides a standardized way to add file attachment functionality to Sift types. - -## Features - -The mixin adds two methods to any class that uses it: - -1. **`async get_attachments()`** - Async method to fetch attachments -2. **`attachments`** - Sync property that fetches attachments using the client's event loop - -## Usage - -### For Sift Type Models - -The mixin automatically determines the entity type based on the class name. Simply inherit from `AttachmentsMixin`: - -```python -from sift_client.sift_types._mixins.file_attachments import AttachmentsMixin - - -class Asset(BaseType[AssetProto, "Asset"], AttachmentsMixin): - """Asset model - automatically mapped to ENTITY_TYPE_ASSET.""" - - # ... rest of the model fields -``` - -### Adding New Models - -To add attachment support to a new model: - -1. Inherit from `AttachmentsMixin` -2. Add the class name mapping to `AttachmentsMixin._ENTITY_TYPE_MAP` in `_mixins.py` - -```python -# In _mixins.py -_ENTITY_TYPE_MAP: ClassVar[dict[str, str]] = { - "Asset": "ENTITY_TYPE_ASSET", - "Run": "ENTITY_TYPE_RUN", - "TestReport": "ENTITY_TYPE_TEST_REPORT", - "MyNewModel": "ENTITY_TYPE_MY_NEW_MODEL", # Add your model here -} -``` - -### Examples - -#### Async Usage - -```python -# In an async context -asset = await client.assets.get(asset_id="...") -attachments = await asset.get_attachments() -for file in attachments: - print(f"File: {file.file_name}") -``` - -#### Sync Usage (Property) - -```python -# In a sync context -asset = client.assets.get(asset_id="...") -attachments = asset.attachments # Uses the property -for file in attachments: - print(f"File: {file.file_name}") -``` - -## Current Implementations - -The following Sift types currently use `AttachmentsMixin`: - -- **Asset** - `ENTITY_TYPE_ASSET` -- **Run** - `ENTITY_TYPE_RUN` -- **TestReport** - `ENTITY_TYPE_TEST_REPORT` - -## How It Works - -1. The mixin maintains a `_ENTITY_TYPE_MAP` dictionary mapping class names to entity types -2. When `get_attachments()` is called, it looks up the class name in the map -3. The async method uses the low-level `RemoteFilesLowLevelClient` to query for files -4. It builds a CEL filter based on the entity ID and the mapped entity type -5. The sync property `attachments` uses `asyncio.run_coroutine_threadsafe()` to run the async method on the client's dedicated event loop -6. This ensures all async operations happen in the same event loop context (see architecture notes) - -## Architecture Notes - -The mixin follows the established pattern where: -- Low-level clients are purely async -- High-level APIs provide both sync and async versions -- Sync methods use `asyncio.run_coroutine_threadsafe()` with the client's dedicated event loop -- This prevents "Task got Future attached to a different loop" errors From 1e4b98db4783bf941e363c4e9371d6b2b7f91450 Mon Sep 17 00:00:00 2001 From: Andrew Baker Date: Mon, 10 Nov 2025 11:46:42 -0800 Subject: [PATCH 3/3] Cleanup: use FileAttachments... instead of Attachments and don't include creating attachments --- .../sift_client/resources/sync_stubs/__init__.py | 2 +- .../sift_types/_mixins/file_attachments.py | 13 ++----------- python/lib/sift_client/sift_types/run.py | 4 ++-- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.py b/python/lib/sift_client/resources/sync_stubs/__init__.py index 11a452262..cf3879278 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.py +++ b/python/lib/sift_client/resources/sync_stubs/__init__.py @@ -25,7 +25,7 @@ ReportsAPI = generate_sync_api(ReportsAPIAsync, "ReportsAPI") TagsAPI = generate_sync_api(TagsAPIAsync, "TagsAPI") TestResultsAPI = generate_sync_api(TestResultsAPIAsync, "TestResultsAPI") -FileAttachmentsAPI = generate_sync_api(FileAttachmentsAPIAsync, "AttachmentsAPI") +FileAttachmentsAPI = generate_sync_api(FileAttachmentsAPIAsync, "FileAttachmentsAPI") __all__ = [ "AssetsAPI", diff --git a/python/lib/sift_client/sift_types/_mixins/file_attachments.py b/python/lib/sift_client/sift_types/_mixins/file_attachments.py index eeb5282b9..50a99076a 100644 --- a/python/lib/sift_client/sift_types/_mixins/file_attachments.py +++ b/python/lib/sift_client/sift_types/_mixins/file_attachments.py @@ -7,7 +7,7 @@ from sift_client.sift_types.file_attachment import FileAttachment -class AttachmentsMixin: +class FileAttachmentsMixin: """Mixin for sift_types that support file attachments (remote files). This mixin assumes the class also inherits from BaseType, which provides: @@ -42,7 +42,7 @@ def _get_entity_type_name(self) -> str: if not entity_type: raise ValueError( f"{class_name} is not configured for attachments. " - f"Add it to AttachmentsMixin._ENTITY_TYPE_MAP" + f"Add it to FileAttachmentsMixin._ENTITY_TYPE_MAP" ) return entity_type @@ -55,15 +55,6 @@ def attachments(self: BaseType) -> list[FileAttachment]: entity_id=self.id_, ) - def add_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, - ) - def delete_attachment( self: BaseType, file_attachment: FileAttachment | list[FileAttachment] ) -> FileAttachment: diff --git a/python/lib/sift_client/sift_types/run.py b/python/lib/sift_client/sift_types/run.py index eb93347c1..0c687789b 100644 --- a/python/lib/sift_client/sift_types/run.py +++ b/python/lib/sift_client/sift_types/run.py @@ -14,7 +14,7 @@ ModelCreateUpdateBase, ModelUpdate, ) -from sift_client.sift_types._mixins.file_attachments import AttachmentsMixin +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 @@ -23,7 +23,7 @@ from sift_client.sift_types.asset import Asset -class Run(BaseType[RunProto, "Run"], AttachmentsMixin): +class Run(BaseType[RunProto, "Run"], FileAttachmentsMixin): """Run model representing a data collection run.""" # Required fields