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/_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]