Skip to content

Commit e0a1725

Browse files
committed
improved typing and made the ignore matching less docker-specific
1 parent 81dba83 commit e0a1725

9 files changed

Lines changed: 99 additions & 54 deletions

File tree

src/runloop_api_client/lib/_ignore.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
from __future__ import annotations
22

33
import os
4+
from abc import ABC, abstractmethod
45
from typing import Iterable, Optional, Sequence
56
from pathlib import Path, PurePosixPath
67
from dataclasses import dataclass
78

89
__all__ = [
910
"IgnorePattern",
11+
"IgnoreMatcher",
12+
"DockerIgnoreMatcher",
1013
"read_ignorefile",
1114
"compile_ignore",
1215
"path_match",
@@ -281,3 +284,61 @@ def iter_included_files(
281284
if is_ignored(rel_file, is_dir=False, patterns=patterns):
282285
continue
283286
yield file_path
287+
288+
289+
class IgnoreMatcher(ABC):
290+
"""Abstract interface for ignore matchers like .dockerignore and .gitignore.
291+
292+
There is considerable variation for each ignore file format, so this interface
293+
provides a minimal contract for supporting each format. Implementations are
294+
responsible for interpreting any underlying ignore configuration (files, inline
295+
patterns, etc.) and returning all files that should be included under a given
296+
root directory.
297+
"""
298+
299+
@abstractmethod
300+
def iter_paths(self, root: Path) -> Iterable[Path]:
301+
"""Yield filesystem paths to include under ``root``."""
302+
303+
304+
@dataclass(frozen=True)
305+
class DockerIgnoreMatcher(IgnoreMatcher):
306+
"""Ignore matcher that mirrors Docker's .dockerignore semantics.
307+
308+
This matcher:
309+
- Closely follows Docker's .dockerignore semantics.
310+
- Always loads patterns from ``.dockerignore`` in the provided context
311+
root, if present.
312+
- Optionally loads additional patterns from an extra ignorefile.
313+
- Optionally appends inline pattern strings.
314+
315+
Note: Patterns follow Docker-style semantics (``!`` negation, ``**`` support).
316+
"""
317+
318+
extra_ignorefile: str | Path | None = None
319+
patterns: Sequence[str] | None = None
320+
321+
def iter_paths(self, root: Path) -> Iterable[Path]:
322+
"""Yield non-ignored files under ``root`` honoring Docker-style patterns."""
323+
324+
root = root.resolve()
325+
326+
all_patterns: list[str] = []
327+
328+
# 1) Always consider .dockerignore under the context root, if present.
329+
default_ignorefile = root / ".dockerignore"
330+
all_patterns.extend(read_ignorefile(default_ignorefile))
331+
332+
# 2) Optional additional ignorefile.
333+
if self.extra_ignorefile is not None:
334+
ignore_path = Path(self.extra_ignorefile)
335+
if not ignore_path.exists():
336+
raise FileNotFoundError(f"Ignore file does not exist: {ignore_path}")
337+
all_patterns.extend(read_ignorefile(ignore_path))
338+
339+
# 3) Optional inline patterns appended last.
340+
if self.patterns:
341+
all_patterns.extend(self.patterns)
342+
343+
compiled: list[IgnorePattern] = compile_ignore(all_patterns)
344+
return iter_included_files(root, patterns=compiled)

src/runloop_api_client/lib/context_loader.py

Lines changed: 7 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,54 +5,30 @@
55
from typing import Iterable, Optional, Sequence
66
from pathlib import Path
77

8-
from ._ignore import IgnorePattern, compile_ignore, read_ignorefile, iter_included_files
8+
from ._ignore import IgnoreMatcher, IgnorePattern, DockerIgnoreMatcher, iter_included_files
99

1010

1111
def build_docker_context_tar(
1212
context_root: Path,
1313
*,
14-
ignore: Optional[Sequence[str] | Path | str] = None,
14+
ignore: Optional[IgnoreMatcher] = None,
1515
) -> bytes:
16-
"""Create a .tar.gz of the build context, honoring ignore patterns.
16+
"""Create a .tar.gz of the build context, honoring Docker-style ignore patterns.
1717
1818
- Treats ``context_root`` as the build context root.
1919
- Always loads ``.dockerignore`` under ``context_root`` if present.
20-
- An optional ``ignore`` argument may be provided:
21-
22-
* If a :class:`pathlib.Path` or string is given, it is treated as an
23-
additional ignorefile path whose patterns are appended after
24-
``.dockerignore``.
25-
* If a sequence of strings is given, they are treated as inline patterns
26-
appended after any file-derived patterns.
27-
28-
Patterns use Docker-style semantics with ``!`` negation and ``**`` support.
20+
- An optional :class:`IgnoreMatcher` may be provided to customise how ignore
21+
patterns are resolved; when omitted, :class:`DockerIgnoreMatcher` is used.
2922
"""
3023

3124
context_root = context_root.resolve()
3225

33-
all_patterns: list[str] = []
34-
35-
# 1) Always consider .dockerignore under the context root, if present.
36-
default_ignorefile = context_root / ".dockerignore"
37-
all_patterns.extend(read_ignorefile(default_ignorefile))
38-
39-
# 2) Optional additional ignore source
40-
if ignore is not None:
41-
if isinstance(ignore, (str, Path)):
42-
ignore_path = Path(ignore)
43-
if not ignore_path.exists():
44-
raise FileNotFoundError(f"Ignore file does not exist: {ignore_path}")
45-
all_patterns.extend(read_ignorefile(ignore_path))
46-
else:
47-
# Treat as a sequence of raw patterns
48-
all_patterns.extend(list(ignore))
49-
50-
compiled: list[IgnorePattern] = compile_ignore(all_patterns)
26+
matcher: IgnoreMatcher = ignore or DockerIgnoreMatcher()
5127

5228
buf = io.BytesIO()
5329

5430
with tarfile.open(mode="w:gz", fileobj=buf) as tf:
55-
for path in _iter_build_context_files(context_root, patterns=compiled):
31+
for path in matcher.iter_paths(context_root):
5632
rel = path.relative_to(context_root)
5733
tf.add(path, arcname=rel.as_posix())
5834

src/runloop_api_client/sdk/_build_context.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from dataclasses import dataclass
1414
from typing_extensions import Protocol
1515

16+
from ..lib._ignore import IgnoreMatcher
1617
from ..lib.context_loader import build_docker_context_tar
1718
from ..types.object_create_params import ContentType
1819

@@ -47,7 +48,7 @@ def __call__( # pragma: no cover - interface only
4748
context_root: Path,
4849
*,
4950
name: str | None = None,
50-
ignore: str | Path | tuple[str, ...] | list[str] | None = None,
51+
ignore: IgnoreMatcher | None = None,
5152
) -> BuildContextArtifact:
5253
"""Package the given directory into a tarball.
5354
@@ -67,7 +68,7 @@ def default_build_context_strategy(
6768
context_root: Path,
6869
*,
6970
name: str | None = None,
70-
ignore: str | Path | tuple[str, ...] | list[str] | None = None,
71+
ignore: IgnoreMatcher | None = None,
7172
) -> BuildContextArtifact:
7273
"""Default implementation that wraps ``build_docker_context_tar``.
7374

src/runloop_api_client/sdk/async_.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
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
2728
from .async_devbox import AsyncDevbox
2829
from .async_snapshot import AsyncSnapshot
2930
from .async_blueprint import AsyncBlueprint
@@ -375,7 +376,7 @@ async def upload_from_dir(
375376
name: Optional[str] = None,
376377
metadata: Optional[Dict[str, str]] = None,
377378
ttl: Optional[timedelta] = None,
378-
ignore: str | Path | Sequence[str] | None = None,
379+
ignore: IgnoreMatcher | None = None,
379380
**options: Unpack[LongRequestOptions],
380381
) -> AsyncStorageObject:
381382
"""Create and upload an object from a local directory.
@@ -390,12 +391,11 @@ async def upload_from_dir(
390391
:type metadata: Optional[Dict[str, str]]
391392
:param ttl: Optional Time-To-Live, after which the object is automatically deleted
392393
:type ttl: Optional[timedelta]
393-
:param ignore: Optional ignore configuration. If a string or :class:`Path`
394-
is provided it is treated as the path to an additional ignorefile.
395-
If a sequence of strings is provided, they are interpreted as inline
396-
ignore patterns appended after patterns loaded from
397-
``.dockerignore`` under ``dir_path``.
398-
:type ignore: Optional[str | Path | Sequence[str]]
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]
399399
:param options: See :typeddict:`~runloop_api_client.sdk._types.LongRequestOptions`
400400
for available options
401401
:return: Wrapper for the uploaded object

src/runloop_api_client/sdk/async_storage_object.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from ._types import BaseRequestOptions, LongRequestOptions, SDKObjectDownloadParams
99
from .._client import AsyncRunloop
1010
from ..types.object_view import ObjectView
11+
from ..types.blueprint_create_params import BuildContext
1112
from ..types.object_download_url_view import ObjectDownloadURLView
1213

1314

@@ -159,10 +160,10 @@ async def upload_content(self, content: str | bytes | Iterable[bytes]) -> None:
159160
response = await self._client._client.put(url, content=content)
160161
response.raise_for_status()
161162

162-
def as_build_context(self) -> dict[str, str]:
163+
def as_build_context(self) -> BuildContext:
163164
"""Return this object in the shape expected for a Blueprint build context.
164165
165-
The returned dict can be passed directly to ``build_context`` or
166+
The returned mapping can be passed directly to ``build_context`` or
166167
``named_build_contexts`` when creating a blueprint.
167168
"""
168169
return {

src/runloop_api_client/sdk/storage_object.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from ._types import BaseRequestOptions, LongRequestOptions, SDKObjectDownloadParams
99
from .._client import Runloop
1010
from ..types.object_view import ObjectView
11+
from ..types.blueprint_create_params import BuildContext
1112
from ..types.object_download_url_view import ObjectDownloadURLView
1213

1314

@@ -159,10 +160,10 @@ def upload_content(self, content: str | bytes | Iterable[bytes]) -> None:
159160
response = self._client._client.put(url, content=content)
160161
response.raise_for_status()
161162

162-
def as_build_context(self) -> dict[str, str]:
163+
def as_build_context(self) -> BuildContext:
163164
"""Return this object in the shape expected for a Blueprint build context.
164165
165-
The returned dict can be passed directly to ``build_context`` or
166+
The returned mapping can be passed directly to ``build_context`` or
166167
``named_build_contexts`` when creating a blueprint.
167168
"""
168169
return {

src/runloop_api_client/sdk/sync.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from ._helpers import detect_content_type
2727
from .snapshot import Snapshot
2828
from .blueprint import Blueprint
29+
from ..lib._ignore import IgnoreMatcher
2930
from .storage_object import StorageObject
3031
from ..lib.context_loader import build_docker_context_tar
3132
from ..types.object_create_params import ContentType
@@ -374,7 +375,7 @@ def upload_from_dir(
374375
name: Optional[str] = None,
375376
metadata: Optional[Dict[str, str]] = None,
376377
ttl: Optional[timedelta] = None,
377-
ignore: str | Path | Sequence[str] | None = None,
378+
ignore: IgnoreMatcher | None = None,
378379
**options: Unpack[LongRequestOptions],
379380
) -> StorageObject:
380381
"""Create and upload an object from a local directory.
@@ -389,12 +390,11 @@ def upload_from_dir(
389390
:type metadata: Optional[Dict[str, str]]
390391
:param ttl: Optional Time-To-Live, after which the object is automatically deleted
391392
:type ttl: Optional[timedelta]
392-
:param ignore: Optional ignore configuration. If a string or :class:`pathlib.Path`
393-
is provided it is treated as the path to an additional ignorefile.
394-
If a sequence of strings is provided, they are interpreted as inline
395-
ignore patterns appended after patterns loaded from
396-
``.dockerignore`` under ``dir_path``.
397-
:type ignore: Optional[str | Path | Sequence[str]]
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]
398398
:param options: See :typeddict:`~runloop_api_client.sdk._types.LongRequestOptions`
399399
for available options
400400
:return: Wrapper for the uploaded object

tests/sdk/test_async_clients.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
AsyncBlueprintOps,
2626
AsyncStorageObjectOps,
2727
)
28+
from runloop_api_client.lib._ignore import DockerIgnoreMatcher
2829
from runloop_api_client.lib.polling import PollingConfig
2930

3031

@@ -461,7 +462,8 @@ async def test_upload_from_dir_with_inline_ignore_patterns(
461462
mock_async_client._client = http_client
462463

463464
client = AsyncStorageObjectOps(mock_async_client)
464-
obj = await client.upload_from_dir(test_dir, ignore=["*.log", "build/"])
465+
matcher = DockerIgnoreMatcher(patterns=["*.log", "build/"])
466+
obj = await client.upload_from_dir(test_dir, ignore=matcher)
465467

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

tests/sdk/test_clients.py

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

3031

@@ -545,7 +546,8 @@ def test_upload_from_dir_with_extra_ignore_file(
545546
mock_client._client = http_client
546547

547548
client = StorageObjectOps(mock_client)
548-
obj = client.upload_from_dir(test_dir, ignore=extra_ignore)
549+
matcher = DockerIgnoreMatcher(extra_ignorefile=extra_ignore)
550+
obj = client.upload_from_dir(test_dir, ignore=matcher)
549551

550552
assert isinstance(obj, StorageObject)
551553
uploaded_content = http_client.put.call_args[1]["content"]
@@ -577,7 +579,8 @@ def test_upload_from_dir_with_inline_ignore_patterns(
577579
mock_client._client = http_client
578580

579581
client = StorageObjectOps(mock_client)
580-
obj = client.upload_from_dir(test_dir, ignore=["*.log", "build/"])
582+
matcher = DockerIgnoreMatcher(patterns=["*.log", "build/"])
583+
obj = client.upload_from_dir(test_dir, ignore=matcher)
581584

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

0 commit comments

Comments
 (0)