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.
- aixand_lib_github_source-0.1.0b0.dist-info/METADATA +134 -0
- aixand_lib_github_source-0.1.0b0.dist-info/RECORD +13 -0
- aixand_lib_github_source-0.1.0b0.dist-info/WHEEL +5 -0
- aixand_lib_github_source-0.1.0b0.dist-info/licenses/LICENSE +202 -0
- aixand_lib_github_source-0.1.0b0.dist-info/top_level.txt +1 -0
- github_source/__init__.py +17 -0
- github_source/client.py +553 -0
- github_source/contracts.py +211 -0
- github_source/errors.py +17 -0
- github_source/py.typed +0 -0
- github_source/snapshots.py +113 -0
- github_source/types.py +40 -0
- github_source/urls.py +67 -0
github_source/client.py
ADDED
|
@@ -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
|