|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | import os |
| 4 | +import tarfile |
4 | 5 | from abc import ABC, abstractmethod |
5 | | -from typing import Iterable, Optional, Sequence |
| 6 | +from typing import Callable, Iterable, Optional, Sequence |
6 | 7 | from pathlib import Path, PurePosixPath |
7 | 8 | from dataclasses import dataclass |
8 | 9 |
|
9 | 10 | __all__ = [ |
10 | 11 | "IgnorePattern", |
11 | 12 | "IgnoreMatcher", |
12 | 13 | "DockerIgnoreMatcher", |
| 14 | + "FilePatternMatcher", |
| 15 | + "TarFilterMatcher", |
13 | 16 | "read_ignorefile", |
14 | 17 | "compile_ignore", |
15 | 18 | "path_match", |
16 | 19 | "is_ignored", |
| 20 | + "iter_included_files", |
17 | 21 | ] |
18 | 22 |
|
19 | 23 |
|
@@ -286,6 +290,74 @@ def iter_included_files( |
286 | 290 | yield file_path |
287 | 291 |
|
288 | 292 |
|
| 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 | + |
289 | 361 | class IgnoreMatcher(ABC): |
290 | 362 | """Abstract interface for ignore matchers like .dockerignore and .gitignore. |
291 | 363 |
|
@@ -342,3 +414,30 @@ def iter_paths(self, root: Path) -> Iterable[Path]: |
342 | 414 |
|
343 | 415 | compiled: list[IgnorePattern] = compile_ignore(all_patterns) |
344 | 416 | 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) |
0 commit comments