Skip to content

Commit be5d99e

Browse files
authored
feat(storage-objects): Add upload_from_dir helper method (#680)
* 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. * add tests
1 parent c3776b6 commit be5d99e

8 files changed

Lines changed: 462 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+
*,
372+
name: Optional[str] = None,
373+
metadata: Optional[Dict[str, str]] = None,
374+
ttl: Optional[timedelta] = 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: Optional[str]
385+
:param metadata: Optional key-value metadata
386+
:type metadata: Optional[Dict[str, str]]
387+
:param ttl: Optional Time-To-Live, after which the object is automatically deleted
388+
:type ttl: Optional[timedelta]
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() -> bytes:
399+
with io.BytesIO() as tar_buffer:
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.read()
404+
405+
tar_bytes = 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_bytes)
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+
*,
371+
name: Optional[str] = None,
372+
metadata: Optional[Dict[str, str]] = None,
373+
ttl: Optional[timedelta] = 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: Optional[str]
384+
:param metadata: Optional key-value metadata
385+
:type metadata: Optional[Dict[str, str]]
386+
:param ttl: Optional Time-To-Live, after which the object is automatically deleted
387+
:type ttl: Optional[timedelta]
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,

tests/sdk/test_async_clients.py

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

33
from __future__ import annotations
44

