Skip to content

Commit 43125df

Browse files
committed
Add upload low level service and exercise importing test reports.
1 parent 3def5d2 commit 43125df

8 files changed

Lines changed: 4578 additions & 15 deletions

File tree

python/lib/sift_client/_internal/low_level_wrappers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from sift_client._internal.low_level_wrappers.rules import RulesLowLevelClient
99
from sift_client._internal.low_level_wrappers.runs import RunsLowLevelClient
1010
from sift_client._internal.low_level_wrappers.test_results import TestResultsLowLevelClient
11+
from sift_client._internal.low_level_wrappers.upload import UploadLowLevelClient
1112

1213
__all__ = [
1314
"AssetsLowLevelClient",
@@ -18,4 +19,5 @@
1819
"RulesLowLevelClient",
1920
"RunsLowLevelClient",
2021
"TestResultsLowLevelClient",
22+
"UploadLowLevelClient",
2123
]
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import logging
5+
from pathlib import Path
6+
from typing import TYPE_CHECKING, Any
7+
8+
from requests_toolbelt import MultipartEncoder
9+
10+
from sift_client._internal.low_level_wrappers.base import LowLevelClientBase
11+
from sift_client.transport import WithRestClient
12+
13+
if TYPE_CHECKING:
14+
from sift_client.transport.rest_transport import RestClient
15+
16+
# Configure logging
17+
logger = logging.getLogger(__name__)
18+
19+
20+
class UploadLowLevelClient(LowLevelClientBase, WithRestClient):
21+
"""Low-level client for file upload operations.
22+
23+
This class provides a thin wrapper for uploading file attachments via REST API.
24+
25+
Example:
26+
```python
27+
from sift_client.client import SiftClient
28+
from sift_client._internal.low_level_wrappers.upload import UploadLowLevelClient
29+
30+
# Initialize the REST client
31+
sift_client = SiftClient(rest_url="https://your-api.siftstack.com", grpc_url="https://your-grpc-api.siftstack.com", api_key="your-api-key")
32+
33+
# Create the upload client
34+
upload_client = UploadLowLevelClient(sift_client.rest_client)
35+
36+
# Upload a file
37+
remote_file_id = await upload_client.upload_attachment(
38+
path="path/to/file.mp4",
39+
entity_id="run_12345",
40+
entity_type="runs",
41+
description="Video of test run",
42+
)
43+
```
44+
"""
45+
46+
UPLOAD_PATH = "/api/v0/remote-files/upload"
47+
UPLOAD_BULK_PATH = "/api/v0/remote-files/upload:bulk"
48+
49+
def __init__(self, rest_client: RestClient):
50+
"""Initialize the UploadLowLevelClient.
51+
52+
Args:
53+
rest_client: The REST client to use for making API calls.
54+
"""
55+
super().__init__(rest_client)
56+
57+
async def upload_attachment(
58+
self,
59+
path: str | Path,
60+
entity_id: str,
61+
entity_type: str,
62+
metadata: dict[str, Any] | None = None,
63+
description: str | None = None,
64+
organization_id: str | None = None,
65+
) -> str:
66+
"""Upload a file attachment to an entity.
67+
68+
Args:
69+
path: Path to the file to upload.
70+
entity_id: The ID of the entity to attach the file to.
71+
entity_type: The type of entity (e.g., "runs", "annotations", "annotation_logs").
72+
metadata: Optional metadata for the file (e.g., video/image metadata).
73+
description: Optional description of the file.
74+
organization_id: Optional organization ID.
75+
76+
Returns:
77+
The remote file ID of the uploaded file.
78+
79+
Raises:
80+
ValueError: If the path doesn't point to a regular file or MIME type cannot be determined.
81+
Exception: If the upload fails.
82+
"""
83+
posix_path = Path(path) if isinstance(path, str) else path
84+
85+
if not posix_path.is_file():
86+
raise ValueError(f"Provided path, '{path}', does not point to a regular file.")
87+
88+
file_name, mimetype, content_encoding = self._mime_and_content_type_from_path(posix_path)
89+
90+
if not mimetype:
91+
raise ValueError(f"The MIME-type of '{posix_path}' could not be computed.")
92+
93+
# Run the synchronous file upload in a thread pool to avoid blocking the event loop
94+
loop = asyncio.get_event_loop()
95+
return await loop.run_in_executor(
96+
None,
97+
self._upload_file_sync,
98+
posix_path,
99+
file_name,
100+
mimetype,
101+
content_encoding,
102+
entity_id,
103+
entity_type,
104+
metadata,
105+
description,
106+
organization_id,
107+
)
108+
109+
def _upload_file_sync(
110+
self,
111+
path: Path,
112+
file_name: str,
113+
mimetype: str,
114+
content_encoding: str | None,
115+
entity_id: str,
116+
entity_type: str,
117+
metadata: dict[str, Any] | None,
118+
description: str | None,
119+
organization_id: str | None,
120+
) -> str:
121+
"""Synchronous helper to upload the file.
122+
123+
This is called from a thread pool to avoid blocking the async event loop.
124+
"""
125+
with open(path, "rb") as file:
126+
form_fields: dict[str, Any] = {
127+
"entityId": entity_id,
128+
"entityType": entity_type,
129+
}
130+
131+
if content_encoding:
132+
form_fields["file"] = (
133+
file_name,
134+
file,
135+
mimetype,
136+
{
137+
"Content-Encoding": content_encoding,
138+
},
139+
)
140+
else:
141+
form_fields["file"] = (file_name, file, mimetype)
142+
143+
if metadata:
144+
import json
145+
146+
form_fields["metadata"] = json.dumps(
147+
metadata, default=lambda x: x.as_json() if hasattr(x, "as_json") else x
148+
)
149+
150+
if organization_id:
151+
form_fields["organizationId"] = organization_id
152+
153+
if description:
154+
form_fields["description"] = description
155+
156+
form_data = MultipartEncoder(fields=form_fields)
157+
158+
# Use the RestClient to make the POST request
159+
response = self._rest_client.post(
160+
endpoint=self.UPLOAD_PATH,
161+
data=form_data, # type: ignore
162+
headers={
163+
"Content-Type": form_data.content_type,
164+
},
165+
)
166+
167+
if response.status_code != 200:
168+
raise Exception(
169+
f"Request failed with status code {response.status_code} ({response.reason})."
170+
)
171+
172+
response_data = response.json()
173+
return response_data.get("remoteFile", {}).get("remoteFileId")
174+
175+
@staticmethod
176+
def _mime_and_content_type_from_path(path: Path) -> tuple[str, str | None, str | None]:
177+
"""Determine the MIME type and content encoding from a file path.
178+
179+
Args:
180+
path: The file path to analyze.
181+
182+
Returns:
183+
A tuple of (file_name, mime_type, content_encoding).
184+
"""
185+
import mimetypes
186+
187+
file_name = path.name
188+
mime, encoding = mimetypes.guess_type(path)
189+
return file_name, mime, encoding

0 commit comments

Comments
 (0)