Skip to content

Commit 7c65baa

Browse files
committed
added context loader helper
1 parent e8d1053 commit 7c65baa

1 file changed

Lines changed: 145 additions & 0 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import io
2+
import tarfile
3+
from typing import Iterable, Optional
4+
from pathlib import Path, PurePosixPath
5+
6+
## This file has helper methods to get a docker context tarball from a given context root.
7+
8+
9+
def _load_dockerignore_patterns(
10+
dockerignore_path: Optional[Path],
11+
) -> list[tuple[bool, str]]:
12+
"""Parse .dockerignore contents into a list of (is_negated, pattern).
13+
14+
Notes:
15+
- Empty lines and comments are ignored.
16+
- Lines starting with '!' are negation patterns.
17+
"""
18+
if dockerignore_path is None or not dockerignore_path.exists():
19+
return []
20+
21+
patterns: list[tuple[bool, str]] = []
22+
for raw_line in dockerignore_path.read_text(encoding="utf-8").splitlines():
23+
line = raw_line.strip()
24+
if not line or line.startswith("#"):
25+
continue
26+
27+
is_negated = line.startswith("!")
28+
if is_negated:
29+
line = line[1:].strip()
30+
if not line:
31+
continue
32+
33+
patterns.append((is_negated, line))
34+
35+
return patterns
36+
37+
38+
def _match_dockerignore_pattern(relpath: str, pattern: str) -> bool:
39+
"""Return True if relpath matches a single .dockerignore pattern.
40+
41+
This is a small, pragmatic approximation of Docker's matching rules:
42+
- Patterns ending with '/' are treated as directory-only.
43+
- Patterns without '/' match basenames anywhere in the tree.
44+
- Other patterns match against the full relative path.
45+
"""
46+
from fnmatch import fnmatch
47+
48+
relpath_posix = PurePosixPath(relpath).as_posix()
49+
50+
directory_only = pattern.endswith("/")
51+
if directory_only:
52+
pattern = pattern.rstrip("/")
53+
54+
if "/" not in pattern:
55+
# Match against basename anywhere in the tree
56+
name = PurePosixPath(relpath_posix).name
57+
matched = fnmatch(name, pattern)
58+
else:
59+
# Match against the full relative path
60+
matched = fnmatch(relpath_posix, pattern)
61+
62+
if directory_only:
63+
# Directory-only pattern matches the directory itself or anything under it
64+
return matched and (relpath_posix == pattern or relpath_posix.startswith(f"{pattern}/"))
65+
66+
return matched
67+
68+
69+
def _is_ignored(relpath: str, patterns: list[tuple[bool, str]]) -> bool:
70+
"""Apply .dockerignore patterns with 'last match wins' semantics."""
71+
72+
included = True # include by default
73+
for is_negated, pat in patterns:
74+
if _match_dockerignore_pattern(relpath, pat):
75+
# Negated patterns flip back to included, normal patterns exclude.
76+
included = is_negated
77+
return not included
78+
79+
80+
def _iter_build_context_files(
81+
context_root: Path,
82+
*,
83+
dockerignore_path: Optional[Path] = None,
84+
) -> Iterable[Path]:
85+
"""Yield files to include in the build context, honoring .dockerignore.
86+
87+
This hand-rolls .dockerignore parsing and matching instead of relying on the
88+
Docker SDK to avoid pulling in the docker Python dependency.
89+
It approximates Docker's behavior.
90+
"""
91+
if not context_root.is_dir():
92+
raise ValueError(f"context_root must be a directory, got: {context_root}")
93+
94+
if dockerignore_path is None:
95+
candidate = context_root / ".dockerignore"
96+
dockerignore_path = candidate if candidate.exists() else None
97+
98+
patterns = _load_dockerignore_patterns(dockerignore_path)
99+
100+
# Walk the tree and apply ignore rules. We mirror the "include by default"
101+
# behavior and apply patterns in order, with last match winning.
102+
for path in context_root.rglob("*"):
103+
if path.is_dir():
104+
# Docker's context is file-based; directories are implicit.
105+
continue
106+
107+
rel = path.relative_to(context_root).as_posix()
108+
109+
if _is_ignored(rel, patterns):
110+
continue
111+
112+
yield path
113+
114+
115+
def build_docker_context_tar(
116+
context_root: Path,
117+
*,
118+
dockerignore: Optional[Path] = None,
119+
) -> bytes:
120+
"""Create a .tar.gz of the Docker build context, respecting .dockerignore.
121+
122+
- Treats ``context_root`` as the Docker build context root.
123+
- Determines the .dockerignore path as:
124+
* explicit ``dockerignore`` argument if provided
125+
* otherwise ``context_root / \".dockerignore\"`` if it exists
126+
"""
127+
context_root = context_root.resolve()
128+
129+
# Resolve dockerignore path according to the requested behavior
130+
if dockerignore is not None:
131+
dockerignore_path = dockerignore.resolve()
132+
else:
133+
dockerignore_path = context_root / ".dockerignore"
134+
135+
buf = io.BytesIO()
136+
137+
with tarfile.open(mode="w:gz", fileobj=buf) as tf:
138+
for path in _iter_build_context_files(
139+
context_root,
140+
dockerignore_path=dockerignore_path if dockerignore_path.exists() else None,
141+
):
142+
rel = path.relative_to(context_root)
143+
tf.add(path, arcname=rel.as_posix())
144+
145+
return buf.getvalue()

0 commit comments

Comments
 (0)