bladekit 4.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bladekit/__init__.py +219 -0
- bladekit/_app.py +3241 -0
- bladekit/_banner.py +640 -0
- bladekit/_batch.py +150 -0
- bladekit/_bucket.py +211 -0
- bladekit/_build.py +337 -0
- bladekit/_bundle.py +459 -0
- bladekit/_client.py +1152 -0
- bladekit/_cls.py +574 -0
- bladekit/_constants.py +121 -0
- bladekit/_cron.py +88 -0
- bladekit/_dict.py +174 -0
- bladekit/_env.py +98 -0
- bladekit/_image.py +577 -0
- bladekit/_loader.py +79 -0
- bladekit/_map.py +555 -0
- bladekit/_preempt.py +94 -0
- bladekit/_queue.py +137 -0
- bladekit/_render.py +683 -0
- bladekit/_runner_api.py +148 -0
- bladekit/_sandbox.py +1051 -0
- bladekit/_secret.py +52 -0
- bladekit/_serialize.py +88 -0
- bladekit/_serve.py +188 -0
- bladekit/_volume.py +232 -0
- bladekit/_webhook.py +90 -0
- bladekit/cli.py +2282 -0
- bladekit/config.py +181 -0
- bladekit/exceptions.py +544 -0
- bladekit-4.5.0.dist-info/METADATA +373 -0
- bladekit-4.5.0.dist-info/RECORD +33 -0
- bladekit-4.5.0.dist-info/WHEEL +4 -0
- bladekit-4.5.0.dist-info/entry_points.txt +3 -0
bladekit/_bundle.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""Project bundling: deterministic tar.gz of the project + sha256.
|
|
2
|
+
|
|
3
|
+
The bundle root is the directory of the entrypoint script. ``.forgeignore`` is
|
|
4
|
+
honored with ``.gitignore`` syntax and matching; ``.git``, ``.venv``,
|
|
5
|
+
``__pycache__`` and a few other always-noise paths are excluded unconditionally.
|
|
6
|
+
|
|
7
|
+
Determinism matters for dedup (conventions.md / openapi dedup by sha256): the
|
|
8
|
+
same project content must produce the same sha256 across machines, so we sort
|
|
9
|
+
entries and zero out mtimes / uid / gid.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import gzip
|
|
15
|
+
import hashlib
|
|
16
|
+
import io
|
|
17
|
+
import os
|
|
18
|
+
import posixpath
|
|
19
|
+
import re
|
|
20
|
+
import tarfile
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Callable, List, NamedTuple, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
# Always excluded, regardless of .forgeignore.
|
|
25
|
+
ALWAYS_IGNORE = (
|
|
26
|
+
".git",
|
|
27
|
+
".venv",
|
|
28
|
+
"venv",
|
|
29
|
+
"__pycache__",
|
|
30
|
+
".forge",
|
|
31
|
+
".mypy_cache",
|
|
32
|
+
".pytest_cache",
|
|
33
|
+
".DS_Store",
|
|
34
|
+
)
|
|
35
|
+
ALWAYS_IGNORE_SUFFIXES = (".pyc", ".pyo")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── .forgeignore: git's .gitignore rules (dir.c + wildmatch WM_PATHNAME) ──────
|
|
39
|
+
|
|
40
|
+
_POSIX_CLASSES = {
|
|
41
|
+
"alnum": "a-zA-Z0-9",
|
|
42
|
+
"alpha": "a-zA-Z",
|
|
43
|
+
"blank": " \\t",
|
|
44
|
+
"cntrl": "\\x00-\\x1f\\x7f",
|
|
45
|
+
"digit": "0-9",
|
|
46
|
+
"graph": "\\x21-\\x7e",
|
|
47
|
+
"lower": "a-z",
|
|
48
|
+
"print": "\\x20-\\x7e",
|
|
49
|
+
"punct": "!-/:-@\\[-`{-~",
|
|
50
|
+
"space": " \\t\\n\\r\\f\\v",
|
|
51
|
+
"upper": "A-Z",
|
|
52
|
+
"xdigit": "0-9A-Fa-f",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _GitignoreRule(NamedTuple):
|
|
57
|
+
match: Callable[[str], bool]
|
|
58
|
+
negate: bool # "!pattern" re-includes
|
|
59
|
+
dir_only: bool # "pattern/" matches directories only
|
|
60
|
+
basename: bool # no "/" in the pattern: match the name at any depth
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _never(_path: str) -> bool:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _wildmatch_class(pat: str, i: int) -> Optional[Tuple[str, int]]:
|
|
68
|
+
"""Translate the bracket expression starting at ``pat[i] == "["``.
|
|
69
|
+
|
|
70
|
+
Returns ``(regex, index after "]")``, or ``None`` when git's wildmatch
|
|
71
|
+
would abort (unclosed bracket, unknown ``[:class:]``): such a pattern
|
|
72
|
+
never matches.
|
|
73
|
+
"""
|
|
74
|
+
n = len(pat)
|
|
75
|
+
j = i + 1
|
|
76
|
+
negate = j < n and pat[j] in "!^"
|
|
77
|
+
if negate:
|
|
78
|
+
j += 1
|
|
79
|
+
parts: List[str] = []
|
|
80
|
+
first = True
|
|
81
|
+
while j < n:
|
|
82
|
+
c = pat[j]
|
|
83
|
+
if c == "]" and not first:
|
|
84
|
+
return ("[^" if negate else "[") + "".join(parts) + "]", j + 1
|
|
85
|
+
first = False
|
|
86
|
+
if c == "\\" and j + 1 < n:
|
|
87
|
+
parts.append(re.escape(pat[j + 1]))
|
|
88
|
+
j += 2
|
|
89
|
+
elif pat.startswith("[:", j):
|
|
90
|
+
end = pat.find(":]", j + 2)
|
|
91
|
+
if end == -1:
|
|
92
|
+
parts.append(re.escape(c))
|
|
93
|
+
j += 1
|
|
94
|
+
continue
|
|
95
|
+
cls = _POSIX_CLASSES.get(pat[j + 2 : end])
|
|
96
|
+
if cls is None:
|
|
97
|
+
return None
|
|
98
|
+
parts.append(cls)
|
|
99
|
+
j = end + 2
|
|
100
|
+
elif c == "-" and parts and j + 1 < n and pat[j + 1] != "]":
|
|
101
|
+
parts.append("-") # range between the neighbouring characters
|
|
102
|
+
j += 1
|
|
103
|
+
else:
|
|
104
|
+
parts.append(re.escape(c))
|
|
105
|
+
j += 1
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _compile_gitignore_glob(pat: str) -> Callable[[str], bool]:
|
|
110
|
+
"""wildmatch(WM_PATHNAME): ``*``/``?``/``[...]`` never match ``/``; ``**``
|
|
111
|
+
spans directories only as a whole segment (``**/x``, ``x/**/y``, ``x/**``)
|
|
112
|
+
and is a plain ``*`` anywhere else."""
|
|
113
|
+
out: List[str] = []
|
|
114
|
+
i, n = 0, len(pat)
|
|
115
|
+
while i < n:
|
|
116
|
+
c = pat[i]
|
|
117
|
+
if c == "*":
|
|
118
|
+
j = i
|
|
119
|
+
while j < n and pat[j] == "*":
|
|
120
|
+
j += 1
|
|
121
|
+
whole_segment = (i == 0 or pat[i - 1] == "/") and (j == n or pat[j] == "/")
|
|
122
|
+
if j - i >= 2 and whole_segment:
|
|
123
|
+
if j == n:
|
|
124
|
+
out.append(".*")
|
|
125
|
+
else:
|
|
126
|
+
out.append("(?:.*/)?")
|
|
127
|
+
j += 1 # the "/" is part of "**/"
|
|
128
|
+
else:
|
|
129
|
+
out.append("[^/]*")
|
|
130
|
+
i = j
|
|
131
|
+
elif c == "?":
|
|
132
|
+
out.append("[^/]")
|
|
133
|
+
i += 1
|
|
134
|
+
elif c == "[":
|
|
135
|
+
cls = _wildmatch_class(pat, i)
|
|
136
|
+
if cls is None:
|
|
137
|
+
return _never
|
|
138
|
+
out.append("(?!/)" + cls[0])
|
|
139
|
+
i = cls[1]
|
|
140
|
+
elif c == "\\":
|
|
141
|
+
if i + 1 == n:
|
|
142
|
+
return _never # dangling escape: wildmatch never matches
|
|
143
|
+
out.append(re.escape(pat[i + 1]))
|
|
144
|
+
i += 2
|
|
145
|
+
else:
|
|
146
|
+
out.append(re.escape(c))
|
|
147
|
+
i += 1
|
|
148
|
+
try:
|
|
149
|
+
compiled = re.compile("".join(out))
|
|
150
|
+
except re.error: # e.g. a reversed range "[z-a]"
|
|
151
|
+
return _never
|
|
152
|
+
return lambda path: compiled.fullmatch(path) is not None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _trim_trailing_spaces(line: str) -> str:
|
|
156
|
+
"""Drop trailing spaces unless escaped with a backslash (git's rule)."""
|
|
157
|
+
end = len(line)
|
|
158
|
+
while end and line[end - 1] == " ":
|
|
159
|
+
backslashes = 0
|
|
160
|
+
k = end - 2
|
|
161
|
+
while k >= 0 and line[k] == "\\":
|
|
162
|
+
backslashes += 1
|
|
163
|
+
k -= 1
|
|
164
|
+
if backslashes % 2:
|
|
165
|
+
break
|
|
166
|
+
end -= 1
|
|
167
|
+
return line[:end]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _parse_gitignore_line(line: str) -> Optional[_GitignoreRule]:
|
|
171
|
+
if line.startswith("#"):
|
|
172
|
+
return None
|
|
173
|
+
pattern = _trim_trailing_spaces(line)
|
|
174
|
+
negate = pattern.startswith("!")
|
|
175
|
+
if negate:
|
|
176
|
+
pattern = pattern[1:]
|
|
177
|
+
dir_only = pattern.endswith("/")
|
|
178
|
+
if dir_only:
|
|
179
|
+
pattern = pattern[:-1]
|
|
180
|
+
if not pattern:
|
|
181
|
+
return None
|
|
182
|
+
basename = "/" not in pattern
|
|
183
|
+
if pattern.startswith("/"):
|
|
184
|
+
pattern = pattern[1:]
|
|
185
|
+
return _GitignoreRule(_compile_gitignore_glob(pattern), negate, dir_only, basename)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _load_forgeignore(root: Path) -> List[_GitignoreRule]:
|
|
189
|
+
path = root / ".forgeignore"
|
|
190
|
+
if not path.exists():
|
|
191
|
+
return []
|
|
192
|
+
text = path.read_text(encoding="utf-8", errors="replace").removeprefix("\ufeff")
|
|
193
|
+
rules = [_parse_gitignore_line(line) for line in text.splitlines()]
|
|
194
|
+
return [r for r in rules if r is not None]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _forgeignored(rel_posix: str, is_dir: bool, rules: List[_GitignoreRule]) -> bool:
|
|
198
|
+
"""Last matching rule wins, as in git. Callers never descend into an
|
|
199
|
+
ignored directory, so (as in git) nothing below it can be re-included."""
|
|
200
|
+
ignored = False
|
|
201
|
+
name = rel_posix.rsplit("/", 1)[-1]
|
|
202
|
+
for rule in rules:
|
|
203
|
+
if rule.dir_only and not is_dir:
|
|
204
|
+
continue
|
|
205
|
+
if rule.match(name if rule.basename else rel_posix):
|
|
206
|
+
ignored = not rule.negate
|
|
207
|
+
return ignored
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _is_always_ignored(name: str) -> bool:
|
|
211
|
+
if name in ALWAYS_IGNORE:
|
|
212
|
+
return True
|
|
213
|
+
return any(name.endswith(suf) for suf in ALWAYS_IGNORE_SUFFIXES)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def collect_files(root: Path) -> List[Tuple[Path, str]]:
|
|
217
|
+
"""Return ``(absolute_path, arcname)`` for every file to include, sorted."""
|
|
218
|
+
root = root.resolve()
|
|
219
|
+
rules = _load_forgeignore(root)
|
|
220
|
+
out: List[Tuple[Path, str]] = []
|
|
221
|
+
|
|
222
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
223
|
+
dir_abs = Path(dirpath)
|
|
224
|
+
rel_dir = dir_abs.relative_to(root)
|
|
225
|
+
|
|
226
|
+
# Prune ignored directories in place so os.walk doesn't descend.
|
|
227
|
+
kept_dirs = []
|
|
228
|
+
for d in dirnames:
|
|
229
|
+
if _is_always_ignored(d):
|
|
230
|
+
continue
|
|
231
|
+
rel_posix = (rel_dir / d).as_posix() if str(rel_dir) != "." else d
|
|
232
|
+
if _forgeignored(rel_posix, True, rules):
|
|
233
|
+
continue
|
|
234
|
+
kept_dirs.append(d)
|
|
235
|
+
dirnames[:] = sorted(kept_dirs)
|
|
236
|
+
|
|
237
|
+
for f in sorted(filenames):
|
|
238
|
+
if _is_always_ignored(f):
|
|
239
|
+
continue
|
|
240
|
+
rel_posix = (rel_dir / f).as_posix() if str(rel_dir) != "." else f
|
|
241
|
+
if _forgeignored(rel_posix, False, rules):
|
|
242
|
+
continue
|
|
243
|
+
abs_path = dir_abs / f
|
|
244
|
+
if abs_path.is_symlink() or not abs_path.is_file():
|
|
245
|
+
# skip symlinks/specials — keep bundles simple and safe.
|
|
246
|
+
continue
|
|
247
|
+
out.append((abs_path, rel_posix))
|
|
248
|
+
|
|
249
|
+
out.sort(key=lambda t: t[1])
|
|
250
|
+
return out
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def build_bundle(root: Path) -> Tuple[bytes, str, List[str]]:
|
|
254
|
+
"""Build the tar.gz in memory; return ``(data, sha256_hex, arcnames)``.
|
|
255
|
+
|
|
256
|
+
Deterministic: sorted entries, mtime/uid/gid zeroed, fixed gzip mtime so the
|
|
257
|
+
same content always hashes identically (enables server-side dedup).
|
|
258
|
+
"""
|
|
259
|
+
files = collect_files(root)
|
|
260
|
+
raw = io.BytesIO()
|
|
261
|
+
# mtime=0 in the gzip header keeps the archive byte-stable.
|
|
262
|
+
with gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) as gz:
|
|
263
|
+
with tarfile.open(fileobj=gz, mode="w") as tar: # type: ignore[arg-type]
|
|
264
|
+
for abs_path, arcname in files:
|
|
265
|
+
info = tar.gettarinfo(str(abs_path), arcname=arcname)
|
|
266
|
+
info.mtime = 0
|
|
267
|
+
info.uid = info.gid = 0
|
|
268
|
+
info.uname = info.gname = ""
|
|
269
|
+
info.mode = 0o644
|
|
270
|
+
with abs_path.open("rb") as fh:
|
|
271
|
+
tar.addfile(info, fh)
|
|
272
|
+
|
|
273
|
+
data = raw.getvalue()
|
|
274
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
275
|
+
return data, digest, [a for _, a in files]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ── from_dockerfile build context (docs/specs/image-from-dockerfile.md) ──────
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# Regex metacharacters that are literal in a Docker ignore pattern; the rest of
|
|
282
|
+
# the pattern syntax (* ? ** [ ] \) is translated by _compile_dockerignore.
|
|
283
|
+
_DOCKERIGNORE_LITERAL_META = frozenset(".+()|{}$")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _clean_dockerignore_pattern(pattern: str) -> str:
|
|
287
|
+
"""``filepath.Clean`` + leading-``/`` strip, as docker's ignorefile.ReadAll does.
|
|
288
|
+
|
|
289
|
+
Every pattern is relative to the context root, so ``/a`` and ``a`` are the
|
|
290
|
+
same pattern.
|
|
291
|
+
"""
|
|
292
|
+
if pattern.startswith("/"):
|
|
293
|
+
pattern = "/" + pattern.lstrip("/") # Go collapses "//a"; normpath keeps it
|
|
294
|
+
pattern = posixpath.normpath(pattern)
|
|
295
|
+
if len(pattern) > 1 and pattern.startswith("/"):
|
|
296
|
+
pattern = pattern[1:]
|
|
297
|
+
return pattern
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _compile_dockerignore(pattern: str) -> Callable[[str], bool]:
|
|
301
|
+
"""Matcher for one cleaned pattern — a port of moby/patternmatcher.
|
|
302
|
+
|
|
303
|
+
``*`` and ``?`` never cross ``/``; ``**`` spans any number of directories
|
|
304
|
+
(``**/`` included). The returned callable takes a root-relative POSIX path.
|
|
305
|
+
Raises ``ValueError`` on a malformed pattern (e.g. an unclosed ``[``).
|
|
306
|
+
"""
|
|
307
|
+
regex = ""
|
|
308
|
+
kind = "exact"
|
|
309
|
+
i, n = 0, len(pattern)
|
|
310
|
+
first = True
|
|
311
|
+
while i < n:
|
|
312
|
+
ch = pattern[i]
|
|
313
|
+
i += 1
|
|
314
|
+
if ch == "*":
|
|
315
|
+
if i < n and pattern[i] == "*":
|
|
316
|
+
i += 1
|
|
317
|
+
if i < n and pattern[i] == "/":
|
|
318
|
+
i += 1
|
|
319
|
+
if i >= n:
|
|
320
|
+
# trailing "**": everything below (gitignore semantics)
|
|
321
|
+
if kind == "exact":
|
|
322
|
+
kind = "prefix"
|
|
323
|
+
else:
|
|
324
|
+
regex += ".*"
|
|
325
|
+
kind = "regex"
|
|
326
|
+
else:
|
|
327
|
+
regex += "(.*/)?"
|
|
328
|
+
kind = "regex"
|
|
329
|
+
if first:
|
|
330
|
+
kind = "suffix"
|
|
331
|
+
else:
|
|
332
|
+
regex += "[^/]*"
|
|
333
|
+
kind = "regex"
|
|
334
|
+
elif ch == "?":
|
|
335
|
+
regex += "[^/]"
|
|
336
|
+
kind = "regex"
|
|
337
|
+
elif ch in _DOCKERIGNORE_LITERAL_META:
|
|
338
|
+
regex += "\\" + ch
|
|
339
|
+
elif ch == "\\":
|
|
340
|
+
if i < n:
|
|
341
|
+
regex += "\\" + pattern[i]
|
|
342
|
+
i += 1
|
|
343
|
+
kind = "regex"
|
|
344
|
+
else:
|
|
345
|
+
regex += "\\"
|
|
346
|
+
elif ch in "[]":
|
|
347
|
+
regex += ch
|
|
348
|
+
kind = "regex"
|
|
349
|
+
else:
|
|
350
|
+
regex += ch
|
|
351
|
+
first = False
|
|
352
|
+
|
|
353
|
+
if kind == "exact":
|
|
354
|
+
return lambda path: path == pattern
|
|
355
|
+
if kind == "prefix":
|
|
356
|
+
prefix = pattern[:-2]
|
|
357
|
+
return lambda path: path.startswith(prefix)
|
|
358
|
+
if kind == "suffix":
|
|
359
|
+
suffix = pattern[2:]
|
|
360
|
+
bare = suffix[1:] if suffix.startswith("/") else None # "**/foo" matches "foo"
|
|
361
|
+
return lambda path: path.endswith(suffix) or path == bare
|
|
362
|
+
try:
|
|
363
|
+
compiled = re.compile(regex)
|
|
364
|
+
except re.error as e:
|
|
365
|
+
raise ValueError(f".dockerignore: invalid pattern {pattern!r}: {e}") from None
|
|
366
|
+
return lambda path: compiled.fullmatch(path) is not None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _load_dockerignore(root: Path) -> List[Callable[[str], bool]]:
|
|
370
|
+
"""Load ``.dockerignore`` matchers from the context root, parsed like docker.
|
|
371
|
+
|
|
372
|
+
``#`` comments (only at the very start of a line), a leading UTF-8 BOM, and
|
|
373
|
+
surrounding whitespace follow docker's ``ignorefile.ReadAll``. ``!``
|
|
374
|
+
negation is not supported in v1 and is rejected loudly rather than silently
|
|
375
|
+
including/excluding files differently than docker would.
|
|
376
|
+
"""
|
|
377
|
+
path = root / ".dockerignore"
|
|
378
|
+
if not path.exists():
|
|
379
|
+
return []
|
|
380
|
+
text = path.read_text(encoding="utf-8", errors="replace").removeprefix("\ufeff")
|
|
381
|
+
matchers: List[Callable[[str], bool]] = []
|
|
382
|
+
for line in text.splitlines():
|
|
383
|
+
if line.startswith("#"):
|
|
384
|
+
continue
|
|
385
|
+
stripped = line.strip()
|
|
386
|
+
if not stripped:
|
|
387
|
+
continue
|
|
388
|
+
if stripped.startswith("!"):
|
|
389
|
+
raise ValueError(
|
|
390
|
+
f".dockerignore: negation pattern {stripped!r} is not supported "
|
|
391
|
+
"by AI Fabric yet — restructure the ignore rules without '!'"
|
|
392
|
+
)
|
|
393
|
+
matchers.append(_compile_dockerignore(_clean_dockerignore_pattern(stripped)))
|
|
394
|
+
return matchers
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def collect_context_files(root: Path) -> List[Tuple[Path, str]]:
|
|
398
|
+
"""``(absolute_path, arcname)`` for every build-context file, sorted.
|
|
399
|
+
|
|
400
|
+
Docker-compatible: only ``.dockerignore`` filters the tree (no
|
|
401
|
+
``.forgeignore``, no built-in excludes — ignore ``.git``/``.venv``
|
|
402
|
+
explicitly if unwanted), with docker's matching rules: patterns are
|
|
403
|
+
relative to the context root and a matching directory drops its whole
|
|
404
|
+
subtree (moby ``MatchesOrParentMatches``). Symlinks and special files are
|
|
405
|
+
skipped, as in code bundles: the builder refuses to extract them.
|
|
406
|
+
"""
|
|
407
|
+
root = root.resolve()
|
|
408
|
+
matchers = _load_dockerignore(root)
|
|
409
|
+
out: List[Tuple[Path, str]] = []
|
|
410
|
+
|
|
411
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
412
|
+
dir_abs = Path(dirpath)
|
|
413
|
+
rel_dir = dir_abs.relative_to(root)
|
|
414
|
+
|
|
415
|
+
kept_dirs = []
|
|
416
|
+
for d in dirnames:
|
|
417
|
+
rel_posix = (rel_dir / d).as_posix() if str(rel_dir) != "." else d
|
|
418
|
+
if any(m(rel_posix) for m in matchers):
|
|
419
|
+
continue
|
|
420
|
+
kept_dirs.append(d)
|
|
421
|
+
dirnames[:] = sorted(kept_dirs)
|
|
422
|
+
|
|
423
|
+
for f in sorted(filenames):
|
|
424
|
+
rel_posix = (rel_dir / f).as_posix() if str(rel_dir) != "." else f
|
|
425
|
+
if any(m(rel_posix) for m in matchers):
|
|
426
|
+
continue
|
|
427
|
+
abs_path = dir_abs / f
|
|
428
|
+
if abs_path.is_symlink() or not abs_path.is_file():
|
|
429
|
+
continue
|
|
430
|
+
out.append((abs_path, rel_posix))
|
|
431
|
+
|
|
432
|
+
out.sort(key=lambda t: t[1])
|
|
433
|
+
return out
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def build_context_bundle(root: Path) -> Tuple[bytes, str, List[str]]:
|
|
437
|
+
"""Deterministic tar.gz of a Dockerfile build context; ``(data, sha256, arcnames)``.
|
|
438
|
+
|
|
439
|
+
Same determinism rules as :func:`build_bundle` (sorted entries, zeroed
|
|
440
|
+
mtime/uid/gid, fixed gzip mtime ⇒ same content, same sha256, free
|
|
441
|
+
server-side dedup), with one difference: the executable bit is preserved
|
|
442
|
+
(``COPY`` keeps file modes — a copied script must stay runnable).
|
|
443
|
+
"""
|
|
444
|
+
files = collect_context_files(root)
|
|
445
|
+
raw = io.BytesIO()
|
|
446
|
+
with gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) as gz:
|
|
447
|
+
with tarfile.open(fileobj=gz, mode="w") as tar: # type: ignore[arg-type]
|
|
448
|
+
for abs_path, arcname in files:
|
|
449
|
+
info = tar.gettarinfo(str(abs_path), arcname=arcname)
|
|
450
|
+
info.mtime = 0
|
|
451
|
+
info.uid = info.gid = 0
|
|
452
|
+
info.uname = info.gname = ""
|
|
453
|
+
info.mode = 0o755 if info.mode & 0o100 else 0o644
|
|
454
|
+
with abs_path.open("rb") as fh:
|
|
455
|
+
tar.addfile(info, fh)
|
|
456
|
+
|
|
457
|
+
data = raw.getvalue()
|
|
458
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
459
|
+
return data, digest, [a for _, a in files]
|