Skip to content

Commit 57ee98e

Browse files
authored
feat!(storage-objects): name is now a keyword-only parameter to StorageObject upload helper methods, and ttl can be passed in as a parameter (#687)
* change 'name' from positional to named parameter, added 'ttl' parameter and cleaned up docs for object upload helper methods * modified object tests to pass 'name' as named parameter * upgrade runloop-api-client package version in uv lock * updated unit test expected call args
1 parent 52644b5 commit 57ee98e

7 files changed

Lines changed: 182 additions & 94 deletions

File tree

src/runloop_api_client/sdk/async_.py

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -330,22 +330,25 @@ async def list(
330330
async def upload_from_file(
331331
self,
332332
file_path: str | Path,
333-
name: str | None = None,
334333
*,
335-
content_type: ContentType | None = None,
334+
name: Optional[str] = None,
335+
content_type: Optional[ContentType] = None,
336336
metadata: Optional[Dict[str, str]] = None,
337+
ttl: Optional[timedelta] = None,
337338
**options: Unpack[LongRequestOptions],
338339
) -> AsyncStorageObject:
339340
"""Create and upload an object from a local file path.
340341
341342
:param file_path: Local filesystem path to read
342343
:type file_path: str | Path
343-
:param name: Optional object name; defaults to the file name, defaults to None
344-
:type name: str | None, optional
345-
:param content_type: Optional MIME type to apply to the object, defaults to None
346-
:type content_type: ContentType | None, optional
347-
:param metadata: Optional key-value metadata, defaults to None
348-
:type metadata: Optional[Dict[str, str]], optional
344+
:param name: Optional object name; defaults to the file name
345+
:type name: Optional[str]
346+
:param content_type: Optional MIME type to apply to the object
347+
:type content_type: Optional[ContentType]
348+
:param metadata: Optional key-value metadata
349+
:type metadata: Optional[Dict[str, str]]
350+
:param ttl: Optional Time-To-Live, after which the object is automatically deleted
351+
:type ttl: Optional[timedelta]
349352
:param options: See :typeddict:`~runloop_api_client.sdk._types.LongRequestOptions` for available options
350353
:return: Wrapper for the uploaded object
351354
:rtype: AsyncStorageObject
@@ -360,7 +363,8 @@ async def upload_from_file(
360363

361364
name = name or path.name
362365
content_type = content_type or detect_content_type(str(file_path))
363-
obj = await self.create(name=name, content_type=content_type, metadata=metadata, **options)
366+
ttl_ms = int(ttl.total_seconds()) * 1000 if ttl else None
367+
obj = await self.create(name=name, content_type=content_type, metadata=metadata, ttl_ms=ttl_ms, **options)
364368
await obj.upload_content(content)
365369
await obj.complete()
366370
return obj
@@ -412,9 +416,10 @@ def synchronous_io() -> bytes:
412416
async def upload_from_text(
413417
self,
414418
text: str,
415-
name: str,
416419
*,
420+
name: str,
417421
metadata: Optional[Dict[str, str]] = None,
422+
ttl: Optional[timedelta] = None,
418423
**options: Unpack[LongRequestOptions],
419424
) -> AsyncStorageObject:
420425
"""Create and upload an object from a text payload.
@@ -423,24 +428,28 @@ async def upload_from_text(
423428
:type text: str
424429
:param name: Object display name
425430
:type name: str
426-
:param metadata: Optional key-value metadata, defaults to None
427-
:type metadata: Optional[Dict[str, str]], optional
431+
:param metadata: Optional key-value metadata
432+
:type metadata: Optional[Dict[str, str]]
433+
:param ttl: Optional Time-To-Live, after which the object is automatically deleted
434+
:type ttl: Optional[timedelta]
428435
:param options: See :typeddict:`~runloop_api_client.sdk._types.LongRequestOptions` for available options
429436
:return: Wrapper for the uploaded object
430437
:rtype: AsyncStorageObject
431438
"""
432-
obj = await self.create(name=name, content_type="text", metadata=metadata, **options)
439+
ttl_ms = int(ttl.total_seconds()) * 1000 if ttl else None
440+
obj = await self.create(name=name, content_type="text", metadata=metadata, ttl_ms=ttl_ms, **options)
433441
await obj.upload_content(text)
434442
await obj.complete()
435443
return obj
436444

437445
async def upload_from_bytes(
438446
self,
439447
data: bytes,
440-
name: str,
441448
*,
449+
name: str,
442450
content_type: ContentType,
443451
metadata: Optional[Dict[str, str]] = None,
452+
ttl: Optional[timedelta] = None,
444453
**options: Unpack[LongRequestOptions],
445454
) -> AsyncStorageObject:
446455
"""Create and upload an object from a bytes payload.
@@ -451,13 +460,16 @@ async def upload_from_bytes(
451460
:type name: str
452461
:param content_type: MIME type describing the payload
453462
:type content_type: ContentType
454-
:param metadata: Optional key-value metadata, defaults to None
455-
:type metadata: Optional[Dict[str, str]], optional
463+
:param metadata: Optional key-value metadata
464+
:type metadata: Optional[Dict[str, str]]
465+
:param ttl: Optional Time-To-Live, after which the object is automatically deleted
466+
:type ttl: Optional[timedelta]
456467
:param options: See :typeddict:`~runloop_api_client.sdk._types.LongRequestOptions` for available options
457468
:return: Wrapper for the uploaded object
458469
:rtype: AsyncStorageObject
459470
"""
460-
obj = await self.create(name=name, content_type=content_type, metadata=metadata, **options)
471+
ttl_ms = int(ttl.total_seconds()) * 1000 if ttl else None
472+
obj = await self.create(name=name, content_type=content_type, metadata=metadata, ttl_ms=ttl_ms, **options)
461473
await obj.upload_content(data)
462474
await obj.complete()
463475
return obj

src/runloop_api_client/sdk/sync.py

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -329,22 +329,25 @@ def list(
329329
def upload_from_file(
330330
self,
331331
file_path: str | Path,
332-
name: str | None = None,
333332
*,
334-
content_type: ContentType | None = None,
333+
name: Optional[str] = None,
334+
content_type: Optional[ContentType] = None,
335335
metadata: Optional[Dict[str, str]] = None,
336+
ttl: Optional[timedelta] = None,
336337
**options: Unpack[LongRequestOptions],
337338
) -> StorageObject:
338339
"""Create and upload an object from a local file path.
339340
340341
:param file_path: Local filesystem path to read
341342
:type file_path: str | Path
342-
:param name: Optional object name; defaults to the file name, defaults to None
343-
:type name: str | None, optional
344-
:param content_type: Optional MIME type to apply to the object, defaults to None
345-
:type content_type: ContentType | None, optional
346-
:param metadata: Optional key-value metadata, defaults to None
347-
:type metadata: Optional[Dict[str, str]], optional
343+
:param name: Optional object name; defaults to the file name
344+
:type name: Optional[str]
345+
:param content_type: Optional MIME type to apply to the object
346+
:type content_type: Optional[ContentType]
347+
:param metadata: Optional key-value metadata
348+
:type metadata: Optional[Dict[str, str]]
349+
:param ttl: Optional Time-To-Live, after which the object is automatically deleted
350+
:type ttl: Optional[timedelta]
348351
:param options: See :typeddict:`~runloop_api_client.sdk._types.LongRequestOptions` for available options
349352
:return: Wrapper for the uploaded object
350353
:rtype: StorageObject
@@ -359,7 +362,8 @@ def upload_from_file(
359362

360363
name = name or path.name
361364
content_type = content_type or detect_content_type(str(file_path))
362-
obj = self.create(name=name, content_type=content_type, metadata=metadata, **options)
365+
ttl_ms = int(ttl.total_seconds()) * 1000 if ttl else None
366+
obj = self.create(name=name, content_type=content_type, metadata=metadata, ttl_ms=ttl_ms, **options)
363367
obj.upload_content(content)
364368
obj.complete()
365369
return obj
@@ -407,9 +411,10 @@ def upload_from_dir(
407411
def upload_from_text(
408412
self,
409413
text: str,
410-
name: str,
411414
*,
415+
name: str,
412416
metadata: Optional[Dict[str, str]] = None,
417+
ttl: Optional[timedelta] = None,
413418
**options: Unpack[LongRequestOptions],
414419
) -> StorageObject:
415420
"""Create and upload an object from a text payload.
@@ -418,24 +423,28 @@ def upload_from_text(
418423
:type text: str
419424
:param name: Object display name
420425
:type name: str
421-
:param metadata: Optional key-value metadata, defaults to None
422-
:type metadata: Optional[Dict[str, str]], optional
426+
:param metadata: Optional key-value metadata
427+
:type metadata: Optional[Dict[str, str]]
428+
:param ttl: Optional Time-To-Live, after which the object is automatically deleted
429+
:type ttl: Optional[timedelta]
423430
:param options: See :typeddict:`~runloop_api_client.sdk._types.LongRequestOptions` for available options
424431
:return: Wrapper for the uploaded object
425432
:rtype: StorageObject
426433
"""
427-
obj = self.create(name=name, content_type="text", metadata=metadata, **options)
434+
ttl_ms = int(ttl.total_seconds()) * 1000 if ttl else None
435+
obj = self.create(name=name, content_type="text", metadata=metadata, ttl_ms=ttl_ms, **options)
428436
obj.upload_content(text)
429437
obj.complete()
430438
return obj
431439

432440
def upload_from_bytes(
433441
self,
434442
data: bytes,
435-
name: str,
436443
*,
444+
name: str,
437445
content_type: ContentType,
438446
metadata: Optional[Dict[str, str]] = None,
447+
ttl: Optional[timedelta] = None,
439448
**options: Unpack[LongRequestOptions],
440449
) -> StorageObject:
441450
"""Create and upload an object from a bytes payload.
@@ -446,13 +455,16 @@ def upload_from_bytes(
446455
:type name: str
447456
:param content_type: MIME type describing the payload
448457
:type content_type: ContentType
449-
:param metadata: Optional key-value metadata, defaults to None
450-
:type metadata: Optional[Dict[str, str]], optional
458+
:param metadata: Optional key-value metadata
459+
:type metadata: Optional[Dict[str, str]]
460+
:param ttl: Optional Time-To-Live, after which the object is automatically deleted
461+
:type ttl: Optional[timedelta]
451462
:param options: See :typeddict:`~runloop_api_client.sdk._types.LongRequestOptions` for available options
452463
:return: Wrapper for the uploaded object
453464
:rtype: StorageObject
454465
"""
455-
obj = self.create(name=name, content_type=content_type, metadata=metadata, **options)
466+
ttl_ms = int(ttl.total_seconds()) * 1000 if ttl else None
467+
obj = self.create(name=name, content_type=content_type, metadata=metadata, ttl_ms=ttl_ms, **options)
456468
obj.upload_content(data)
457469
obj.complete()
458470
return obj

tests/sdk/test_async_clients.py

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -239,13 +239,20 @@ async def test_list(self, mock_async_client: AsyncMock, object_view: MockObjectV
239239
name="test",
240240
search="query",
241241
starting_after="obj_000",
242-
state="ready",
242+
state="READ_ONLY",
243243
)
244244

245245
assert len(objects) == 1
246246
assert isinstance(objects[0], AsyncStorageObject)
247247
assert objects[0].id == "obj_123"
248-
mock_async_client.objects.list.assert_awaited_once()
248+
mock_async_client.objects.list.assert_awaited_once_with(
249+
content_type="text",
250+
limit=10,
251+
name="test",
252+
search="query",
253+
starting_after="obj_000",
254+
state="READ_ONLY",
255+
)
249256

250257
@pytest.mark.asyncio
251258
async def test_upload_from_file(
@@ -268,9 +275,14 @@ async def test_upload_from_file(
268275

269276
assert isinstance(obj, AsyncStorageObject)
270277
assert obj.id == "obj_123"
271-
mock_async_client.objects.create.assert_awaited_once()
272-
mock_async_client.objects.complete.assert_awaited_once()
278+
mock_async_client.objects.create.assert_awaited_once_with(
279+
name="test.txt",
280+
content_type="text",
281+
metadata=None,
282+
ttl_ms=None,
283+
)
273284
http_client.put.assert_awaited_once_with(object_view.upload_url, content=b"test content")
285+
mock_async_client.objects.complete.assert_awaited_once()
274286

275287
@pytest.mark.asyncio
276288
async def test_upload_from_text(self, mock_async_client: AsyncMock, object_view: MockObjectView) -> None:
@@ -284,14 +296,15 @@ async def test_upload_from_text(self, mock_async_client: AsyncMock, object_view:
284296
mock_async_client._client = http_client
285297

286298
client = AsyncStorageObjectOps(mock_async_client)
287-
obj = await client.upload_from_text("test content", "test.txt", metadata={"key": "value"})
299+
obj = await client.upload_from_text("test content", name="test.txt", metadata={"key": "value"})
288300

289301
assert isinstance(obj, AsyncStorageObject)
290302
assert obj.id == "obj_123"
291303
mock_async_client.objects.create.assert_awaited_once_with(
292304
name="test.txt",
293305
content_type="text",
294306
metadata={"key": "value"},
307+
ttl_ms=None,
295308
)
296309
http_client.put.assert_awaited_once_with(object_view.upload_url, content="test content")
297310
mock_async_client.objects.complete.assert_awaited_once()
@@ -308,14 +321,15 @@ async def test_upload_from_bytes(self, mock_async_client: AsyncMock, object_view
308321
mock_async_client._client = http_client
309322

310323
client = AsyncStorageObjectOps(mock_async_client)
311-
obj = await client.upload_from_bytes(b"test content", "test.bin", content_type="binary")
324+
obj = await client.upload_from_bytes(b"test content", name="test.bin", content_type="binary")
312325

313326
assert isinstance(obj, AsyncStorageObject)
314327
assert obj.id == "obj_123"
315328
mock_async_client.objects.create.assert_awaited_once_with(
316329
name="test.bin",
317330
content_type="binary",
318331
metadata=None,
332+
ttl_ms=None,
319333
)
320334
http_client.put.assert_awaited_once_with(object_view.upload_url, content=b"test content")
321335
mock_async_client.objects.complete.assert_awaited_once()
@@ -401,10 +415,12 @@ async def test_upload_from_dir_default_name(
401415

402416
assert isinstance(obj, AsyncStorageObject)
403417
# 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"
418+
mock_async_client.objects.create.assert_awaited_once_with(
419+
name="my_folder.tar.gz",
420+
content_type="tgz",
421+
metadata=None,
422+
ttl_ms=None,
423+
)
408424

409425
@pytest.mark.asyncio
410426
async def test_upload_from_dir_with_ttl(
@@ -429,10 +445,12 @@ async def test_upload_from_dir_with_ttl(
429445
obj = await client.upload_from_dir(test_dir, ttl=timedelta(hours=2))
430446

431447
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
448+
mock_async_client.objects.create.assert_awaited_once_with(
449+
name="temp_dir.tar.gz",
450+
content_type="tgz",
451+
metadata=None,
452+
ttl_ms=7200000, # 2 hours = 7200 seconds = 7200000 milliseconds
453+
)
436454

437455
@pytest.mark.asyncio
438456
async def test_upload_from_dir_empty_directory(
@@ -455,7 +473,12 @@ async def test_upload_from_dir_empty_directory(
455473

456474
assert isinstance(obj, AsyncStorageObject)
457475
assert obj.id == "obj_123"
458-
mock_async_client.objects.create.assert_awaited_once()
476+
mock_async_client.objects.create.assert_awaited_once_with(
477+
name="empty_dir.tar.gz",
478+
content_type="tgz",
479+
metadata=None,
480+
ttl_ms=None,
481+
)
459482
http_client.put.assert_awaited_once()
460483
mock_async_client.objects.complete.assert_awaited_once()
461484

@@ -482,7 +505,14 @@ async def test_upload_from_dir_with_string_path(
482505

483506
assert isinstance(obj, AsyncStorageObject)
484507
assert obj.id == "obj_123"
485-
mock_async_client.objects.create.assert_awaited_once()
508+
mock_async_client.objects.create.assert_awaited_once_with(
509+
name="string_path_dir.tar.gz",
510+
content_type="tgz",
511+
metadata=None,
512+
ttl_ms=None,
513+
)
514+
http_client.put.assert_awaited_once()
515+
mock_async_client.objects.complete.assert_awaited_once()
486516

487517

488518
class TestAsyncRunloopSDK:

0 commit comments

Comments
 (0)