Skip to content

Commit fb7dc38

Browse files
committed
feat(object-storage): Add upload_from_dir helper method
This method creates an object out of a local directory by bundling it into a compressed tarball and uploading it as an object.
1 parent e78e3eb commit fb7dc38

File tree

4 files changed

+96
-3
lines changed

4 files changed

+96
-3
lines changed

src/runloop_api_client/sdk/async_.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
from __future__ import annotations
44

5+
import asyncio
6+
from datetime import timedelta
7+
import io
8+
import tarfile
59
from typing import Dict, Mapping, Optional
610
from pathlib import Path
711
from typing_extensions import Unpack
@@ -371,7 +375,7 @@ async def upload_from_file(
371375
path = Path(file_path)
372376

373377
try:
374-
content = path.read_bytes()
378+
content = await asyncio.to_thread(lambda: path.read_bytes())
375379
except OSError as error:
376380
raise OSError(f"Failed to read file {path}: {error}") from error
377381

@@ -382,6 +386,50 @@ async def upload_from_file(
382386
await obj.complete()
383387
return obj
384388

389+
async def upload_from_dir(
390+
self,
391+
dir_path: str | Path,
392+
name: str | None = None,
393+
*,
394+
metadata: Optional[Dict[str, str]] = None,
395+
ttl: timedelta | None = None,
396+
**options: Unpack[LongRequestOptions],
397+
) -> AsyncStorageObject:
398+
"""Create and upload an object from a local directory.
399+
400+
The resulting object will be uploaded as a compressed tarball.
401+
402+
Args:
403+
dir_path: Local filesystem directory path to tar.
404+
name: Optional object name; defaults to the directory name + '.tar.gz'.
405+
metadata: Optional key-value metadata.
406+
ttl: Optional Time-To-Live, after which the object is automatically deleted.
407+
**options: Additional request configuration.
408+
409+
Returns:
410+
AsyncStorageObject: Wrapper for the uploaded object.
411+
412+
Raises:
413+
OSError: If any of the files in the directory could not be read.
414+
"""
415+
path = Path(dir_path)
416+
name = name or f"{path.name}.tar.gz"
417+
ttl_ms = int(ttl.total_seconds()) * 1000 if ttl else None
418+
419+
def synchronous_io() -> io.BytesIO:
420+
tar_buffer = io.BytesIO()
421+
with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar:
422+
tar.add(path, arcname=".", recursive=True)
423+
tar_buffer.seek(0)
424+
return tar_buffer
425+
426+
tar_buffer = await asyncio.to_thread(synchronous_io)
427+
428+
obj = await self.create(name=name, content_type="tgz", metadata=metadata, ttl_ms=ttl_ms, **options)
429+
await obj.upload_content(tar_buffer)
430+
await obj.complete()
431+
return obj
432+
385433
async def upload_from_text(
386434
self,
387435
text: str,

src/runloop_api_client/sdk/async_storage_object.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Storage object resource class for asynchronous operations."""
22

33
from __future__ import annotations
4+
from typing import Iterable
45

56
from typing_extensions import Unpack, override
67

@@ -156,7 +157,7 @@ async def delete(
156157
**options,
157158
)
158159

159-
async def upload_content(self, content: str | bytes) -> None:
160+
async def upload_content(self, content: str | bytes | Iterable[bytes]) -> None:
160161
"""Upload content to the object's pre-signed URL.
161162
162163
Args:

src/runloop_api_client/sdk/storage_object.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Storage object resource class for synchronous operations."""
22

33
from __future__ import annotations
4+
from typing import Iterable
45

56
from typing_extensions import Unpack, override
67

@@ -156,7 +157,7 @@ def delete(
156157
**options,
157158
)
158159

159-
def upload_content(self, content: str | bytes) -> None:
160+
def upload_content(self, content: str | bytes | Iterable[bytes]) -> None:
160161
"""Upload content to the object's pre-signed URL.
161162
162163
Args:

src/runloop_api_client/sdk/sync.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
from __future__ import annotations
44

5+
from datetime import timedelta
6+
import io
7+
import tarfile
58
from typing import Dict, Mapping, Optional
69
from pathlib import Path
710
from typing_extensions import Unpack
@@ -373,6 +376,46 @@ def upload_from_file(
373376
obj.complete()
374377
return obj
375378

379+
def upload_from_dir(
380+
self,
381+
dir_path: str | Path,
382+
name: str | None = None,
383+
*,
384+
metadata: Optional[Dict[str, str]] = None,
385+
ttl: timedelta | None = None,
386+
**options: Unpack[LongRequestOptions],
387+
) -> StorageObject:
388+
"""Create and upload an object from a local directory.
389+
390+
The resulting object will be uploaded as a compressed tarball.
391+
392+
Args:
393+
dir_path: Local filesystem directory path to tar.
394+
name: Optional object name; defaults to the directory name + '.tar.gz'.
395+
metadata: Optional key-value metadata.
396+
ttl: Optional Time-To-Live, after which the object is automatically deleted.
397+
**options: Additional request configuration.
398+
399+
Returns:
400+
StorageObject: Wrapper for the uploaded object.
401+
402+
Raises:
403+
OSError: If any of the files in the directory could not be read.
404+
"""
405+
path = Path(dir_path)
406+
name = name or f"{path.name}.tar.gz"
407+
ttl_ms = int(ttl.total_seconds()) * 1000 if ttl else None
408+
409+
tar_buffer = io.BytesIO()
410+
with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar:
411+
tar.add(path, arcname=".", recursive=True)
412+
tar_buffer.seek(0)
413+
414+
obj = self.create(name=name, content_type="tgz", metadata=metadata, ttl_ms=ttl_ms, **options)
415+
obj.upload_content(tar_buffer)
416+
obj.complete()
417+
return obj
418+
376419
def upload_from_text(
377420
self,
378421
text: str,

0 commit comments

Comments
 (0)