Skip to content

Commit 9ef3450

Browse files
committed
added tar filter for upload_from_dir
1 parent e0a1725 commit 9ef3450

5 files changed

Lines changed: 55 additions & 138 deletions

File tree

src/runloop_api_client/lib/context_loader.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
import io
44
import tarfile
5-
from typing import Iterable, Optional, Sequence
5+
from typing import Callable, Iterable, Optional, Sequence
66
from pathlib import Path
77

88
from ._ignore import IgnoreMatcher, IgnorePattern, DockerIgnoreMatcher, iter_included_files
99

10+
TarFilter = Callable[[tarfile.TarInfo], tarfile.TarInfo | None]
11+
1012

1113
def build_docker_context_tar(
1214
context_root: Path,
@@ -35,6 +37,28 @@ def build_docker_context_tar(
3537
return buf.getvalue()
3638

3739

40+
def build_directory_tar(
41+
root: Path,
42+
*,
43+
tar_filter: TarFilter | None = None,
44+
) -> bytes:
45+
"""Create a .tar.gz archive containing all files under ``root``.
46+
47+
No ignore semantics are applied by default; callers may pass a tar filter
48+
compatible with :meth:`tarfile.TarFile.add` to modify or exclude members.
49+
"""
50+
51+
root = root.resolve()
52+
buf = io.BytesIO()
53+
with tarfile.open(mode="w:gz", fileobj=buf) as tf:
54+
for file_path in root.rglob("*"):
55+
if not file_path.is_file():
56+
continue
57+
rel = file_path.relative_to(root)
58+
tf.add(file_path, arcname=rel.as_posix(), filter=tar_filter)
59+
return buf.getvalue()
60+
61+
3862
def _iter_build_context_files(
3963
context_root: Path,
4064
*,

src/runloop_api_client/sdk/async_.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,10 @@
2424
from .._types import Timeout, NotGiven, not_given
2525
from .._client import DEFAULT_MAX_RETRIES, AsyncRunloop
2626
from ._helpers import detect_content_type
27-
from ..lib._ignore import IgnoreMatcher
2827
from .async_devbox import AsyncDevbox
2928
from .async_snapshot import AsyncSnapshot
3029
from .async_blueprint import AsyncBlueprint
31-
from ..lib.context_loader import build_docker_context_tar
30+
from ..lib.context_loader import TarFilter, build_directory_tar
3231
from .async_storage_object import AsyncStorageObject
3332
from ..types.object_create_params import ContentType
3433

@@ -376,7 +375,7 @@ async def upload_from_dir(
376375
name: Optional[str] = None,
377376
metadata: Optional[Dict[str, str]] = None,
378377
ttl: Optional[timedelta] = None,
379-
ignore: IgnoreMatcher | None = None,
378+
ignore: TarFilter | None = None,
380379
**options: Unpack[LongRequestOptions],
381380
) -> AsyncStorageObject:
382381
"""Create and upload an object from a local directory.
@@ -391,11 +390,10 @@ async def upload_from_dir(
391390
:type metadata: Optional[Dict[str, str]]
392391
:param ttl: Optional Time-To-Live, after which the object is automatically deleted
393392
:type ttl: Optional[timedelta]
394-
:param ignore: Optional ignore matcher. When provided it controls which
395-
files under ``dir_path`` are included in the archived build
396-
context. When omitted, a default Docker-style matcher that honors
397-
``.dockerignore`` under ``dir_path`` is used.
398-
:type ignore: Optional[IgnoreMatcher]
393+
:param ignore: Optional tar filter function compatible with
394+
:meth:`tarfile.TarFile.add`. If provided, it will be called for each
395+
member to allow modification or exclusion (by returning ``None``).
396+
:type ignore: Optional[TarFilter]
399397
:param options: See :typeddict:`~runloop_api_client.sdk._types.LongRequestOptions`
400398
for available options
401399
:return: Wrapper for the uploaded object
@@ -410,7 +408,7 @@ async def upload_from_dir(
410408
ttl_ms = int(ttl.total_seconds()) * 1000 if ttl else None
411409

412410
def synchronous_io() -> bytes:
413-
return build_docker_context_tar(path, ignore=ignore)
411+
return build_directory_tar(path, tar_filter=ignore)
414412

415413
tar_bytes = await asyncio.to_thread(synchronous_io)
416414

src/runloop_api_client/sdk/sync.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,8 @@
2626
from ._helpers import detect_content_type
2727
from .snapshot import Snapshot
2828
from .blueprint import Blueprint
29-
from ..lib._ignore import IgnoreMatcher
3029
from .storage_object import StorageObject
31-
from ..lib.context_loader import build_docker_context_tar
30+
from ..lib.context_loader import TarFilter, build_directory_tar
3231
from ..types.object_create_params import ContentType
3332

3433

@@ -375,7 +374,7 @@ def upload_from_dir(
375374
name: Optional[str] = None,
376375
metadata: Optional[Dict[str, str]] = None,
377376
ttl: Optional[timedelta] = None,
378-
ignore: IgnoreMatcher | None = None,
377+
ignore: TarFilter | None = None,
379378
**options: Unpack[LongRequestOptions],
380379
) -> StorageObject:
381380
"""Create and upload an object from a local directory.
@@ -390,11 +389,10 @@ def upload_from_dir(
390389
:type metadata: Optional[Dict[str, str]]
391390
:param ttl: Optional Time-To-Live, after which the object is automatically deleted
392391
:type ttl: Optional[timedelta]
393-
:param ignore: Optional ignore matcher. When provided it controls which
394-
files under ``dir_path`` are included in the archived build
395-
context. When omitted, a default Docker-style matcher that honors
396-
``.dockerignore`` under ``dir_path`` is used.
397-
:type ignore: Optional[IgnoreMatcher]
392+
:param ignore: Optional tar filter function compatible with
393+
:meth:`tarfile.TarFile.add`. If provided, it will be called for each
394+
member to allow modification or exclusion (by returning ``None``).
395+
:type ignore: Optional[TarFilter]
398396
:param options: See :typeddict:`~runloop_api_client.sdk._types.LongRequestOptions`
399397
for available options
400398
:return: Wrapper for the uploaded object
@@ -408,7 +406,7 @@ def upload_from_dir(
408406
name = name or f"{path.name}.tar.gz"
409407
ttl_ms = int(ttl.total_seconds()) * 1000 if ttl else None
410408

411-
tar_bytes = build_docker_context_tar(path, ignore=ignore)
409+
tar_bytes = build_directory_tar(path, tar_filter=ignore)
412410

413411
obj = self.create(name=name, content_type="tgz", metadata=metadata, ttl_ms=ttl_ms, **options)
414412
obj.upload_content(tar_bytes)

tests/sdk/test_async_clients.py

Lines changed: 8 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
AsyncBlueprintOps,
2626
AsyncStorageObjectOps,
2727
)
28-
from runloop_api_client.lib._ignore import DockerIgnoreMatcher
2928
from runloop_api_client.lib.polling import PollingConfig
3029

3130

@@ -403,43 +402,6 @@ async def test_upload_from_dir(
403402

404403
mock_async_client.objects.complete.assert_awaited_once()
405404

406-
@pytest.mark.asyncio
407-
async def test_upload_from_dir_respects_dockerignore(
408-
self, mock_async_client: AsyncMock, object_view: MockObjectView, tmp_path: Path
409-
) -> None:
410-
"""upload_from_dir should respect .dockerignore patterns by default."""
411-
mock_async_client.objects.create = AsyncMock(return_value=object_view)
412-
mock_async_client.objects.complete = AsyncMock(return_value=object_view)
413-
414-
test_dir = tmp_path / "ctx"
415-
test_dir.mkdir()
416-
(test_dir / "keep.txt").write_text("keep", encoding="utf-8")
417-
(test_dir / "ignore.log").write_text("ignore", encoding="utf-8")
418-
build_dir = test_dir / "build"
419-
build_dir.mkdir()
420-
(build_dir / "ignored.txt").write_text("ignored", encoding="utf-8")
421-
422-
dockerignore = test_dir / ".dockerignore"
423-
dockerignore.write_text("*.log\nbuild/\n", encoding="utf-8")
424-
425-
http_client = AsyncMock()
426-
mock_response = create_mock_httpx_response()
427-
http_client.put = AsyncMock(return_value=mock_response)
428-
mock_async_client._client = http_client
429-
430-
client = AsyncStorageObjectOps(mock_async_client)
431-
obj = await client.upload_from_dir(test_dir)
432-
433-
assert isinstance(obj, AsyncStorageObject)
434-
uploaded_content = http_client.put.call_args[1]["content"]
435-
436-
with tarfile.open(fileobj=io.BytesIO(uploaded_content), mode="r:gz") as tar:
437-
names = {m.name for m in tar.getmembers()}
438-
439-
assert "keep.txt" in names
440-
assert "ignore.log" not in names
441-
assert not any(name.startswith("build/") for name in names)
442-
443405
@pytest.mark.asyncio
444406
async def test_upload_from_dir_with_inline_ignore_patterns(
445407
self, mock_async_client: AsyncMock, object_view: MockObjectView, tmp_path: Path
@@ -462,8 +424,14 @@ async def test_upload_from_dir_with_inline_ignore_patterns(
462424
mock_async_client._client = http_client
463425

464426
client = AsyncStorageObjectOps(mock_async_client)
465-
matcher = DockerIgnoreMatcher(patterns=["*.log", "build/"])
466-
obj = await client.upload_from_dir(test_dir, ignore=matcher)
427+
428+
# Tar filter: drop logs and anything under build/
429+
def ignore_logs_and_build(ti: tarfile.TarInfo) -> tarfile.TarInfo | None:
430+
if ti.name.endswith(".log") or ti.name.startswith("build/"):
431+
return None
432+
return ti
433+
434+
obj = await client.upload_from_dir(test_dir, ignore=ignore_logs_and_build)
467435

468436
assert isinstance(obj, AsyncStorageObject)
469437
uploaded_content = http_client.put.call_args[1]["content"]

tests/sdk/test_clients.py

Lines changed: 8 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
BlueprintOps,
2626
StorageObjectOps,
2727
)
28-
from runloop_api_client.lib._ignore import DockerIgnoreMatcher
2928
from runloop_api_client.lib.polling import PollingConfig
3029

3130

@@ -483,10 +482,10 @@ def test_upload_from_dir_with_string_path(
483482
http_client.put.assert_called_once()
484483
mock_client.objects.complete.assert_called_once()
485484

486-
def test_upload_from_dir_respects_dockerignore(
485+
def test_upload_from_dir_respects_filter(
487486
self, mock_client: Mock, object_view: MockObjectView, tmp_path: Path
488487
) -> None:
489-
"""upload_from_dir should respect .dockerignore patterns by default."""
488+
"""upload_from_dir should respect a tar filter when provided."""
490489
mock_client.objects.create.return_value = object_view
491490

492491
test_dir = tmp_path / "ctx"
@@ -497,90 +496,20 @@ def test_upload_from_dir_respects_dockerignore(
497496
build_dir.mkdir()
498497
(build_dir / "ignored.txt").write_text("ignored", encoding="utf-8")
499498

500-
dockerignore = test_dir / ".dockerignore"
501-
dockerignore.write_text("*.log\nbuild/\n", encoding="utf-8")
502-
503499
http_client = Mock()
504500
mock_response = create_mock_httpx_response()
505501
http_client.put.return_value = mock_response
506502
mock_client._client = http_client
507503

508504
client = StorageObjectOps(mock_client)
509-
obj = client.upload_from_dir(test_dir)
510-
511-
assert isinstance(obj, StorageObject)
512-
http_client.put.assert_called_once()
513-
uploaded_content = http_client.put.call_args[1]["content"]
514-
515-
with tarfile.open(fileobj=io.BytesIO(uploaded_content), mode="r:gz") as tar:
516-
names = {m.name for m in tar.getmembers()}
517-
518-
assert "keep.txt" in names
519-
assert "ignore.log" not in names
520-
assert not any(name.startswith("build/") for name in names)
521505

522-
def test_upload_from_dir_with_extra_ignore_file(
523-
self, mock_client: Mock, object_view: MockObjectView, tmp_path: Path
524-
) -> None:
525-
"""upload_from_dir should merge .dockerignore and an extra ignore file."""
526-
mock_client.objects.create.return_value = object_view
527-
528-
test_dir = tmp_path / "ctx"
529-
test_dir.mkdir()
530-
(test_dir / "keep.txt").write_text("keep", encoding="utf-8")
531-
(test_dir / "ignore.log").write_text("ignore", encoding="utf-8")
532-
build_dir = test_dir / "build"
533-
build_dir.mkdir()
534-
(build_dir / "ignored.txt").write_text("ignored", encoding="utf-8")
535-
536-
# Only ignore logs in .dockerignore
537-
dockerignore = test_dir / ".dockerignore"
538-
dockerignore.write_text("*.log\n", encoding="utf-8")
539-
540-
extra_ignore = tmp_path / "extra.ignore"
541-
extra_ignore.write_text("build/\n", encoding="utf-8")
542-
543-
http_client = Mock()
544-
mock_response = create_mock_httpx_response()
545-
http_client.put.return_value = mock_response
546-
mock_client._client = http_client
506+
# Tar filter: drop logs and anything under build/
507+
def ignore_logs_and_build(ti: tarfile.TarInfo) -> tarfile.TarInfo | None:
508+
if ti.name.endswith(".log") or ti.name.startswith("build/"):
509+
return None
510+
return ti
547511

548-
client = StorageObjectOps(mock_client)
549-
matcher = DockerIgnoreMatcher(extra_ignorefile=extra_ignore)
550-
obj = client.upload_from_dir(test_dir, ignore=matcher)
551-
552-
assert isinstance(obj, StorageObject)
553-
uploaded_content = http_client.put.call_args[1]["content"]
554-
555-
with tarfile.open(fileobj=io.BytesIO(uploaded_content), mode="r:gz") as tar:
556-
names = {m.name for m in tar.getmembers()}
557-
558-
assert "keep.txt" in names
559-
assert "ignore.log" not in names
560-
assert not any(name.startswith("build/") for name in names)
561-
562-
def test_upload_from_dir_with_inline_ignore_patterns(
563-
self, mock_client: Mock, object_view: MockObjectView, tmp_path: Path
564-
) -> None:
565-
"""upload_from_dir should respect inline ignore patterns."""
566-
mock_client.objects.create.return_value = object_view
567-
568-
test_dir = tmp_path / "ctx"
569-
test_dir.mkdir()
570-
(test_dir / "keep.txt").write_text("keep", encoding="utf-8")
571-
(test_dir / "ignore.log").write_text("ignore", encoding="utf-8")
572-
build_dir = test_dir / "build"
573-
build_dir.mkdir()
574-
(build_dir / "ignored.txt").write_text("ignored", encoding="utf-8")
575-
576-
http_client = Mock()
577-
mock_response = create_mock_httpx_response()
578-
http_client.put.return_value = mock_response
579-
mock_client._client = http_client
580-
581-
client = StorageObjectOps(mock_client)
582-
matcher = DockerIgnoreMatcher(patterns=["*.log", "build/"])
583-
obj = client.upload_from_dir(test_dir, ignore=matcher)
512+
obj = client.upload_from_dir(test_dir, ignore=ignore_logs_and_build)
584513

585514
assert isinstance(obj, StorageObject)
586515
uploaded_content = http_client.put.call_args[1]["content"]

0 commit comments

Comments
 (0)