aixand-lib-github-source 0.1.0b0__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.
@@ -0,0 +1,553 @@
1
+ """Public GitHub snapshots; immutable commits, no Git checkout or hooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import re
8
+ import time
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from copy import deepcopy
11
+ from urllib.error import HTTPError, URLError
12
+ from urllib.parse import quote, urlsplit
13
+ from urllib.request import Request, build_opener
14
+
15
+ from .contracts import (
16
+ MAX_DOWNLOAD,
17
+ MAX_ENTRIES,
18
+ MAX_FILE,
19
+ MAX_TOTAL,
20
+ GitHubError,
21
+ digest,
22
+ loads,
23
+ relative,
24
+ require,
25
+ validate_tree,
26
+ )
27
+ from .snapshots import archive_tree
28
+ from .types import Comparison, ResolvedSource, Snapshot
29
+ from .urls import COMMIT, REPO_PART, Redirects, parse_url
30
+
31
+
32
+ class Client:
33
+ def __init__(self, *, timeout=30, retries=2, cancelled=None, progress=None):
34
+ require(
35
+ type(timeout) in (int, float) and 0 < timeout <= 60,
36
+ "Timeout must be 0–60 seconds",
37
+ )
38
+ require(type(retries) is int and 0 <= retries <= 3, "Retries must be 0–3")
39
+ require(
40
+ cancelled is None or callable(cancelled),
41
+ "Cancellation callback must be callable",
42
+ )
43
+ require(
44
+ progress is None or callable(progress), "Progress callback must be callable"
45
+ )
46
+ self.timeout, self.retries = timeout, retries
47
+ self.cancelled, self.progress = cancelled, progress
48
+
49
+ def _check_cancelled(self):
50
+ if self.cancelled is not None and self.cancelled():
51
+ raise GitHubError("cancelled", "GitHub acquisition was cancelled")
52
+
53
+ def get(self, url, *, limit=MAX_DOWNLOAD, json=True):
54
+ self._check_cancelled()
55
+ require(
56
+ type(limit) is int and 0 <= limit <= MAX_DOWNLOAD,
57
+ "Invalid response size limit",
58
+ )
59
+ parts = urlsplit(url)
60
+ require(
61
+ parts.scheme == "https"
62
+ and parts.hostname
63
+ in {"api.github.com", "codeload.github.com", "raw.githubusercontent.com"}
64
+ and parts.port in (None, 443)
65
+ and not parts.username
66
+ and not parts.password
67
+ and not parts.fragment,
68
+ "Unsupported GitHub transport URL",
69
+ )
70
+ for attempt in range(self.retries + 1):
71
+ try:
72
+ return self._request(url, limit=limit, json=json)
73
+ except GitHubError as error:
74
+ if attempt == self.retries or error.code != "network":
75
+ raise
76
+ for _ in range(2**attempt * 5):
77
+ self._check_cancelled()
78
+ time.sleep(0.1)
79
+
80
+ def _request(self, url, *, limit, json):
81
+ headers = {
82
+ "User-Agent": "aixand-github-source/0.1",
83
+ "Accept": "application/vnd.github+json",
84
+ "X-GitHub-Api-Version": "2022-11-28",
85
+ }
86
+ try:
87
+ with build_opener(Redirects()).open(
88
+ Request(url, headers=headers), timeout=self.timeout
89
+ ) as response:
90
+ length = response.headers.get("Content-Length")
91
+ require(
92
+ length is None or length.isdecimal(),
93
+ "Invalid GitHub Content-Length",
94
+ "integrity",
95
+ )
96
+ require(
97
+ length is None or int(length) <= limit,
98
+ "GitHub response exceeds size limit",
99
+ "size",
100
+ )
101
+ chunks, received = [], 0
102
+ while True:
103
+ self._check_cancelled()
104
+ chunk = response.read(min(65536, limit + 1 - received))
105
+ if not chunk:
106
+ break
107
+ received += len(chunk)
108
+ require(
109
+ received <= limit, "GitHub response exceeds size limit", "size"
110
+ )
111
+ chunks.append(chunk)
112
+ if self.progress is not None:
113
+ self.progress({"event": "download", "bytes_received": received})
114
+ data = b"".join(chunks)
115
+ require(
116
+ len(data) <= limit, "GitHub response exceeds size limit", "size"
117
+ )
118
+ return loads(data) if json else data
119
+ except HTTPError as error:
120
+ # GitHub uses 422 as well as 404 for an unresolved commit expression.
121
+ code = (
122
+ "not_found"
123
+ if error.code in (404, 422)
124
+ else "rate_limited"
125
+ if error.code == 429
126
+ or (
127
+ error.code == 403
128
+ and error.headers.get("X-RateLimit-Remaining") == "0"
129
+ )
130
+ else "access_denied"
131
+ if error.code in (401, 403)
132
+ else "network"
133
+ )
134
+ retry = error.headers.get("Retry-After")
135
+ error.close()
136
+ raise GitHubError(
137
+ code,
138
+ f"GitHub returned HTTP {error.code}",
139
+ status=error.code,
140
+ retry_after=int(retry) if retry and retry.isdecimal() else None,
141
+ ) from None
142
+ except (URLError, TimeoutError, OSError) as error:
143
+ raise GitHubError(
144
+ "network", "Could not retrieve the GitHub source"
145
+ ) from error
146
+
147
+ def commit(self, repository, ref):
148
+ require(
149
+ isinstance(ref, str)
150
+ and 0 < len(ref) <= 255
151
+ and not any(c in ref for c in "\0\r\n"),
152
+ "Invalid GitHub reference",
153
+ )
154
+ value = self.get(
155
+ f"https://api.github.com/repos/{repository}/commits/{quote(ref, safe='')}",
156
+ limit=2 * 1024 * 1024,
157
+ )
158
+ require(
159
+ isinstance(value, dict)
160
+ and isinstance(value.get("sha"), str)
161
+ and COMMIT.fullmatch(value["sha"]),
162
+ "GitHub returned an invalid commit",
163
+ "integrity",
164
+ )
165
+ details = value.get("commit")
166
+ require(
167
+ isinstance(details, dict) and isinstance(details.get("tree"), dict),
168
+ "GitHub returned an invalid commit tree",
169
+ "integrity",
170
+ )
171
+ tree = details["tree"].get("sha")
172
+ if isinstance(tree, str) and COMMIT.fullmatch(tree):
173
+ if not hasattr(self, "_trees"):
174
+ self._trees = {}
175
+ self._trees[value["sha"]] = tree
176
+ return value["sha"]
177
+
178
+ def resolve(
179
+ self, url: str, *, ref: str | None = None, path: str | None = None
180
+ ) -> ResolvedSource:
181
+ self._check_cancelled()
182
+ owner, repo, tail = parse_url(url)
183
+ metadata = self.get(
184
+ f"https://api.github.com/repos/{owner}/{repo}", limit=1024 * 1024
185
+ )
186
+ require(
187
+ isinstance(metadata, dict)
188
+ and type(metadata.get("id")) is int
189
+ and metadata["id"] > 0
190
+ and isinstance(metadata.get("full_name"), str),
191
+ "Invalid GitHub repository response",
192
+ "integrity",
193
+ )
194
+ canonical = metadata["full_name"]
195
+ require(
196
+ len(canonical.split("/")) == 2
197
+ and all(REPO_PART.fullmatch(p) for p in canonical.split("/")),
198
+ "Invalid canonical repository",
199
+ "integrity",
200
+ )
201
+ if tail and ref is None:
202
+ require(len(tail) <= 32, "GitHub URL has too many path components")
203
+ for count in range(len(tail), 0, -1):
204
+ try:
205
+ ref = "/".join(tail[:count])
206
+ commit = self.commit(canonical, ref)
207
+ path = "/".join(tail[count:]) if path is None else path
208
+ break
209
+ except GitHubError as error:
210
+ if error.code != "not_found":
211
+ raise
212
+ else:
213
+ raise GitHubError(
214
+ "not_found", "GitHub URL reference could not be resolved"
215
+ )
216
+ else:
217
+ if tail and path is None:
218
+ joined = "/".join(tail)
219
+ require(
220
+ joined == ref or joined.startswith(ref + "/"),
221
+ "Specify the package path when overriding the URL reference",
222
+ )
223
+ path = joined[len(ref) :].lstrip("/")
224
+ ref = ref or metadata.get("default_branch")
225
+ commit = self.commit(canonical, ref)
226
+ path = relative(path or "", empty=True)
227
+ reference_kind = "commit" if COMMIT.fullmatch(ref) else "tag"
228
+ if reference_kind != "commit":
229
+ try:
230
+ branch = self.get(
231
+ f"https://api.github.com/repos/{canonical}/branches/{quote(ref, safe='')}",
232
+ limit=1024 * 1024,
233
+ )
234
+ if isinstance(branch, dict) and branch.get("name") == ref:
235
+ reference_kind = "branch"
236
+ except GitHubError as error:
237
+ if error.code != "not_found":
238
+ raise
239
+ result = {
240
+ "schema": "github-source/source-1",
241
+ "kind": "github",
242
+ "repository_id": str(metadata["id"]),
243
+ "repository": canonical,
244
+ "url": f"https://github.com/{canonical}",
245
+ "ref": ref,
246
+ "path": path,
247
+ "commit": commit,
248
+ "reference_kind": reference_kind,
249
+ **(
250
+ {"tree_sha": self._trees[commit]}
251
+ if commit in getattr(self, "_trees", {})
252
+ else {}
253
+ ),
254
+ }
255
+ self._validate_source(result)
256
+ return result
257
+
258
+ def snapshot(self, source):
259
+ self._check_cancelled()
260
+ self._validate_source(source)
261
+ require(COMMIT.fullmatch(source["commit"]), "Source commit must be immutable")
262
+ if source.get("path") and source.get("tree_sha"):
263
+ tree = self.scoped_snapshot(source)
264
+ return tree, digest(tree)
265
+ data = self.get(
266
+ f"https://codeload.github.com/{source['repository']}/tar.gz/{source['commit']}",
267
+ json=False,
268
+ )
269
+ tree = archive_tree(data, check=self._check_cancelled)
270
+ require(
271
+ not any(path.rsplit("/", 1)[-1] == ".gitmodules" for path in tree["files"]),
272
+ "Repository archives with submodules need a narrower package directory without gitlinks",
273
+ "unsupported",
274
+ )
275
+ selected = source["path"]
276
+ if selected:
277
+ prefix = selected + "/"
278
+ tree = {
279
+ **tree,
280
+ "files": {
281
+ p: value
282
+ for p, value in tree["files"].items()
283
+ if p == selected or p.startswith(prefix)
284
+ },
285
+ "directories": [
286
+ p
287
+ for p in tree["directories"]
288
+ if p == selected or p.startswith(prefix)
289
+ ],
290
+ }
291
+ require(
292
+ tree["files"] or tree["directories"],
293
+ "Selected path is missing",
294
+ "not_found",
295
+ )
296
+ return tree, digest(tree)
297
+
298
+ def _validate_source(self, source):
299
+ fields = {
300
+ "schema",
301
+ "kind",
302
+ "repository_id",
303
+ "repository",
304
+ "url",
305
+ "ref",
306
+ "path",
307
+ "commit",
308
+ "reference_kind",
309
+ }
310
+ require(
311
+ isinstance(source, dict)
312
+ and fields <= set(source) <= fields | {"tree_sha"}
313
+ and source.get("schema") == "github-source/source-1"
314
+ and source.get("kind") == "github",
315
+ "Expected a resolved GitHub source",
316
+ )
317
+ owner, repository, tail = parse_url(source.get("url"))
318
+ require(
319
+ not tail and source.get("repository") == owner + "/" + repository,
320
+ "Source repository identity mismatch",
321
+ "integrity",
322
+ )
323
+ require(
324
+ isinstance(source.get("repository_id"), str)
325
+ and re.fullmatch(r"[1-9][0-9]{0,31}", source["repository_id"])
326
+ and isinstance(source.get("commit"), str)
327
+ and COMMIT.fullmatch(source["commit"]),
328
+ "Invalid resolved source identity",
329
+ "integrity",
330
+ )
331
+ relative(source.get("path"), empty=True)
332
+ require(
333
+ isinstance(source["ref"], str)
334
+ and 0 < len(source["ref"]) <= 255
335
+ and not any(ord(c) < 32 or ord(c) == 127 for c in source["ref"])
336
+ and source["reference_kind"] in ("branch", "tag", "commit"),
337
+ "Invalid resolved reference",
338
+ "integrity",
339
+ )
340
+ if "tree_sha" in source:
341
+ require(
342
+ isinstance(source["tree_sha"], str)
343
+ and COMMIT.fullmatch(source["tree_sha"]),
344
+ "Invalid resolved tree identity",
345
+ "integrity",
346
+ )
347
+
348
+ def fetch(self, source: ResolvedSource) -> Snapshot:
349
+ """Fetch one immutable selection; paths remain relative to the repository."""
350
+ source = deepcopy(source)
351
+ tree, identity = self.snapshot(source)
352
+ return {
353
+ "schema": "github-source/snapshot-1",
354
+ "source": deepcopy(source),
355
+ "tree": tree,
356
+ "digest": identity,
357
+ }
358
+
359
+ def compare(self, before: ResolvedSource, after: ResolvedSource) -> Comparison:
360
+ before, after = deepcopy(before), deepcopy(after)
361
+ self._validate_source(before)
362
+ self._validate_source(after)
363
+ require(
364
+ before["repository_id"] == after["repository_id"]
365
+ and before["repository"] == after["repository"]
366
+ and before["path"] == after["path"],
367
+ "Compare the same repository and selection",
368
+ "conflict",
369
+ )
370
+ return {
371
+ "before": before["commit"],
372
+ "after": after["commit"],
373
+ "relationship": self.relation(before, after["commit"]),
374
+ }
375
+
376
+ def scoped_snapshot(self, source):
377
+ """A directory link downloads its package, rather than every repo asset."""
378
+ repository, requested = source["repository"], relative(source["path"])
379
+ root = source["tree_sha"]
380
+ require(COMMIT.fullmatch(root), "Invalid GitHub tree identity", "integrity")
381
+ parts = requested.split("/")
382
+ parent = ""
383
+ node = None
384
+ for index, component in enumerate(parts):
385
+ self._check_cancelled()
386
+ listing = self.get(
387
+ f"https://api.github.com/repos/{repository}/git/trees/{root}",
388
+ limit=8 * 1024 * 1024,
389
+ )
390
+ rows = self._listing(listing)
391
+ node = next((row for row in rows if row.get("path") == component), None)
392
+ require(
393
+ node is not None, "Selected GitHub package path is missing", "not_found"
394
+ )
395
+ require(
396
+ node.get("type") in ("tree", "blob") and node.get("mode") != "120000",
397
+ "Source contains a link or submodule",
398
+ )
399
+ require(
400
+ isinstance(node.get("sha"), str) and COMMIT.fullmatch(node["sha"]),
401
+ "Invalid GitHub tree identity",
402
+ "integrity",
403
+ )
404
+ if index < len(parts) - 1:
405
+ require(node["type"] == "tree", "Source path crosses a regular file")
406
+ root = node["sha"]
407
+ parent = "/".join(parts[: index + 1])
408
+ if node["type"] == "blob":
409
+ entries = [{**node, "path": parts[-1]}]
410
+ prefix = parent
411
+ else:
412
+ if node["type"] == "tree":
413
+ root, prefix = node["sha"], requested
414
+ else:
415
+ prefix = parent
416
+ listing = self.get(
417
+ f"https://api.github.com/repos/{repository}/git/trees/{root}?recursive=1",
418
+ limit=8 * 1024 * 1024,
419
+ )
420
+ entries = self._listing(listing)
421
+ require(len(entries) <= MAX_ENTRIES, "Source has too many files", "size")
422
+ blobs, directories, total = [], ([prefix] if node["type"] == "tree" else []), 0
423
+ for entry in entries:
424
+ self._check_cancelled()
425
+ path = relative((prefix + "/" if prefix else "") + entry["path"])
426
+ require(
427
+ (entry.get("type"), entry.get("mode"))
428
+ in {("tree", "040000"), ("blob", "100644"), ("blob", "100755")},
429
+ "Source contains a link, submodule, or unsupported mode",
430
+ "unsupported",
431
+ )
432
+ require(
433
+ COMMIT.fullmatch(entry.get("sha", "")),
434
+ "Invalid GitHub object identity",
435
+ "integrity",
436
+ )
437
+ if entry["type"] == "tree":
438
+ directories.append(path)
439
+ else:
440
+ size = entry.get("size", 0)
441
+ require(
442
+ type(size) is int and 0 <= size <= MAX_FILE,
443
+ "Source file exceeds size limit",
444
+ "size",
445
+ )
446
+ total += size
447
+ require(total <= MAX_TOTAL, "Source package exceeds size limit", "size")
448
+ blobs.append((path, entry))
449
+ validate_tree(
450
+ {
451
+ "schema": "github-source/tree-1",
452
+ "directories": directories,
453
+ "files": {
454
+ path: {"content_b64": "", "executable": False} for path, _ in blobs
455
+ },
456
+ }
457
+ )
458
+
459
+ def download(item):
460
+ path, entry = item
461
+ content = self.get(
462
+ f"https://raw.githubusercontent.com/{repository}/{source['commit']}/{quote(path, safe='/')}",
463
+ json=False,
464
+ limit=MAX_FILE,
465
+ )
466
+ require(
467
+ len(content) == entry.get("size"),
468
+ "GitHub file size differs from its captured tree",
469
+ "integrity",
470
+ )
471
+ require(
472
+ not content.startswith(b"version https://git-lfs.github.com/spec/v1\n"),
473
+ "Git LFS assets require a complete local checkout; import that local package",
474
+ "unsupported",
475
+ )
476
+ header = f"blob {len(content)}\0".encode()
477
+ actual = (
478
+ hashlib.sha1(header + content).hexdigest()
479
+ if len(entry["sha"]) == 40
480
+ else hashlib.sha256(header + content).hexdigest()
481
+ )
482
+ require(
483
+ actual == entry["sha"],
484
+ "GitHub file does not match its captured tree",
485
+ "integrity",
486
+ )
487
+ return path, {
488
+ "content_b64": base64.b64encode(content).decode(),
489
+ "executable": entry.get("mode") == "100755",
490
+ }
491
+
492
+ with ThreadPoolExecutor(max_workers=4) as pool:
493
+ files = dict(pool.map(download, blobs))
494
+ return validate_tree(
495
+ {
496
+ "schema": "github-source/tree-1",
497
+ "files": files,
498
+ "directories": directories,
499
+ }
500
+ )
501
+
502
+ def _listing(self, value):
503
+ require(
504
+ isinstance(value, dict)
505
+ and isinstance(value.get("tree"), list)
506
+ and all(
507
+ isinstance(row, dict)
508
+ and isinstance(row.get("path"), str)
509
+ and isinstance(row.get("sha"), str)
510
+ and COMMIT.fullmatch(row["sha"])
511
+ for row in value["tree"]
512
+ ),
513
+ "Invalid GitHub tree response",
514
+ "integrity",
515
+ )
516
+ require(
517
+ not value.get("truncated"),
518
+ "GitHub tree is truncated; select a narrower directory",
519
+ "size",
520
+ )
521
+ require(len(value["tree"]) <= MAX_ENTRIES, "Too many source entries", "size")
522
+ paths = [row["path"] for row in value["tree"]]
523
+ require(
524
+ len(paths) == len(set(paths)),
525
+ "GitHub tree contains duplicate paths",
526
+ "integrity",
527
+ )
528
+ return value["tree"]
529
+
530
+ def relation(self, source, commit):
531
+ self._check_cancelled()
532
+ self._validate_source(source)
533
+ require(
534
+ isinstance(commit, str) and COMMIT.fullmatch(commit),
535
+ "Invalid comparison commit",
536
+ )
537
+ before = source["commit"]
538
+ if before == commit:
539
+ return "identical"
540
+ value = self.get(
541
+ f"https://api.github.com/repos/{source['repository']}/compare/{before}...{commit}",
542
+ limit=8 * 1024 * 1024,
543
+ )
544
+ require(
545
+ isinstance(value, dict), "Invalid GitHub comparison response", "integrity"
546
+ )
547
+ status = value.get("status")
548
+ require(
549
+ status in ("ahead", "identical", "behind", "diverged"),
550
+ "Cannot establish GitHub update relationship",
551
+ "integrity",
552
+ )
553
+ return status