5+
import io
6+
import tarfile
57
from types import SimpleNamespace
68
from pathlib import Path
79
from unittest.mock import AsyncMock
@@ -327,6 +329,161 @@ async def test_upload_from_file_missing_path(self, mock_async_client: AsyncMock,
327329
with pytest.raises(OSError, match="Failed to read file"):
328330
await client.upload_from_file(missing_file)
329331

332+
@pytest.mark.asyncio
333+
async def test_upload_from_dir(
334+
self, mock_async_client: AsyncMock, object_view: MockObjectView, tmp_path: Path
335+
) -> None:
336+
"""Test upload_from_dir method."""
337+
mock_async_client.objects.create = AsyncMock(return_value=object_view)
338+
mock_async_client.objects.complete = AsyncMock(return_value=object_view)
339+
340+
# Create a temporary directory with some files
341+
test_dir = tmp_path / "test_directory"
342+
test_dir.mkdir()
343+
(test_dir / "file1.txt").write_text("content1")
344+
(test_dir / "file2.txt").write_text("content2")
345+
subdir = test_dir / "subdir"
346+
subdir.mkdir()
347+
(subdir / "file3.txt").write_text("content3")
348+
349+
http_client = AsyncMock()
350+
mock_response = create_mock_httpx_response()
351+
http_client.put = AsyncMock(return_value=mock_response)
352+
mock_async_client._client = http_client
353+
354+
client = AsyncStorageObjectOps(mock_async_client)
355+
obj = await client.upload_from_dir(test_dir, name="archive.tar.gz", metadata={"key": "value"})
356+
357+
assert isinstance(obj, AsyncStorageObject)
358+
assert obj.id == "obj_123"
359+
mock_async_client.objects.create.assert_awaited_once_with(
360+
name="archive.tar.gz",
361+
content_type="tgz",
362+
metadata={"key": "value"},
363+
ttl_ms=None,
364+
)
365+
# Verify that put was called with tarball content
366+
http_client.put.assert_awaited_once()
367+
call_args = http_client.put.call_args
368+
assert call_args[0][0] == object_view.upload_url
369+
370+
# Verify it's a valid gzipped tarball
371+
uploaded_content = call_args[1]["content"]
372+
with tarfile.open(fileobj=io.BytesIO(uploaded_content), mode="r:gz") as tar:
373+
members = tar.getmembers()
374+
member_names = [m.name for m in members]
375+
# Should contain our test files (may include directory entries)
376+
assert any("file1.txt" in name for name in member_names)
377+
assert any("file2.txt" in name for name in member_names)
378+
assert any("file3.txt" in name for name in member_names)
379+
380+
mock_async_client.objects.complete.assert_awaited_once()
381+
382+
@pytest.mark.asyncio
383+
async def test_upload_from_dir_default_name(
384+
self, mock_async_client: AsyncMock, object_view: MockObjectView, tmp_path: Path
385+
) -> None:
386+
"""Test upload_from_dir uses directory name by default."""
387+
mock_async_client.objects.create = AsyncMock(return_value=object_view)
388+
mock_async_client.objects.complete = AsyncMock(return_value=object_view)
389+
390+
test_dir = tmp_path / "my_folder"
391+
test_dir.mkdir()
392+
(test_dir / "file.txt").write_text("content")
393+
394+
http_client = AsyncMock()
395+
mock_response = create_mock_httpx_response()
396+
http_client.put = AsyncMock(return_value=mock_response)
397+
mock_async_client._client = http_client
398+
399+
client = AsyncStorageObjectOps(mock_async_client)
400+
obj = await client.upload_from_dir(test_dir)
401+
402+
assert isinstance(obj, AsyncStorageObject)
403+
# Name should be directory name + .tar.gz
404+
mock_async_client.objects.create.assert_awaited_once()
405+
call_args = mock_async_client.objects.create.call_args
406+
assert call_args[1]["name"] == "my_folder.tar.gz"
407+
assert call_args[1]["content_type"] == "tgz"
408+
409+
@pytest.mark.asyncio
410+
async def test_upload_from_dir_with_ttl(
411+
self, mock_async_client: AsyncMock, object_view: MockObjectView, tmp_path: Path
412+
) -> None:
413+
"""Test upload_from_dir with TTL."""
414+
from datetime import timedelta
415+
416+
mock_async_client.objects.create = AsyncMock(return_value=object_view)
417+
mock_async_client.objects.complete = AsyncMock(return_value=object_view)
418+
419+
test_dir = tmp_path / "temp_dir"
420+
test_dir.mkdir()
421+
(test_dir / "file.txt").write_text("temporary content")
422+
423+
http_client = AsyncMock()
424+
mock_response = create_mock_httpx_response()
425+
http_client.put = AsyncMock(return_value=mock_response)
426+
mock_async_client._client = http_client
427+
428+
client = AsyncStorageObjectOps(mock_async_client)
429+
obj = await client.upload_from_dir(test_dir, ttl=timedelta(hours=2))
430+
431+
assert isinstance(obj, AsyncStorageObject)
432+
mock_async_client.objects.create.assert_awaited_once()
433+
call_args = mock_async_client.objects.create.call_args
434+
# 2 hours = 7200 seconds = 7200000 milliseconds
435+
assert call_args[1]["ttl_ms"] == 7200000
436+
437+
@pytest.mark.asyncio
438+
async def test_upload_from_dir_empty_directory(
439+
self, mock_async_client: AsyncMock, object_view: MockObjectView, tmp_path: Path
440+
) -> None:
441+
"""Test upload_from_dir with empty directory."""
442+
mock_async_client.objects.create = AsyncMock(return_value=object_view)
443+
mock_async_client.objects.complete = AsyncMock(return_value=object_view)
444+
445+
test_dir = tmp_path / "empty_dir"
446+
test_dir.mkdir()
447+
448+
http_client = AsyncMock()
449+
mock_response = create_mock_httpx_response()
450+
http_client.put = AsyncMock(return_value=mock_response)
451+
mock_async_client._client = http_client
452+
453+
client = AsyncStorageObjectOps(mock_async_client)
454+
obj = await client.upload_from_dir(test_dir)
455+
456+
assert isinstance(obj, AsyncStorageObject)
457+
assert obj.id == "obj_123"
458+
mock_async_client.objects.create.assert_awaited_once()
459+
http_client.put.assert_awaited_once()
460+
mock_async_client.objects.complete.assert_awaited_once()
461+
462+
@pytest.mark.asyncio
463+
async def test_upload_from_dir_with_string_path(
464+
self, mock_async_client: AsyncMock, object_view: MockObjectView, tmp_path: Path
465+
) -> None:
466+
"""Test upload_from_dir with string path instead of Path object."""
467+
mock_async_client.objects.create = AsyncMock(return_value=object_view)
468+
mock_async_client.objects.complete = AsyncMock(return_value=object_view)
469+
470+
test_dir = tmp_path / "string_path_dir"
471+
test_dir.mkdir()
472+
(test_dir / "file.txt").write_text("content")
473+
474+
http_client = AsyncMock()
475+
mock_response = create_mock_httpx_response()
476+
http_client.put = AsyncMock(return_value=mock_response)
477+
mock_async_client._client = http_client
478+
479+
client = AsyncStorageObjectOps(mock_async_client)
480+
# Pass string path instead of Path object
481+
obj = await client.upload_from_dir(str(test_dir))
482+
483+
assert isinstance(obj, AsyncStorageObject)
484+
assert obj.id == "obj_123"
485+
mock_async_client.objects.create.assert_awaited_once()
486+
330487

331488
class TestAsyncRunloopSDK:
332489
"""Tests for AsyncRunloopSDK class."""

0 commit comments

Comments
 (0)