Skip to content

Commit 70fbe38

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 7603442 commit 70fbe38

4 files changed

Lines changed: 96 additions & 3 deletions

File tree

src/runloop_api_client/sdk/async_.py

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

33
from __future__ import annotations
44

5+
import io
6+
import asyncio
7+
import tarfile
58
from typing import Dict, Mapping, Optional
69
from pathlib import Path
10+
from datetime import timedelta
711
from typing_extensions import Unpack
812

913
import httpx
@@ -350,7 +354,7 @@ async def upload_from_file(
350354
path = Path(file_path)
351355

352356
try:
353-
content = path.read_bytes()
357+
content = await asyncio.to_thread(lambda: path.read_bytes())
354358
except OSError as error:
355359
raise OSError(f"Failed to read file {path}: {error}") from error
356360

@@ -361,6 +365,50 @@ async def upload_from_file(
361365
await obj.complete()
362366
return obj
363367

368+
async def upload_from_dir(
369+
self,
370+
dir_path: str | Path,
371+
name: str | None = None,
372+
*,
373+
metadata: Optional[Dict[str, str]] = None,
374+
ttl: timedelta | None = None,
375+
**options: Unpack[LongRequestOptions],
376+
) -> AsyncStorageObject:
377+
"""Create and upload an object from a local directory.
378+
379+
The resulting object will be uploaded as a compressed tarball.
380+
381+
:param dir_path: Local filesystem directory path to tar
382+
:type dir_path: str | Path
383+
:param name: Optional object name; defaults to the directory name + '.tar.gz'
384+
:type name: str | None, optional
385+
:param metadata: Optional key-value metadata
386+
:type metadata: Optional[Dict[str, str]], optional
387+
:param ttl: Optional Time-To-Live, after which the object is automatically deleted
388+
:type ttl: timedelta | None, optional
389+
:param options: See :typeddict:`~runloop_api_client.sdk._types.LongRequestOptions` for available options
390+
:return: Wrapper for the uploaded object
391+
:rtype: AsyncStorageObject
392+
:raises OSError: If the local file cannot be read
393+
"""
394+
path = Path(dir_path)
395+
name = name or f"{path.name}.tar.gz"
396+
ttl_ms = int(ttl.total_seconds()) * 1000 if ttl else None
397+
398+
def synchronous_io() -> io.BytesIO:
399+
tar_buffer = io.BytesIO()
400+
with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar:
401+
tar.add(path, arcname=".", recursive=True)
402+
tar_buffer.seek(0)
403+
return tar_buffer
404+
405+
tar_buffer = await asyncio.to_thread(synchronous_io)
406+
407+
obj = await self.create(name=name, content_type="tgz", metadata=metadata, ttl_ms=ttl_ms, **options)
408+
await obj.upload_content(tar_buffer)
409+
await obj.complete()
410+
return obj
411+
364412
async def upload_from_text(
365413
self,
366414
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
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from typing import Iterable
56
from typing_extensions import Unpack, override
67

78
from ._types import RequestOptions, LongRequestOptions, SDKObjectDownloadParams
@@ -146,7 +147,7 @@ async def delete(
146147
**options,
147148
)
148149

149-
async def upload_content(self, content: str | bytes) -> None:
150+
async def upload_content(self, content: str | bytes | Iterable[bytes]) -> None:
150151
"""Upload content to the object's pre-signed URL.
151152
152153
:param content: Bytes or text payload to upload

src/runloop_api_client/sdk/storage_object.py

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

33
from __future__ import annotations
44

5+
from typing import Iterable
56
from typing_extensions import Unpack, override
67

78
from ._types import RequestOptions, LongRequestOptions, SDKObjectDownloadParams
@@ -146,7 +147,7 @@ def delete(
146147
**options,
147148
)
148149

149-
def upload_content(self, content: str | bytes) -> None:
150+
def upload_content(self, content: str | bytes | Iterable[bytes]) -> None:
150151
"""Upload content to the object's pre-signed URL.
151152
152153
:param content: Bytes or text payload to upload

src/runloop_api_client/sdk/sync.py

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

33
from __future__ import annotations
44

5+
import io
6+
import tarfile
57
from typing import Dict, Mapping, Optional
68
from pathlib import Path
9+
from datetime import timedelta
710
from typing_extensions import Unpack
811

912
import httpx
@@ -361,6 +364,46 @@ def upload_from_file(
361364
obj.complete()
362365
return obj
363366

367+
def upload_from_dir(
368+
self,
369+
dir_path: str | Path,
370+
name: str | None = None,
371+
*,
372+
metadata: Optional[Dict[str, str]] = None,
373+
ttl: timedelta | None = None,
374+
**options: Unpack[LongRequestOptions],
375+
) -> StorageObject:
376+
"""Create and upload an object from a local directory.
377+
378+
The resulting object will be uploaded as a compressed tarball.
379+
380+
:param dir_path: Local filesystem directory path to tar
381+
:type dir_path: str | Path
382+
:param name: Optional object name; defaults to the directory name + '.tar.gz'
383+
:type name: str | None, optional
384+
:param metadata: Optional key-value metadata
385+
:type metadata: Optional[Dict[str, str]], optional
386+
:param ttl: Optional Time-To-Live, after which the object is automatically deleted
387+
:type ttl: timedelta | None, optional
388+
:param options: See :typeddict:`~runloop_api_client.sdk._types.LongRequestOptions` for available options
389+
:return: Wrapper for the uploaded object
390+
:rtype: StorageObject
391+
:raises OSError: If the local file cannot be read
392+
"""
393+
path = Path(dir_path)
394+
name = name or f"{path.name}.tar.gz"
395+
ttl_ms = int(ttl.total_seconds()) * 1000 if ttl else None
396+
397+
tar_buffer = io.BytesIO()
398+
with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar:
399+
tar.add(path, arcname=".", recursive=True)
400+
tar_buffer.seek(0)
401+
402+
obj = self.create(name=name, content_type="tgz", metadata=metadata, ttl_ms=ttl_ms, **options)
403+
obj.upload_content(tar_buffer)
404+
obj.complete()
405+
return obj
406+
364407
def upload_from_text(
365408
self,
366409
text: str,

0 commit comments

Comments
 (0)