diff --git a/python/lib/sift_client/_internal/low_level_wrappers/__init__.py b/python/lib/sift_client/_internal/low_level_wrappers/__init__.py index d4122d3aa..39caca801 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/__init__.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/__init__.py @@ -5,6 +5,7 @@ from sift_client._internal.low_level_wrappers.channels import ChannelsLowLevelClient from sift_client._internal.low_level_wrappers.ingestion import IngestionLowLevelClient from sift_client._internal.low_level_wrappers.ping import PingLowLevelClient +from sift_client._internal.low_level_wrappers.remote_files import RemoteFilesLowLevelClient from sift_client._internal.low_level_wrappers.reports import ReportsLowLevelClient from sift_client._internal.low_level_wrappers.rules import RulesLowLevelClient from sift_client._internal.low_level_wrappers.runs import RunsLowLevelClient @@ -18,6 +19,7 @@ "ChannelsLowLevelClient", "IngestionLowLevelClient", "PingLowLevelClient", + "RemoteFilesLowLevelClient", "ReportsLowLevelClient", "RulesLowLevelClient", "RunsLowLevelClient", 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 new file mode 100644 index 000000000..b1ef49833 --- /dev/null +++ b/python/lib/sift_client/_internal/low_level_wrappers/remote_files.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any, cast + +import requests +from sift.remote_files.v1.remote_files_pb2 import ( + BatchDeleteRemoteFilesRequest, + DeleteRemoteFileRequest, + GetRemoteFileDownloadUrlRequest, + GetRemoteFileRequest, + GetRemoteFileResponse, + ListRemoteFilesRequest, + ListRemoteFilesResponse, + UpdateRemoteFileRequest, + UpdateRemoteFileResponse, +) +from sift.remote_files.v1.remote_files_pb2_grpc import RemoteFileServiceStub + +from sift_client._internal.low_level_wrappers.base import ( + LowLevelClientBase, +) +from sift_client.transport import GrpcClient, WithGrpcClient + +if TYPE_CHECKING: + from sift_client.client import SiftClient + from sift_client.sift_types.file_attachment import FileAttachment, FileAttachmentUpdate + + +class RemoteFilesLowLevelClient(LowLevelClientBase, WithGrpcClient): + """Low-level client for the RemoteFilesAPI. + + This class provides a thin wrapper around the autogenerated bindings for the RemoteFilesAPI. + """ + + def __init__(self, grpc_client: GrpcClient): + """Initialize the RemoteFilesLowLevelClient. + + Args: + grpc_client: The gRPC client to use for making API calls. + """ + super().__init__(grpc_client) + + async def get_remote_file( + self, remote_file_id: str, sift_client: SiftClient | None = None + ) -> FileAttachment: + """Get a remote file by ID. + + Args: + remote_file_id: The ID of the remote file to retrieve. + sift_client: The SiftClient to attach to the returned RemoteFile. + + Returns: + The 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 FileAttachment._from_proto(grpc_remote_file, sift_client) + + async def list_all_remote_files( + self, + query_filter: str | None = None, + max_results: int | None = None, + page_size: int | None = None, + order_by: str | None = None, + sift_client: SiftClient | None = None, + ) -> list[FileAttachment]: + """List all remote files matching the given query. + + Args: + query_filter: The CEL query filter. + + max_results: The maximum number of results to return. + page_size: The number of results to return per page. + order_by: The field to order by. Not supported by the backend, but it is here for API consistency. TODO: Add to backend + sift_client: The SiftClient to attach to the returned RemoteFiles. + + Returns: + A list of RemoteFiles matching the given query. + """ + return await self._handle_pagination( + self.list_remote_files, + kwargs={"query_filter": query_filter, "sift_client": sift_client}, + page_size=page_size, + max_results=max_results, + order_by=order_by, + ) + + async def list_remote_files( + self, + page_size: int | None = None, + page_token: str | None = None, + query_filter: str | None = None, + order_by: str | None = None, + sift_client: SiftClient | None = None, + ) -> tuple[list[FileAttachment], str]: + """List remote files with pagination support. + + Args: + page_size: The number of results to return per page. + page_token: The page token for pagination. + query_filter: The CEL query filter. + order_by: The field to order by. Not supported by the backend, but it is here for API consistency. TODO: Add to backend + sift_client: The SiftClient to attach to the returned RemoteFiles. + + Returns: + A tuple of (list of RemoteFiles, next_page_token). + """ + from sift_client.sift_types.file_attachment import FileAttachment + + if order_by is not None: + raise NotImplementedError + + 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 + + request = ListRemoteFilesRequest(**request_kwargs) + response = await self._grpc_client.get_stub(RemoteFileServiceStub).ListRemoteFiles(request) + response = cast("ListRemoteFilesResponse", response) + return [ + FileAttachment._from_proto(rf, sift_client) for rf in response.remote_files + ], response.next_page_token + + async def update_remote_file( + self, update: FileAttachmentUpdate, sift_client: SiftClient | None = None + ) -> FileAttachment: + """Update a remote file. + + Args: + update: The FileAttachmentUpdate containing the fields to update. + sift_client: The SiftClient to attach to the returned RemoteFile. + + Returns: + The updated 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 FileAttachment._from_proto(updated_grpc_remote_file, sift_client) + + async def delete_remote_file(self, remote_file_id: str) -> None: + """Delete a remote file. + + Args: + remote_file_id: The ID of the remote file to delete. + """ + request = DeleteRemoteFileRequest(remote_file_id=remote_file_id) + await self._grpc_client.get_stub(RemoteFileServiceStub).DeleteRemoteFile(request) + + async def batch_delete_remote_files(self, remote_file_ids: list[str]) -> None: + """Batch delete remote files. + + Args: + remote_file_ids: The IDs of the remote files to delete (up to 1000). + """ + request = BatchDeleteRemoteFilesRequest(remote_file_ids=remote_file_ids) + await self._grpc_client.get_stub(RemoteFileServiceStub).BatchDeleteRemoteFiles(request) + + async def get_remote_file_download_url(self, remote_file_id: str) -> str: + """Get a download URL for a remote file. + + Args: + remote_file_id: The ID of the remote file. + + Returns: + The download URL for the remote file. + """ + request = GetRemoteFileDownloadUrlRequest(remote_file_id=remote_file_id) + response = await self._grpc_client.get_stub(RemoteFileServiceStub).GetRemoteFileDownloadUrl( + request + ) + return response.download_url + + async def download_remote_file(self, file_attachment: FileAttachment) -> bytes: + """Download a remote file. + + Args: + file_attachment: The FileAttachment to download. + + Returns: + The downloaded file. + """ + url = await self.get_remote_file_download_url(file_attachment._id_or_error) + + # Run the synchronous requests.get in a thread pool to avoid blocking + def _download(): + response = requests.get(url) + response.raise_for_status() + return response.content + + # Use run_in_executor for Python 3.8 compatibility (asyncio.to_thread was added in 3.9) + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, _download) diff --git a/python/lib/sift_client/_tests/README.md b/python/lib/sift_client/_tests/README.md new file mode 100644 index 000000000..f5f6360f3 --- /dev/null +++ b/python/lib/sift_client/_tests/README.md @@ -0,0 +1,13 @@ +# Sift Python + +## Running Integration Tests Locally + +1. Create Environmental Variables + a. Create or open a .env file in /python + b. Add an API key for SIFT_API_KEY +2. Start Local Sift +3. Asset Data: NostromoLV426 + a. Ensure that your local Sift instance contains data for the asset NostromoLV426 + b. If it doesn't them export data for NostromoLV426 from development +4. Run tests + a. Run tests using /python/scripts/dev {test, test-integration, test-all} diff --git a/python/lib/sift_client/_tests/conftest.py b/python/lib/sift_client/_tests/conftest.py index 81e24b287..3efaf4d93 100644 --- a/python/lib/sift_client/_tests/conftest.py +++ b/python/lib/sift_client/_tests/conftest.py @@ -19,13 +19,15 @@ def sift_client() -> SiftClient: grpc_url = os.getenv("SIFT_GRPC_URI", "localhost:50051") rest_url = os.getenv("SIFT_REST_URI", "localhost:8080") api_key = os.getenv("SIFT_API_KEY", "") + # If the URL contains localhost, don't use SSL. Most likely running tests or local development. + use_ssl = not ("localhost" in grpc_url or "localhost" in rest_url) client = SiftClient( connection_config=SiftConnectionConfig( api_key=api_key, grpc_url=grpc_url, rest_url=rest_url, - use_ssl=True, + use_ssl=use_ssl, ) ) @@ -45,6 +47,7 @@ def mock_client(): client.rules = MagicMock() client.tags = MagicMock() client.test_results = MagicMock() + client.file_attachments = MagicMock() client.async_ = MagicMock(spec=AsyncAPIs) client.async_.ingestion = MagicMock() return client diff --git a/python/lib/sift_client/_tests/resources/test_file_attachments.py b/python/lib/sift_client/_tests/resources/test_file_attachments.py new file mode 100644 index 000000000..ad20af0d7 --- /dev/null +++ b/python/lib/sift_client/_tests/resources/test_file_attachments.py @@ -0,0 +1,572 @@ +"""Pytest tests for the File Attachments API. + +These tests demonstrate and validate the usage of the File Attachments API including: +- Basic file attachment operations (get, list, upload, download) +- File attachment filtering by entity +- File attachment updates and deletion +- Error handling and edge cases +""" + +import os +import tempfile +from pathlib import Path + +import pytest +import pytest_asyncio + +from sift_client import SiftClient +from sift_client.resources import FileAttachmentsAPI, FileAttachmentsAPIAsync +from sift_client.sift_types.file_attachment import ( + FileAttachment, + FileAttachmentUpdate, + RemoteFileEntityType, +) + +pytestmark = pytest.mark.integration + + +def test_client_binding(sift_client): + """Test that file_attachments API is properly bound to the client.""" + assert sift_client.file_attachments + assert isinstance(sift_client.file_attachments, FileAttachmentsAPI) + assert sift_client.async_.file_attachments + assert isinstance(sift_client.async_.file_attachments, FileAttachmentsAPIAsync) + + +@pytest.fixture +def file_attachments_api_async(sift_client: SiftClient): + """Get the async file attachments API instance.""" + return sift_client.async_.file_attachments + + +@pytest.fixture +def file_attachments_api_sync(sift_client: SiftClient): + """Get the synchronous file attachments API instance.""" + return sift_client.file_attachments + + +@pytest.fixture +def test_run(sift_client: SiftClient): + """Get a test run to attach files to.""" + runs = sift_client.runs.list_(limit=1) + if runs: + return runs[0] + pytest.fail("No runs available for testing, please add test test runs") + + +@pytest.fixture +def test_asset(sift_client: SiftClient): + """Get a test asset to attach files to.""" + assets = sift_client.assets.list_(limit=1) + if assets: + return assets[0] + pytest.fail("No assets available for testing, please add test test assets") + + +@pytest_asyncio.fixture +async def uploaded_file_attachment(file_attachments_api_async, test_run): + """Upload a test file and return the file attachment, cleaning up after test.""" + # Create a temporary test file + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tmp: + tmp.write("Test file content for integration tests\n") + tmp.write("This file should be deleted after the test\n") + tmp_path = tmp.name + + try: + # Upload the file + file_attachment = await file_attachments_api_async.upload( + path=tmp_path, + entity=test_run, + description="Integration test file attachment", + ) + yield file_attachment + + # Cleanup: delete the uploaded file + try: + await file_attachments_api_async.delete(file_attachments=file_attachment) + except Exception: + pass # If deletion fails, it's okay for test cleanup + finally: + # Cleanup: delete the temporary local file + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + +class TestFileAttachmentsAPIAsync: + """Test suite for the async File Attachments API functionality.""" + + class TestUpload: + """Tests for the async upload method.""" + + @pytest.mark.asyncio + async def test_upload_to_run(self, file_attachments_api_async, test_run): + """Test uploading a file attachment to a run.""" + # Create a temporary test file + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tmp: + tmp.write("Test file content\n") + tmp_path = tmp.name + + try: + # Upload the file + file_attachment = await file_attachments_api_async.upload( + path=tmp_path, + entity=test_run, + description="Test upload to run", + ) + + # Verify the upload + assert isinstance(file_attachment, FileAttachment) + assert file_attachment.id_ is not None + assert file_attachment.file_name is not None + assert file_attachment.description == "Test upload to run" + assert file_attachment.entity_id == test_run.id_ + + # Cleanup: delete the uploaded file + await file_attachments_api_async.delete(file_attachments=file_attachment) + finally: + # Cleanup: delete the temporary local file + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + @pytest.mark.asyncio + async def test_upload_to_asset(self, file_attachments_api_async, test_asset): + """Test uploading a file attachment to an asset.""" + # Create a temporary test file + with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as tmp: + tmp.write("col1,col2,col3\n") + tmp.write("1,2,3\n") + tmp_path = tmp.name + + try: + # Upload the file + file_attachment = await file_attachments_api_async.upload( + path=tmp_path, + entity=test_asset, + description="Test CSV upload to asset", + metadata={"test_key": "test_value"}, + ) + + # Verify the upload + assert isinstance(file_attachment, FileAttachment) + assert file_attachment.id_ is not None + assert file_attachment.entity_id == test_asset.id_ + assert file_attachment.description == "Test CSV upload to asset" + + # Cleanup + await file_attachments_api_async.delete(file_attachments=file_attachment) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + @pytest.mark.asyncio + async def test_upload_with_pathlib(self, file_attachments_api_async, test_run): + """Test uploading using pathlib.Path instead of string.""" + # Create a temporary test file + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tmp: + tmp.write("Test pathlib upload\n") + tmp_path = Path(tmp.name) + + try: + # Upload using Path object + file_attachment = await file_attachments_api_async.upload( + path=tmp_path, + entity=test_run, + description="Test pathlib upload", + ) + + assert isinstance(file_attachment, FileAttachment) + assert file_attachment.id_ is not None + + # Cleanup + await file_attachments_api_async.delete(file_attachments=file_attachment) + finally: + if tmp_path.exists(): + tmp_path.unlink() + + class TestGet: + """Tests for the async get method.""" + + @pytest.mark.asyncio + async def test_get_by_id(self, file_attachments_api_async, uploaded_file_attachment): + """Test getting a file attachment by ID.""" + retrieved = await file_attachments_api_async.get( + file_attachment_id=uploaded_file_attachment.id_ + ) + + assert retrieved is not None + assert retrieved.id_ == uploaded_file_attachment.id_ + assert retrieved.file_name == uploaded_file_attachment.file_name + + @pytest.mark.asyncio + async def test_get_nonexistent_raises_error(self, file_attachments_api_async): + """Test that getting a non-existent file attachment raises an error.""" + # Should raise an error for non-existent file attachment + with pytest.raises(Exception, match="invalid input syntax for type uuid"): + await file_attachments_api_async.get(file_attachment_id="nonexistent-file-id-12345") + + class TestList: + """Tests for the async list_ method.""" + + @pytest.mark.asyncio + async def test_basic_list(self, file_attachments_api_async): + """Test basic file attachment listing functionality.""" + file_attachments = await file_attachments_api_async.list_(limit=5) + + # Verify we get a list + assert isinstance(file_attachments, list) + + # If we have file attachments, verify their structure + if file_attachments: + fa = file_attachments[0] + assert isinstance(fa, FileAttachment) + assert fa.id_ is not None + + @pytest.mark.asyncio + async def test_list_by_entity( + self, file_attachments_api_async, uploaded_file_attachment, test_run + ): + """Test listing file attachments filtered by entity.""" + file_attachments = await file_attachments_api_async.list_( + entities=[test_run], + limit=100, + ) + + assert isinstance(file_attachments, list) + + # Should find our uploaded file + found = any(fa.id_ == uploaded_file_attachment.id_ for fa in file_attachments) + assert found, "Uploaded file attachment not found in entity list" + + # All returned attachments should belong to the test run + for fa in file_attachments: + assert fa.entity_id == test_run.id_ + + @pytest.mark.asyncio + async def test_list_by_entity_id( + self, file_attachments_api_async, uploaded_file_attachment, test_run + ): + """Test listing file attachments filtered by entity_id.""" + file_attachments = await file_attachments_api_async.list_( + entity_ids=[test_run.id_], + limit=100, + ) + + assert isinstance(file_attachments, list) + + # Should find our uploaded file + found = any(fa.id_ == uploaded_file_attachment.id_ for fa in file_attachments) + assert found, "Uploaded file attachment not found in entity list" + + @pytest.mark.asyncio + async def test_list_by_entity_type( + self, file_attachments_api_async, uploaded_file_attachment, test_run + ): + """Test listing file attachments filtered by entity_type.""" + # Test filtering by RUNS entity type + file_attachments = await file_attachments_api_async.list_( + entity_type=RemoteFileEntityType.RUNS, + limit=100, + ) + assert isinstance(file_attachments, list) + # All returned attachments should be for RUNS + for fa in file_attachments: + assert fa.entity_type == RemoteFileEntityType.RUNS + + # Test filtering by ASSETS entity type + file_attachments = await file_attachments_api_async.list_( + entity_type=RemoteFileEntityType.ASSETS, + limit=100, + ) + assert isinstance(file_attachments, list) + # All returned attachments should be for ASSETS + for fa in file_attachments: + assert fa.entity_type == RemoteFileEntityType.ASSETS + + # Test filtering by TEST_REPORTS entity type + file_attachments = await file_attachments_api_async.list_( + entity_type=RemoteFileEntityType.TEST_REPORTS, + limit=100, + ) + assert isinstance(file_attachments, list) + # All returned attachments should be for TEST_REPORTS + for fa in file_attachments: + assert fa.entity_type == RemoteFileEntityType.TEST_REPORTS + + @pytest.mark.asyncio + async def test_list_by_file_name( + self, file_attachments_api_async, uploaded_file_attachment + ): + """Test listing file attachments filtered by file name.""" + file_attachments = await file_attachments_api_async.list_( + names=[uploaded_file_attachment.file_name], + ) + + assert isinstance(file_attachments, list) + + # Should find at least our uploaded file + found = any(fa.id_ == uploaded_file_attachment.id_ for fa in file_attachments) + assert found + + # All returned attachments should have the specified file name + for fa in file_attachments: + assert fa.file_name == uploaded_file_attachment.file_name + + @pytest.mark.asyncio + async def test_list_with_limit(self, file_attachments_api_async): + """Test file attachment listing with different limits.""" + # Test with limit of 1 + fas_1 = await file_attachments_api_async.list_(limit=1) + assert isinstance(fas_1, list) + assert len(fas_1) <= 1 + + # Test with limit of 3 + fas_3 = await file_attachments_api_async.list_(limit=3) + assert isinstance(fas_3, list) + assert len(fas_3) <= 3 + + class TestUpdate: + """Tests for the async update method.""" + + @pytest.mark.asyncio + async def test_update_description( + self, file_attachments_api_async, uploaded_file_attachment + ): + """Test updating a file attachment's description.""" + new_description = "Updated description for integration test" + + update = FileAttachmentUpdate(description=new_description) + update.resource_id = uploaded_file_attachment.id_ + + updated = await file_attachments_api_async.update(file_attachment=update) + + assert updated.id_ == uploaded_file_attachment.id_ + assert updated.description == new_description + + @pytest.mark.asyncio + async def test_update_with_dict(self, file_attachments_api_async, uploaded_file_attachment): + """Test updating a file attachment using a dict.""" + new_description = "Updated via dict" + + # When using dict, the ID must be set via resource_id after creating the update object + update_dict = {"description": new_description} + update = FileAttachmentUpdate.model_validate(update_dict) + update.resource_id = uploaded_file_attachment.id_ + + updated = await file_attachments_api_async.update(file_attachment=update) + + assert updated.id_ == uploaded_file_attachment.id_ + assert updated.description == new_description + + class TestDelete: + """Tests for the async delete method.""" + + @pytest.mark.asyncio + async def test_delete_single_by_id(self, file_attachments_api_async, test_run): + """Test deleting a single file attachment by ID string.""" + # Upload a file to delete + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tmp: + tmp.write("File to delete\n") + tmp_path = tmp.name + + try: + file_attachment = await file_attachments_api_async.upload( + path=tmp_path, + entity=test_run, + description="File to delete", + ) + + # Delete by ID string + await file_attachments_api_async.delete(file_attachments=file_attachment.id_) + + # Verify it's deleted by attempting to get it (should raise error) + with pytest.raises(Exception, match="An error occurred"): + await file_attachments_api_async.get(file_attachment_id=file_attachment.id_) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + @pytest.mark.asyncio + async def test_delete_single_by_object(self, file_attachments_api_async, test_run): + """Test deleting a single file attachment by FileAttachment object.""" + # Upload a file to delete + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tmp: + tmp.write("File to delete\n") + tmp_path = tmp.name + + try: + file_attachment = await file_attachments_api_async.upload( + path=tmp_path, + entity=test_run, + description="File to delete by object", + ) + + # Delete by FileAttachment object + await file_attachments_api_async.delete(file_attachments=file_attachment) + + # Verify it's deleted by attempting to get it (should raise error) + with pytest.raises(Exception, match="An error occurred"): + await file_attachments_api_async.get(file_attachment_id=file_attachment.id_) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + @pytest.mark.asyncio + async def test_delete_multiple(self, file_attachments_api_async, test_run): + """Test deleting multiple file attachments at once.""" + # Upload multiple files + file_attachments = [] + tmp_paths = [] + + try: + for i in range(3): + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tmp: + tmp.write(f"File {i} to delete\n") + tmp_paths.append(tmp.name) + + fa = await file_attachments_api_async.upload( + path=tmp_paths[-1], + entity=test_run, + description=f"File {i} to delete", + ) + file_attachments.append(fa) + + # Delete all at once + await file_attachments_api_async.delete(file_attachments=file_attachments) + + # Verify they're all deleted + for fa in file_attachments: + with pytest.raises(Exception, match="An error occurred"): + await file_attachments_api_async.get(file_attachment_id=fa.id_) + finally: + for tmp_path in tmp_paths: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + @pytest.mark.asyncio + async def test_delete_list_of_ids(self, file_attachments_api_async, test_run): + """Test deleting multiple file attachments using a list of ID strings.""" + # Upload multiple files + file_attachments = [] + tmp_paths = [] + + try: + for i in range(2): + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tmp: + tmp.write(f"File {i} to delete\n") + tmp_paths.append(tmp.name) + + fa = await file_attachments_api_async.upload( + path=tmp_paths[-1], + entity=test_run, + description=f"File {i} to delete by ID", + ) + file_attachments.append(fa) + + # Delete using list of IDs + ids = [fa.id_ for fa in file_attachments] + await file_attachments_api_async.delete(file_attachments=ids) + + # Verify they're all deleted + for fa_id in ids: + with pytest.raises(Exception, match="An error occurred"): + await file_attachments_api_async.get(file_attachment_id=fa_id) + finally: + for tmp_path in tmp_paths: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + class TestDownload: + """Tests for the async download methods.""" + + @pytest.mark.asyncio + async def test_get_download_url(self, file_attachments_api_async, uploaded_file_attachment): + """Test getting a download URL for a file attachment.""" + url = await file_attachments_api_async.get_download_url( + file_attachment=uploaded_file_attachment + ) + + assert isinstance(url, str) + assert len(url) > 0 + # URL should be a valid HTTP/HTTPS URL + assert url.startswith("http://") or url.startswith("https://") + + @pytest.mark.asyncio + async def test_get_download_url_by_id( + self, file_attachments_api_async, uploaded_file_attachment + ): + """Test getting a download URL using file attachment ID.""" + url = await file_attachments_api_async.get_download_url( + file_attachment=uploaded_file_attachment.id_ + ) + + assert isinstance(url, str) + assert len(url) > 0 + + +class TestFileAttachmentsAPISync: + """Test suite for the synchronous File Attachments API functionality. + + Only includes a single test for basic sync generation. No specific sync behavior difference tests are needed. + """ + + class TestList: + """Tests for the sync list_ method.""" + + def test_basic_list(self, file_attachments_api_sync): + """Test basic synchronous file attachment listing functionality.""" + file_attachments = file_attachments_api_sync.list_(limit=5) + + # Verify we get a list + assert isinstance(file_attachments, list) + + # If we have file attachments, verify their structure + if file_attachments: + assert isinstance(file_attachments[0], FileAttachment) + + class TestUpload: + """Tests for the sync upload method.""" + + def test_upload_download_and_delete(self, file_attachments_api_sync, test_run): + """Test synchronous upload, download, and cleanup.""" + # Create a temporary test file + completed = False + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tmp: + tmp.write("Sync test file\n") + tmp_path = tmp.name + + # Create a temporary download path + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tmp: + download_path = tmp.name + + try: + # Upload using sync API + file_attachment = file_attachments_api_sync.upload( + path=tmp_path, + entity=test_run, + description="Sync upload test", + ) + + # Verify the upload + assert isinstance(file_attachment, FileAttachment) + assert file_attachment.id_ is not None + + # Download the file + file_attachments_api_sync.download( + file_attachment=file_attachment, output_path=download_path + ) + + # Verify the downloaded content matches the original + with open(download_path) as f: + downloaded_content = f.read() + + assert downloaded_content == "Sync test file\n" + + # Cleanup + file_attachments_api_sync.delete(file_attachments=file_attachment) + completed = True + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + if os.path.exists(download_path): + os.unlink(download_path) + assert completed diff --git a/python/lib/sift_client/_tests/sift_types/test_asset.py b/python/lib/sift_client/_tests/sift_types/test_asset.py index e209e2aad..23d49970d 100644 --- a/python/lib/sift_client/_tests/sift_types/test_asset.py +++ b/python/lib/sift_client/_tests/sift_types/test_asset.py @@ -163,3 +163,24 @@ def test_update_calls_client_and_updates_self(self, mock_asset, mock_client): mock_update.assert_called_once_with(updated_asset) # Verify it returns self assert result is mock_asset + + def test_attachments_property_fetches_files(self, mock_asset, mock_client): + """Test that attachments property fetches files from client.file_attachments API.""" + # Create mock remote files + mock_remote_file = MagicMock() + mock_remote_file.entity_id = mock_asset.id_ + mock_remote_files = [mock_remote_file] + + # Mock the file_attachments API + mock_client.file_attachments.list_.return_value = mock_remote_files + + # Access the attachments property (it's a property, not a method) + result = mock_asset.attachments + + # Verify file_attachments.list_ was called with correct parameters + mock_client.file_attachments.list_.assert_called_once_with( + entities=[mock_asset], + ) + + # Verify result + assert result == mock_remote_files diff --git a/python/lib/sift_client/_tests/sift_types/test_results.py b/python/lib/sift_client/_tests/sift_types/test_results.py index 2ae139a7e..3fcd694e0 100644 --- a/python/lib/sift_client/_tests/sift_types/test_results.py +++ b/python/lib/sift_client/_tests/sift_types/test_results.py @@ -209,3 +209,24 @@ def test_step_measurements(self, mock_test_step, mock_test_measurement, mock_cli measurements = mock_test_step.measurements assert len(measurements) == 1 assert measurements[0] == mock_test_measurement + + def test_attachments_property_fetches_files(self, mock_test_report, mock_client): + """Test that attachments property fetches files from client.file_attachments API.""" + # Create mock remote files + mock_remote_file = MagicMock() + mock_remote_file.entity_id = mock_test_report.id_ + mock_remote_files = [mock_remote_file] + + # Mock the file_attachments API + mock_client.file_attachments.list_.return_value = mock_remote_files + + # Access the attachments property (it's a property, not a method) + result = mock_test_report.attachments + + # Verify file_attachments.list_ was called with correct parameters + mock_client.file_attachments.list_.assert_called_once_with( + entities=[mock_test_report], + ) + + # Verify result + assert result == mock_remote_files diff --git a/python/lib/sift_client/_tests/sift_types/test_run.py b/python/lib/sift_client/_tests/sift_types/test_run.py index a9433f519..64c6b8cad 100644 --- a/python/lib/sift_client/_tests/sift_types/test_run.py +++ b/python/lib/sift_client/_tests/sift_types/test_run.py @@ -206,6 +206,27 @@ def test_update_calls_client_and_updates_self(self, mock_run, mock_client): # Verify it returns self assert result is mock_run + def test_attachments_property_fetches_files(self, mock_run, mock_client): + """Test that attachments property fetches files from client.file_attachments API.""" + # Create mock remote files + mock_remote_file = MagicMock() + mock_remote_file.entity_id = mock_run.id_ + mock_remote_files = [mock_remote_file] + + # Mock the file_attachments API + mock_client.file_attachments.list_.return_value = mock_remote_files + + # Access the attachments property (it's a property, not a method) + result = mock_run.attachments + + # Verify file_attachments.list_ was called with correct parameters + mock_client.file_attachments.list_.assert_called_once_with( + entities=[mock_run], + ) + + # Verify result + assert result == mock_remote_files + def test_run_stop(self, mock_run, mock_client): """Test that stop() calls client.runs.stop and updates self.""" stopped_run = MagicMock() diff --git a/python/lib/sift_client/client.py b/python/lib/sift_client/client.py index 2a2252ef8..655bd56e4 100644 --- a/python/lib/sift_client/client.py +++ b/python/lib/sift_client/client.py @@ -8,6 +8,8 @@ CalculatedChannelsAPIAsync, ChannelsAPI, ChannelsAPIAsync, + FileAttachmentsAPI, + FileAttachmentsAPIAsync, IngestionAPIAsync, PingAPI, PingAPIAsync, @@ -83,6 +85,9 @@ class SiftClient( channels: ChannelsAPI """Instance of the Channels API for making synchronous requests.""" + file_attachments: FileAttachmentsAPI + """Instance of the File Attachments API for making synchronous requests.""" + ingestion: IngestionAPIAsync """Instance of the Ingestion API for making synchronous requests.""" @@ -141,17 +146,20 @@ def __init__( self.assets = AssetsAPI(self) self.calculated_channels = CalculatedChannelsAPI(self) self.channels = ChannelsAPI(self) + self.file_attachments = FileAttachmentsAPI(self) self.rules = RulesAPI(self) self.reports = ReportsAPI(self) self.runs = RunsAPI(self) self.tags = TagsAPI(self) self.test_results = TestResultsAPI(self) + # Accessor for the asynchronous APIs self.async_ = AsyncAPIs( ping=PingAPIAsync(self), assets=AssetsAPIAsync(self), calculated_channels=CalculatedChannelsAPIAsync(self), channels=ChannelsAPIAsync(self), + file_attachments=FileAttachmentsAPIAsync(self), ingestion=IngestionAPIAsync(self), reports=ReportsAPIAsync(self), rules=RulesAPIAsync(self), diff --git a/python/lib/sift_client/resources/__init__.py b/python/lib/sift_client/resources/__init__.py index 7b2eacc29..5058ac366 100644 --- a/python/lib/sift_client/resources/__init__.py +++ b/python/lib/sift_client/resources/__init__.py @@ -153,7 +153,8 @@ async def main(): from sift_client.resources.assets import AssetsAPIAsync from sift_client.resources.calculated_channels import CalculatedChannelsAPIAsync from sift_client.resources.channels import ChannelsAPIAsync -from sift_client.resources.ingestion import IngestionAPIAsync, TracingConfig +from sift_client.resources.file_attachments import FileAttachmentsAPIAsync +from sift_client.resources.ingestion import IngestionAPIAsync from sift_client.resources.ping import PingAPIAsync from sift_client.resources.reports import ReportsAPIAsync from sift_client.resources.rules import RulesAPIAsync @@ -172,6 +173,7 @@ async def main(): RunsAPI, TagsAPI, TestResultsAPI, + FileAttachmentsAPI, ) __all__ = [ @@ -181,6 +183,8 @@ async def main(): "CalculatedChannelsAPIAsync", "ChannelsAPI", "ChannelsAPIAsync", + "FileAttachmentsAPI", + "FileAttachmentsAPIAsync", "IngestionAPIAsync", "PingAPI", "PingAPIAsync", 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..68b930e3c --- /dev/null +++ b/python/lib/sift_client/resources/file_attachments.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from sift_client._internal.low_level_wrappers.remote_files import RemoteFilesLowLevelClient +from sift_client._internal.low_level_wrappers.upload import UploadLowLevelClient +from sift_client.resources._base import ResourceBase +from sift_client.util import cel_utils as cel + +if TYPE_CHECKING: + import re + from pathlib import Path + + from sift_client.client import SiftClient + from sift_client.sift_types.asset import Asset + from sift_client.sift_types.file_attachment import ( + FileAttachment, + FileAttachmentUpdate, + RemoteFileEntityType, + ) + from sift_client.sift_types.run import Run + from sift_client.sift_types.test_report import TestReport + + +class FileAttachmentsAPIAsync(ResourceBase): + """High-level API for interacting with file attachments (remote files). + + This class provides a Pythonic interface for managing file attachments + on Sift entities like runs, assets, and test reports. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the FileAttachmentsAPIAsync. + + Args: + sift_client: The Sift client to use. + """ + super().__init__(sift_client) + self._low_level_client = RemoteFilesLowLevelClient(grpc_client=self.client.grpc_client) + self._upload_client = UploadLowLevelClient(rest_client=self.client.rest_client) + + def _build_name_cel_filters( + self, + *, + name: str | None = None, + names: list[str] | None = None, + name_contains: str | None = None, + name_regex: str | re.Pattern | None = None, + ) -> list[str]: + """Override base implementation to use 'file_name' field instead of 'name'.""" + filter_parts = [] + if name: + filter_parts.append(cel.equals("file_name", name)) + if names: + filter_parts.append(cel.in_("file_name", names)) + if name_contains: + filter_parts.append(cel.contains("file_name", name_contains)) + if name_regex: + filter_parts.append(cel.match("file_name", name_regex)) + return filter_parts + + async def get(self, *, file_attachment_id: str) -> FileAttachment: + """Get a file attachment by ID. + + Args: + file_attachment_id: The ID of the file attachment to retrieve. + + Returns: + The FileAttachment. + """ + file_attachment = await self._low_level_client.get_remote_file( + remote_file_id=file_attachment_id, + sift_client=self.client, + ) + return self._apply_client_to_instance(file_attachment) + + async def list_( + self, + *, + name: str | None = None, + names: list[str] | None = None, + name_contains: str | None = None, + name_regex: str | re.Pattern | None = None, + # self ids + remote_file_ids: list[str] | None = None, + # created/modified ranges TODO: Add to backend + # created_after: datetime | None = None, + # created_before: datetime | None = None, + # modified_after: datetime | None = None, + # modified_before: datetime | None = None, + # created/modified users TODO: Add to backend + # created_by: Any | str | None = None, + # modified_by: Any | str | None = None, + # metadata TODO: Add to backend + # metadata: list[Any] | None = None, + # file specific + entities: list[Run | Asset | TestReport] | None = None, + entity_type: RemoteFileEntityType | None = None, + entity_ids: list[str] | None = None, + # common filters + description_contains: str | None = None, + filter_query: str | None = None, + order_by: str | None = None, + limit: int | None = None, + ) -> list[FileAttachment]: + """List file attachments with optional filtering. + + Args: + name: Exact name of the file attachment. + names: List of file attachment names to filter by. + name_contains: Partial name of the file attachment. + name_regex: Regular expression to filter file attachments by name. + remote_file_ids: Filter to file attachments with any of these IDs. + entities: Filter to file attachments associated with these entities. + entity_type: Filter to file attachments associated with this entity type. + entity_ids: Filter to file attachments associated with these entity IDs. + description_contains: Partial description of the file attachment. + filter_query: Explicit CEL query to filter file attachments. + order_by: Field and direction to order results by. Note: Not supported by the backend, but it is here for API consistency. + limit: Maximum number of file attachments to return. If None, returns all matches. + + Returns: + A list of FileAttachment objects that match the filter criteria. + """ + filter_parts = [ + *self._build_name_cel_filters( + name=name, names=names, name_contains=name_contains, name_regex=name_regex + ), + # *self._build_time_cel_filters( + # created_after=created_after, + # created_before=created_before, + # modified_after=modified_after, + # modified_before=modified_before, + # created_by=created_by, + # modified_by=modified_by, + # ), + # *self._build_tags_metadata_cel_filters(metadata=metadata), + *self._build_common_cel_filters( + description_contains=description_contains, + filter_query=filter_query, + ), + ] + + if not entity_ids: + entity_ids = [] + if entities: + entity_ids += [entity._id_or_error for entity in entities] + + if entity_ids: + filter_parts.append(cel.in_("entity_id", entity_ids)) + if entity_type: + filter_parts.append(cel.equals("entity_type", entity_type.name.lower())) + if remote_file_ids: + filter_parts.append(cel.in_("remote_file_id", remote_file_ids)) + + query_filter = cel.and_(*filter_parts) + + file_attachments = await self._low_level_client.list_all_remote_files( + query_filter=query_filter or None, + max_results=limit, + ) + return self._apply_client_to_instances(file_attachments) + + async def update( + self, + *, + file_attachment: FileAttachmentUpdate | dict, + ) -> FileAttachment: + """Update a file attachment. + + Args: + file_attachment: The FileAttachmentUpdate with fields to update. + + Returns: + The updated FileAttachment. + """ + from sift_client.sift_types.file_attachment import FileAttachmentUpdate + + if isinstance(file_attachment, dict): + file_attachment = FileAttachmentUpdate.model_validate(file_attachment) + + updated = await self._low_level_client.update_remote_file( + update=file_attachment, + sift_client=self.client, + ) + return self._apply_client_to_instance(updated) + + async def delete( + self, *, file_attachments: list[FileAttachment | str] | FileAttachment | str + ) -> None: + """Batch delete multiple file attachments. + + Args: + file_attachments: List of FileAttachments or the IDs of the file attachments to delete (up to 1000). + """ + from sift_client.sift_types.file_attachment import FileAttachment + + file_attachment_ids: list[str] = [] + if isinstance(file_attachments, FileAttachment): + file_attachment_ids.append(file_attachments._id_or_error) + elif isinstance(file_attachments, str): + file_attachment_ids.append(file_attachments) + elif isinstance(file_attachments, list): + for file_attachment in file_attachments: + if isinstance(file_attachment, FileAttachment): + file_attachment_ids.append(file_attachment._id_or_error) + elif isinstance(file_attachment, str): + file_attachment_ids.append(file_attachment) + else: + raise ValueError( + "file_attachments must be a list of FileAttachment or list of str" + ) + else: + raise ValueError( + "file_attachments must be a FileAttachment, a string, or a list of FileAttachment or strings" + ) + await self._low_level_client.batch_delete_remote_files(remote_file_ids=file_attachment_ids) + + async def get_download_url(self, *, file_attachment: FileAttachment | str) -> str: + """Get a download URL for a file attachment. + + Args: + file_attachment: The FileAttachment or the ID of the file attachment. + + Returns: + The download URL for the file attachment. + """ + from sift_client.sift_types.file_attachment import FileAttachment + + attachment_id = ( + file_attachment._id_or_error + if isinstance(file_attachment, FileAttachment) + else file_attachment + ) + return await self._low_level_client.get_remote_file_download_url( + remote_file_id=attachment_id + ) + + async def download( + self, *, file_attachment: FileAttachment | str, output_path: str | Path + ) -> None: + """Download a file attachment to a local path. + + Args: + file_attachment: The FileAttachment or the ID of the file attachment to download. + output_path: The path to download the file attachment to. + """ + if isinstance(file_attachment, str): + file_attachment = await self.get(file_attachment_id=file_attachment) + content = await self._low_level_client.download_remote_file(file_attachment=file_attachment) + with open(output_path, "wb") as f: + f.write(content) + + async def upload( + self, + *, + path: str | Path, + entity: Asset | Run | TestReport, + metadata: dict[str, Any] | None = None, + description: str | None = None, + organization_id: str | None = None, + ) -> FileAttachment: + """Upload a file attachment to a remote file. + + Args: + path: The path to the file to upload. + entity: The entity that the file is attached to. + metadata: Optional metadata for the file (e.g., video/image metadata). + description: Optional description of the file. + organization_id: Optional organization ID. + + Returns: + The uploaded FileAttachment. + """ + remote_file_id = await self._upload_client.upload_attachment( + path=path, + entity_id=entity._id_or_error, + entity_type=entity._get_entity_type_name(), + metadata=metadata, + description=description, + organization_id=organization_id, + ) + # Should be able to remove await + return await self.get(file_attachment_id=remote_file_id) diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.py b/python/lib/sift_client/resources/sync_stubs/__init__.py index 6664a5e0f..ab988e7f2 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, @@ -19,11 +20,22 @@ AssetsAPI = generate_sync_api(AssetsAPIAsync, "AssetsAPI") CalculatedChannelsAPI = generate_sync_api(CalculatedChannelsAPIAsync, "CalculatedChannelsAPI") ChannelsAPI = generate_sync_api(ChannelsAPIAsync, "ChannelsAPI") +FileAttachmentsAPI = generate_sync_api(FileAttachmentsAPIAsync, "FileAttachmentsAPI") RulesAPI = generate_sync_api(RulesAPIAsync, "RulesAPI") RunsAPI = generate_sync_api(RunsAPIAsync, "RunsAPI") ReportsAPI = generate_sync_api(ReportsAPIAsync, "ReportsAPI") TagsAPI = generate_sync_api(TagsAPIAsync, "TagsAPI") - TestResultsAPI = generate_sync_api(TestResultsAPIAsync, "TestResultsAPI") -__all__ = ["AssetsAPI", "CalculatedChannelsAPI", "PingAPI", "ReportsAPI", "RunsAPI", "TagsAPI"] +__all__ = [ + "AssetsAPI", + "CalculatedChannelsAPI", + "ChannelsAPI", + "FileAttachmentsAPI", + "PingAPI", + "ReportsAPI", + "RulesAPI", + "RunsAPI", + "TagsAPI", + "TestResultsAPI", +] diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 4a819cc7a..97c86f0d6 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -21,6 +21,11 @@ if TYPE_CHECKING: CalculatedChannelUpdate, ) from sift_client.sift_types.channel import Channel + from sift_client.sift_types.file_attachment import ( + FileAttachment, + FileAttachmentUpdate, + RemoteFileEntityType, + ) from sift_client.sift_types.report import Report, ReportUpdate from sift_client.sift_types.rule import Rule, RuleCreate, RuleUpdate from sift_client.sift_types.run import Run, RunCreate, RunUpdate @@ -509,6 +514,136 @@ class ChannelsAPI: """ ... +class FileAttachmentsAPI: + """Sync counterpart to `FileAttachmentsAPIAsync`. + + High-level API for interacting with file attachments (remote files). + + This class provides a Pythonic interface for managing file attachments + on Sift entities like runs, assets, and test reports. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the FileAttachmentsAPIAsync. + + Args: + sift_client: The Sift client to use. + """ + ... + + def _run(self, coro): ... + def delete( + self, *, file_attachments: list[FileAttachment | str] | FileAttachment | str + ) -> None: + """Batch delete multiple file attachments. + + Args: + file_attachments: List of FileAttachments or the IDs of the file attachments to delete (up to 1000). + """ + ... + + def download(self, *, file_attachment: FileAttachment | str, output_path: str | Path) -> None: + """Download a file attachment to a local path. + + Args: + file_attachment: The FileAttachment or the ID of the file attachment to download. + output_path: The path to download the file attachment to. + """ + ... + + def get(self, *, file_attachment_id: str) -> FileAttachment: + """Get a file attachment by ID. + + Args: + file_attachment_id: The ID of the file attachment to retrieve. + + Returns: + The FileAttachment. + """ + ... + + def get_download_url(self, *, file_attachment: FileAttachment | str) -> str: + """Get a download URL for a file attachment. + + Args: + file_attachment: The FileAttachment or the ID of the file attachment. + + Returns: + The download URL for the file attachment. + """ + ... + + def list_( + self, + *, + name: str | None = None, + names: list[str] | None = None, + name_contains: str | None = None, + name_regex: str | re.Pattern | None = None, + remote_file_ids: list[str] | None = None, + entities: list[Run | Asset | TestReport] | None = None, + entity_type: RemoteFileEntityType | None = None, + entity_ids: list[str] | None = None, + description_contains: str | None = None, + filter_query: str | None = None, + order_by: str | None = None, + limit: int | None = None, + ) -> list[FileAttachment]: + """List file attachments with optional filtering. + + Args: + name: Exact name of the file attachment. + names: List of file attachment names to filter by. + name_contains: Partial name of the file attachment. + name_regex: Regular expression to filter file attachments by name. + remote_file_ids: Filter to file attachments with any of these IDs. + entities: Filter to file attachments associated with these entities. + entity_type: Filter to file attachments associated with this entity type. + entity_ids: Filter to file attachments associated with these entity IDs. + description_contains: Partial description of the file attachment. + filter_query: Explicit CEL query to filter file attachments. + order_by: Field and direction to order results by. Note: Not supported by the backend, but it is here for API consistency. + limit: Maximum number of file attachments to return. If None, returns all matches. + + Returns: + A list of FileAttachment objects that match the filter criteria. + """ + ... + + def update(self, *, file_attachment: FileAttachmentUpdate | dict) -> FileAttachment: + """Update a file attachment. + + Args: + file_attachment: The FileAttachmentUpdate with fields to update. + + Returns: + The updated FileAttachment. + """ + ... + + def upload( + self, + *, + path: str | Path, + entity: Asset | Run | TestReport, + metadata: dict[str, Any] | None = None, + description: str | None = None, + organization_id: str | None = None, + ) -> FileAttachment: + """Upload a file attachment to a remote file. + + Args: + path: The path to the file to upload. + entity: The entity that the file is attached to. + metadata: Optional metadata for the file (e.g., video/image metadata). + description: Optional description of the file. + organization_id: Optional organization ID. + + Returns: + The uploaded FileAttachment. + """ + ... + class PingAPI: """Sync counterpart to `PingAPIAsync`. 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..304b03c6a --- /dev/null +++ b/python/lib/sift_client/sift_types/_mixins/file_attachments.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar, Protocol + +if TYPE_CHECKING: + from pathlib import Path + + from sift_client.client import SiftClient + from sift_client.sift_types.file_attachment import FileAttachment + + +class _SupportsFileAttachments(Protocol): + """Protocol for types that support file attachments.""" + + @property + def client(self) -> SiftClient: ... + + @property + def id_(self) -> str | None: ... + + +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 -> assets + - Run -> runs + - TestReport -> test_reports + """ + + # Mapping of class names to entity types (REST API format) + _ENTITY_TYPE_MAP: ClassVar[dict[str, str]] = { + "Asset": "assets", + "Run": "runs", + "TestReport": "test_reports", + } + + def _get_entity_type_name(self) -> str: + """Get the entity type string. + + Returns: + The entity type string (e.g., 'assets', 'runs', 'test_reports') + + 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: _SupportsFileAttachments) -> list[FileAttachment]: + """Get all file attachments for this entity. + + Returns: + A list of FileAttachments associated with this entity. + """ + from sift_client.sift_types.asset import Asset + from sift_client.sift_types.run import Run + from sift_client.sift_types.test_report import TestReport + + if not isinstance(self, (Asset, Run, TestReport)): + raise ValueError("Entity is not a valid entity type") + return self.client.file_attachments.list_( + entities=[self], + ) + + def delete_attachment( + self: _SupportsFileAttachments, + file_attachment: list[FileAttachment | str] | FileAttachment | str, + ) -> None: + """Delete one or more file attachments. + + Args: + file_attachment: A single FileAttachment or list of FileAttachments to delete. + """ + self.client.file_attachments.delete(file_attachments=file_attachment) + + def upload_attachment( + self: _SupportsFileAttachments, + path: str | Path, + metadata: dict[str, Any] | None = None, + description: str | None = None, + organization_id: str | None = None, + ) -> FileAttachment: + """Upload a file attachment to a remote file. + + Args: + path: The path to the file to upload. + metadata: Optional metadata for the file (e.g., video/image metadata). + description: Optional description of the file. + organization_id: Optional organization ID. + + Returns: + The uploaded FileAttachment. + """ + from sift_client.sift_types.asset import Asset + from sift_client.sift_types.run import Run + from sift_client.sift_types.test_report import TestReport + + if not isinstance(self, (Asset, Run, TestReport)): + raise ValueError("Entity is not a valid entity type") + return self.client.file_attachments.upload( + path=path, + entity=self, + metadata=metadata, + description=description, + organization_id=organization_id, + ) diff --git a/python/lib/sift_client/sift_types/asset.py b/python/lib/sift_client/sift_types/asset.py index a0f088c7a..78217934f 100644 --- a/python/lib/sift_client/sift_types/asset.py +++ b/python/lib/sift_client/sift_types/asset.py @@ -6,6 +6,7 @@ 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 FileAttachmentsMixin from sift_client.sift_types.tag import Tag from sift_client.util.metadata import metadata_dict_to_proto, metadata_proto_to_dict @@ -15,7 +16,7 @@ from sift_client.sift_types.run import Run -class Asset(BaseType[AssetProto, "Asset"]): +class Asset(BaseType[AssetProto, "Asset"], FileAttachmentsMixin): """Model of the Sift Asset.""" # Required fields diff --git a/python/lib/sift_client/sift_types/file_attachment.py b/python/lib/sift_client/sift_types/file_attachment.py new file mode 100644 index 000000000..f10b2fc8a --- /dev/null +++ b/python/lib/sift_client/sift_types/file_attachment.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from enum import Enum +from typing import TYPE_CHECKING + +from sift.remote_files.v1.remote_files_pb2 import EntityType +from sift.remote_files.v1.remote_files_pb2 import RemoteFile as RemoteFileProto + +from sift_client.sift_types._base import BaseType, ModelUpdate + +if TYPE_CHECKING: + from pathlib import Path + + from sift_client.client import SiftClient + from sift_client.sift_types.asset import Asset + from sift_client.sift_types.run import Run + from sift_client.sift_types.test_report import TestReport + + +class RemoteFileEntityType(Enum): + """Enum for the entity type of a remote file.""" + + UNSPECIFIED = EntityType.ENTITY_TYPE_UNSPECIFIED # 0 + RUNS = EntityType.ENTITY_TYPE_RUN # 1 + ANNOTATIONS = EntityType.ENTITY_TYPE_ANNOTATION # 2 + ASSETS = EntityType.ENTITY_TYPE_ASSET # 3 + ANNOTATION_LOGS = EntityType.ENTITY_TYPE_ANNOTATION_LOG # 4 + TEST_REPORTS = EntityType.ENTITY_TYPE_TEST_REPORT # 5 + + @classmethod + def from_str(cls, val: str) -> RemoteFileEntityType | None: + """Convert string representation to RemoteFileEntityType.""" + if isinstance(val, str) and val.startswith("ENTITY_TYPE_"): + for item in cls: + if "ENTITY_TYPE_" + item.name == val: + return item + + return cls(int(val)) + + def __str__(self) -> str: + return "ENTITY_TYPE_" + self.name + + @staticmethod + def from_api_format(val: str) -> RemoteFileEntityType | None: + """Convert API format string to RemoteFileEntityType.""" + for item in RemoteFileEntityType: + if "ENTITY_TYPE_" + item.name == val: + return item + return None + + @staticmethod + def from_proto_value(proto_value: int) -> RemoteFileEntityType: + """Convert protobuf int value to RemoteFileEntityType.""" + return RemoteFileEntityType(proto_value) + + +class FileAttachment(BaseType[RemoteFileProto, "FileAttachment"]): + """Model of the Sift FileAttachment.""" + + organization_id: str + entity_id: str + entity_type: RemoteFileEntityType + file_name: str + file_mime_type: str + file_content_encoding: str + storage_key: str + file_size: int + """Size of the file in bytes.""" + description: str + created_by_user_id: str + modified_by_user_id: str + created_date: datetime + modified_date: datetime + + @classmethod + def _from_proto( + cls, proto: RemoteFileProto, sift_client: SiftClient | None = None + ) -> FileAttachment: + return cls( + id_=proto.remote_file_id, + organization_id=proto.organization_id, + entity_id=proto.entity_id, + entity_type=RemoteFileEntityType.from_proto_value(proto.entity_type), + file_name=proto.file_name, + file_mime_type=proto.file_mime_type, + file_content_encoding=proto.file_content_encoding, + storage_key=proto.storage_key, + file_size=proto.file_size, + description=proto.description, + created_by_user_id=proto.created_by_user_id, + modified_by_user_id=proto.modified_by_user_id, + created_date=proto.created_date.ToDatetime(tzinfo=timezone.utc), + modified_date=proto.modified_date.ToDatetime(tzinfo=timezone.utc), + proto=proto, + _client=sift_client, + ) + + @property + def entity(self) -> Run | Asset | TestReport: + """Get the entity that this file attachment is attached to.""" + if self.entity_type == RemoteFileEntityType.RUNS: + return self.client.runs.get(run_id=self.entity_id) + elif self.entity_type == RemoteFileEntityType.ASSETS: + return self.client.assets.get(asset_id=self.entity_id) + elif self.entity_type == RemoteFileEntityType.TEST_REPORTS: + return self.client.test_results.get(test_report_id=self.entity_id) + elif self.entity_type in ( + RemoteFileEntityType.ANNOTATIONS, + RemoteFileEntityType.ANNOTATION_LOGS, + ): + raise NotImplementedError( + f"Entity type {self.entity_type} is not yet supported for entity access" + ) + else: + raise Exception(f"Unknown remote file entity type: {self.entity_type}") + + def delete(self) -> None: + """Delete the file attachment.""" + self.client.file_attachments.delete(file_attachments=self) + + def update(self, update: FileAttachmentUpdate | dict) -> FileAttachment: + """Update the file attachment.""" + if isinstance(update, dict): + update = FileAttachmentUpdate.model_validate(update) + if self.id_ is None: + raise ValueError("Remote file ID is not set") + update.resource_id = self.id_ + + updated_file_attachment = self.client.file_attachments.update(file_attachment=update) + self._update(updated_file_attachment) + return self + + def download_url(self) -> str: + """Get the download URL for the file attachment.""" + if self.id_ is None: + raise ValueError("file attachment ID is not set") + return self.client.file_attachments.get_download_url(file_attachment=self) + + def download(self, output_path: str | Path) -> None: + """Download the file attachment to a local path.""" + self.client.file_attachments.download(file_attachment=self, output_path=output_path) + + +class FileAttachmentUpdate(ModelUpdate[RemoteFileProto]): + """Model of the FileAttachment fields that can be updated.""" + + description: str | None = None + + def _get_proto_class(self) -> type[RemoteFileProto]: + return RemoteFileProto + + def _add_resource_id_to_proto(self, proto_msg: RemoteFileProto): + if self._resource_id is None: + raise ValueError("Resource ID must be set before adding to proto") + proto_msg.remote_file_id = self._resource_id diff --git a/python/lib/sift_client/sift_types/run.py b/python/lib/sift_client/sift_types/run.py index e11451dca..acfb59a92 100644 --- a/python/lib/sift_client/sift_types/run.py +++ b/python/lib/sift_client/sift_types/run.py @@ -14,6 +14,7 @@ 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 @@ -24,7 +25,7 @@ from sift_client.sift_types.asset import Asset -class Run(BaseType[RunProto, "Run"]): +class Run(BaseType[RunProto, "Run"], FileAttachmentsMixin): """Run model representing a data collection run.""" # Required fields diff --git a/python/lib/sift_client/sift_types/test_report.py b/python/lib/sift_client/sift_types/test_report.py index 55a3b90b2..c03c94192 100644 --- a/python/lib/sift_client/sift_types/test_report.py +++ b/python/lib/sift_client/sift_types/test_report.py @@ -33,6 +33,7 @@ ModelCreateUpdateBase, ModelUpdate, ) +from sift_client.sift_types._mixins.file_attachments import FileAttachmentsMixin from sift_client.util.metadata import metadata_dict_to_proto, metadata_proto_to_dict if TYPE_CHECKING: @@ -515,7 +516,7 @@ def _to_proto(self) -> ErrorInfoProto: ) -class TestReport(BaseType[TestReportProto, "TestReport"]): +class TestReport(BaseType[TestReportProto, "TestReport"], FileAttachmentsMixin): """TestReport model representing a test report.""" status: TestStatus diff --git a/python/lib/sift_client/util/util.py b/python/lib/sift_client/util/util.py index 143c04e52..60b58501b 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, @@ -32,6 +33,9 @@ class AsyncAPIs(NamedTuple): channels: ChannelsAPIAsync """Instance of the Channels API for making asynchronous requests.""" + file_attachments: FileAttachmentsAPIAsync + """Instance of the File Attachments API for making asynchronous requests.""" + ingestion: IngestionAPIAsync """Instance of the Ingestion API for making asynchronous requests.""" diff --git a/python/mkdocs.yml b/python/mkdocs.yml index ed73da187..3129fcad3 100644 --- a/python/mkdocs.yml +++ b/python/mkdocs.yml @@ -98,6 +98,7 @@ plugins: show_symbol_type_heading: true show_symbol_type_toc: true summary: true + inherited_members: true # Custom Griffe extension to inspect the sync stubs and generate their signatures extensions: - griffe_extensions/sync_stubs_inspector.py:InspectSpecificObjects: