Skip to content

Commit bbc1f78

Browse files
committed
removed some dead code and standardized the ignore interface & made it a bit more ergonomic
1 parent d5621ab commit bbc1f78

8 files changed

Lines changed: 250 additions & 146 deletions

File tree

src/runloop_api_client/lib/_ignore.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
from __future__ import annotations
22

33
import os
4+
import tarfile
45
from abc import ABC, abstractmethod
5-
from typing import Iterable, Optional, Sequence
6+
from typing import Callable, Iterable, Optional, Sequence
67
from pathlib import Path, PurePosixPath
78
from dataclasses import dataclass
89

910
__all__ = [
1011
"IgnorePattern",
1112
"IgnoreMatcher",
1213
"DockerIgnoreMatcher",
14+
"FilePatternMatcher",
15+
"TarFilterMatcher",
1316
"read_ignorefile",
1417
"compile_ignore",
1518
"path_match",
1619
"is_ignored",
20+
"iter_included_files",
1721
]
1822

1923

@@ -286,6 +290,74 @@ def iter_included_files(
286290
yield file_path
287291

288292

293+
TarFilter = Callable[[tarfile.TarInfo], Optional[tarfile.TarInfo]]
294+
295+
296+
def _compute_included_dirs_from_files(included_files: set[str]) -> set[str]:
297+
"""Return all directory ancestors (plus ``'.'``) for a set of file paths."""
298+
299+
included_dirs: set[str] = {"."}
300+
for rel in included_files:
301+
parent = PurePosixPath(rel).parent
302+
while True:
303+
as_posix = parent.as_posix() or "."
304+
included_dirs.add(as_posix)
305+
if as_posix == ".":
306+
break
307+
parent = parent.parent
308+
return included_dirs
309+
310+
311+
class TarFilterMatcher:
312+
"""Adapt an :class:`IgnoreMatcher` to a :class:`TarFilter`-compatible callable.
313+
314+
This helper precomputes the set of included files under ``root`` using the
315+
provided :class:`IgnoreMatcher` and converts that into a simple tar filter:
316+
317+
- Only files returned by ``matcher.iter_paths(root)`` are included.
318+
- Directory entries are included only when they are ancestors of at least
319+
one included file (plus the root ``'.'`` entry).
320+
321+
Member names passed to ``__call__`` are expected to be relative to
322+
``root`` and to use POSIX ``'/'`` separators, matching the behaviour of
323+
``build_directory_tar`` in :mod:`runloop_api_client.lib.context_loader`.
324+
"""
325+
326+
def __init__(self, root: Path, matcher: IgnoreMatcher) -> None:
327+
self._root = root.resolve()
328+
329+
# Compute the set of included files as relative POSIX paths.
330+
# Note: the majority of the work being performed here is simply to deal with the path to the root.
331+
included_files: set[str] = set()
332+
for path in matcher.iter_paths(self._root):
333+
rel = path.resolve().relative_to(self._root)
334+
rel_posix = PurePosixPath(rel).as_posix()
335+
included_files.add(rel_posix)
336+
337+
included_dirs = _compute_included_dirs_from_files(included_files)
338+
339+
self._included_files = included_files
340+
self._included_dirs = included_dirs
341+
342+
def __call__(self, ti: tarfile.TarInfo) -> Optional[tarfile.TarInfo]:
343+
name = ti.name
344+
345+
# The root of the archive is always kept.
346+
if name == ".":
347+
return ti
348+
349+
if ti.isdir():
350+
if name in self._included_dirs:
351+
return ti
352+
return None
353+
354+
# Non-directory entries (files, symlinks, etc.) are kept only if their
355+
# relative path is in the included file set.
356+
if name in self._included_files:
357+
return ti
358+
return None
359+
360+
289361
class IgnoreMatcher(ABC):
290362
"""Abstract interface for ignore matchers like .dockerignore and .gitignore.
291363
@@ -342,3 +414,30 @@ def iter_paths(self, root: Path) -> Iterable[Path]:
342414

343415
compiled: list[IgnorePattern] = compile_ignore(all_patterns)
344416
return iter_included_files(root, patterns=compiled)
417+
418+
419+
@dataclass(frozen=True)
420+
class FilePatternMatcher(IgnoreMatcher):
421+
"""Ignore matcher that applies only inline patterns, without .dockerignore.
422+
423+
Patterns follow the same semantics as :func:`compile_ignore` / Docker-style
424+
ignore files and are treated as *ignore* rules (``!`` negation for
425+
re-inclusion, ``**`` support, etc.).
426+
427+
The constructor accepts either a single pattern string or a sequence of
428+
pattern strings; a single string is automatically wrapped into a list.
429+
"""
430+
431+
patterns: Sequence[str] | str
432+
433+
def __post_init__(self) -> None:
434+
# Normalise a single pattern string into a list for downstream helpers.
435+
if isinstance(self.patterns, str):
436+
object.__setattr__(self, "patterns", [self.patterns])
437+
438+
def iter_paths(self, root: Path) -> Iterable[Path]:
439+
"""Yield non-ignored files under ``root`` based only on ``patterns``."""
440+
441+
root = root.resolve()
442+
compiled: list[IgnorePattern] = compile_ignore(self.patterns) # type: ignore[arg-type]
443+
return iter_included_files(root, patterns=compiled)

src/runloop_api_client/lib/context_loader.py

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,18 @@
22

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

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

1010
TarFilter = Callable[[tarfile.TarInfo], Optional[tarfile.TarInfo]]
1111

1212

1313
def build_docker_context_tar(
1414
context_root: Path,
1515
*,
16-
ignore: Optional[IgnoreMatcher] = None,
16+
ignore: IgnoreMatcher | Sequence[str] | None = None,
1717
) -> bytes:
1818
"""Create a .tar.gz of the build context, honoring Docker-style ignore patterns.
1919
@@ -25,7 +25,15 @@ def build_docker_context_tar(
2525

2626
context_root = context_root.resolve()
2727

28-
matcher: IgnoreMatcher = ignore or DockerIgnoreMatcher()
28+
if ignore is None:
29+
matcher: IgnoreMatcher = DockerIgnoreMatcher()
30+
elif isinstance(ignore, IgnoreMatcher):
31+
matcher = ignore
32+
else:
33+
# Treat sequences of pattern strings as additional inline patterns
34+
# appended after ``.dockerignore`` (if present), mirroring
35+
# :class:`DockerIgnoreMatcher` semantics.
36+
matcher = DockerIgnoreMatcher(patterns=list(ignore))
2937

3038
buf = io.BytesIO()
3139

@@ -50,20 +58,21 @@ def build_directory_tar(
5058

5159
root = root.resolve()
5260
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()
6061

62+
def _wrapped_filter(ti: tarfile.TarInfo) -> Optional[tarfile.TarInfo]:
63+
# Normalise member names so callers see paths relative to ``root``
64+
# without a leading ``./``, preserving existing TarFilter semantics and
65+
# archive layout. This applies to both files and directories.
66+
if ti.name.startswith("./"):
67+
ti.name = ti.name[2:]
6168

62-
def _iter_build_context_files(
63-
context_root: Path,
64-
*,
65-
patterns: Sequence[IgnorePattern],
66-
) -> Iterable[Path]:
67-
"""Yield files to include in the build context, honoring ignore patterns."""
69+
if tar_filter is not None:
70+
return tar_filter(ti)
71+
return ti
6872

69-
return iter_included_files(context_root, patterns=patterns)
73+
with tarfile.open(mode="w:gz", fileobj=buf) as tf:
74+
# Add the root directory recursively in one call, delegating member
75+
# handling to the wrapped filter above.
76+
tf.add(root, arcname=".", filter=_wrapped_filter)
77+
78+
return buf.getvalue()

src/runloop_api_client/sdk/_build_context.py

Lines changed: 0 additions & 91 deletions
This file was deleted.

src/runloop_api_client/sdk/async_.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6-
from typing import Dict, Mapping, Optional
6+
from typing import Dict, Mapping, Optional, Sequence
77
from pathlib import Path
88
from datetime import timedelta
99
from typing_extensions import Unpack
@@ -24,10 +24,11 @@
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, TarFilterMatcher, FilePatternMatcher
2728
from .async_devbox import AsyncDevbox
2829
from .async_snapshot import AsyncSnapshot
2930
from .async_blueprint import AsyncBlueprint
30-
from ..lib.context_loader import TarFilter, build_directory_tar
31+
from ..lib.context_loader import build_directory_tar
3132
from .async_storage_object import AsyncStorageObject
3233
from ..types.object_create_params import ContentType
3334

@@ -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: TarFilter | None = None,
379+
ignore: IgnoreMatcher | Sequence[str] | str | None = None,
379380
**options: Unpack[LongRequestOptions],
380381
) -> AsyncStorageObject:
381382
"""Create and upload an object from a local directory.
@@ -390,10 +391,17 @@ 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 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]
394+
:param ignore: Optional ignore configuration controlling which files from
395+
``dir_path`` are included in the uploaded tarball. This may be:
396+
397+
- An :class:`~runloop_api_client.lib._ignore.IgnoreMatcher`
398+
implementation such as :class:`~runloop_api_client.lib._ignore.DockerIgnoreMatcher`
399+
or :class:`~runloop_api_client.lib._ignore.FilePatternMatcher`.
400+
- A single pattern string.
401+
- A sequence of pattern strings.
402+
403+
Patterns follow Docker-style semantics (``!`` negation, ``**`` support).
404+
:type ignore: Optional[IgnoreMatcher | Sequence[str] | str]
397405
:param options: See :typeddict:`~runloop_api_client.sdk._types.LongRequestOptions`
398406
for available options
399407
:return: Wrapper for the uploaded object
@@ -408,7 +416,17 @@ async def upload_from_dir(
408416
ttl_ms = int(ttl.total_seconds()) * 1000 if ttl else None
409417

410418
def synchronous_io() -> bytes:
411-
return build_directory_tar(path, tar_filter=ignore)
419+
matcher: IgnoreMatcher | None
420+
if ignore is None:
421+
matcher = None
422+
elif isinstance(ignore, IgnoreMatcher):
423+
matcher = ignore
424+
else:
425+
matcher = FilePatternMatcher(ignore) # type: ignore[arg-type]
426+
427+
if matcher is None:
428+
return build_directory_tar(path)
429+
return build_directory_tar(path, tar_filter=TarFilterMatcher(path, matcher))
412430

413431
tar_bytes = await asyncio.to_thread(synchronous_io)
414432

0 commit comments

Comments
 (0)