conta 0.0.2__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.
- conta/__init__.py +626 -0
- conta-0.0.2.dist-info/METADATA +60 -0
- conta-0.0.2.dist-info/RECORD +5 -0
- conta-0.0.2.dist-info/WHEEL +4 -0
- conta-0.0.2.dist-info/entry_points.txt +2 -0
conta/__init__.py
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
"""Client for the sha1 content-addressed asset store; index contract: https://github.com/rl-tools/conta-index (format 1).
|
|
2
|
+
|
|
3
|
+
Protocol twin of the C++ client (include/conta/conta.h): plain and gzip blobs share one cache as <sha1> and <sha1>.gz; downloads are verified against their decoded hashes and published by atomic rename. load() returns bytes and prefers compressed storage; resolve() returns a plain filesystem path. CONTA_ROOT selects a read-only offline store, CONTA_URL bypasses the index, and CONTA_CACHE selects the shared cache. Failures raise ContaError.
|
|
4
|
+
|
|
5
|
+
A reference is a bare 40-hex sha1, a "conta:<sha1>" string, or a store manifest entry
|
|
6
|
+
{"description": ..., "hash": ...}; the description is used in log messages only.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import filecmp
|
|
10
|
+
import gzip
|
|
11
|
+
import hashlib
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import secrets
|
|
16
|
+
import socket
|
|
17
|
+
import ssl
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import urllib.request
|
|
23
|
+
import zlib
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
ENTRY_POINTS = (
|
|
28
|
+
"https://conta.rl.tools/version/1/meta.json",
|
|
29
|
+
"https://raw.githubusercontent.com/rl-tools/conta-index/main/version/1/meta.json",
|
|
30
|
+
)
|
|
31
|
+
CACHED_INDEX_NAME = "index-1.json"
|
|
32
|
+
_LFS_POINTER_PREFIX = b"version https://git-lfs"
|
|
33
|
+
_DOWNLOAD_ATTEMPTS = 3
|
|
34
|
+
_CONNECT_TIMEOUT = 15
|
|
35
|
+
_STALL_SECONDS = 60
|
|
36
|
+
_STALL_BYTES_PER_SECOND = 1024
|
|
37
|
+
_HEX = set("0123456789abcdef")
|
|
38
|
+
_ALPHANUMERIC = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
|
39
|
+
_URL_CHARACTERS = _ALPHANUMERIC | set("._~:/?&=+,;@-#")
|
|
40
|
+
_LOCATION_CHARACTERS = _ALPHANUMERIC | set("._~-/")
|
|
41
|
+
_WINDOWS = os.name == "nt"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ContaError(RuntimeError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Config:
|
|
50
|
+
root: str = ""
|
|
51
|
+
cache: str = ""
|
|
52
|
+
url_base: str = ""
|
|
53
|
+
entry_points: list = field(default_factory=lambda: list(ENTRY_POINTS))
|
|
54
|
+
compressed_cache: bool = True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def config_from_environment():
|
|
58
|
+
config = Config()
|
|
59
|
+
config.root = os.environ.get("CONTA_ROOT", "")
|
|
60
|
+
config.cache = os.environ.get("CONTA_CACHE", "")
|
|
61
|
+
if not config.cache:
|
|
62
|
+
if _WINDOWS:
|
|
63
|
+
local_app_data = os.environ.get("LOCALAPPDATA", "")
|
|
64
|
+
if local_app_data:
|
|
65
|
+
config.cache = str(Path(local_app_data) / "conta")
|
|
66
|
+
else:
|
|
67
|
+
xdg_cache_home = os.environ.get("XDG_CACHE_HOME", "")
|
|
68
|
+
home = os.environ.get("HOME", "")
|
|
69
|
+
if xdg_cache_home:
|
|
70
|
+
config.cache = str(Path(xdg_cache_home) / "conta")
|
|
71
|
+
elif home:
|
|
72
|
+
config.cache = str(Path(home) / ".cache" / "conta")
|
|
73
|
+
config.url_base = os.environ.get("CONTA_URL", "")
|
|
74
|
+
if config.url_base and not config.url_base.endswith("/"):
|
|
75
|
+
config.url_base += "/"
|
|
76
|
+
config.compressed_cache = os.environ.get("CONTA_COMPRESSED_CACHE") != "0"
|
|
77
|
+
return config
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_hex(text, length):
|
|
81
|
+
return isinstance(text, str) and len(text) == length and set(text) <= _HEX
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _reference_hash(reference):
|
|
85
|
+
label = None
|
|
86
|
+
if isinstance(reference, dict):
|
|
87
|
+
if "hash" not in reference:
|
|
88
|
+
raise ContaError(f"conta: reference has no \"hash\" key: {reference!r}")
|
|
89
|
+
label = reference.get("description")
|
|
90
|
+
reference = reference["hash"]
|
|
91
|
+
reference = str(reference)
|
|
92
|
+
normalized = (reference[len("conta:"):] if reference.startswith("conta:") else reference).lower()
|
|
93
|
+
if _is_hex(normalized, 40):
|
|
94
|
+
return normalized, label if label is not None else normalized
|
|
95
|
+
if _is_hex(normalized, 64):
|
|
96
|
+
raise ContaError(f"conta: \"{reference}\" is a sha256, but conta references are sha1 (40 hexadecimal characters); the sha256 in the index only verifies downloads")
|
|
97
|
+
raise ContaError(f"conta: invalid hash \"{reference}\": expected a sha1 (40 hexadecimal characters)")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _read_chunks(path):
|
|
101
|
+
with open(path, "rb") as file:
|
|
102
|
+
while True:
|
|
103
|
+
chunk = file.read(64 * 1024)
|
|
104
|
+
if not chunk:
|
|
105
|
+
return
|
|
106
|
+
yield chunk
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def sha1_file(path):
|
|
110
|
+
sha1 = hashlib.sha1(usedforsecurity=False)
|
|
111
|
+
for chunk in _read_chunks(path):
|
|
112
|
+
sha1.update(chunk)
|
|
113
|
+
return sha1.hexdigest()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _digests(path):
|
|
117
|
+
sha1, sha256 = hashlib.sha1(usedforsecurity=False), hashlib.sha256()
|
|
118
|
+
for chunk in _read_chunks(path):
|
|
119
|
+
sha1.update(chunk)
|
|
120
|
+
sha256.update(chunk)
|
|
121
|
+
return sha1.hexdigest(), sha256.hexdigest()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_lfs_pointer(path):
|
|
125
|
+
try:
|
|
126
|
+
if os.path.getsize(path) >= 1024:
|
|
127
|
+
return False
|
|
128
|
+
with open(path, "rb") as file:
|
|
129
|
+
return file.read(len(_LFS_POINTER_PREFIX)) == _LFS_POINTER_PREFIX
|
|
130
|
+
except OSError:
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _printable(text):
|
|
135
|
+
return "".join(character if " " <= character <= "~" else "?" for character in str(text)[:4096])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _unique_suffix():
|
|
139
|
+
return f"{os.getpid()}-{secrets.token_hex(4)}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class _RedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
143
|
+
def redirect_request(self, request, fp, code, message, headers, new_url): # urllib before Python 3.11 refuses 308
|
|
144
|
+
return super().redirect_request(request, fp, 307 if code == 308 else code, message, headers, new_url)
|
|
145
|
+
|
|
146
|
+
http_error_308 = urllib.request.HTTPRedirectHandler.http_error_302
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
_OPENER = urllib.request.build_opener(_RedirectHandler)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _stream(url, file, digests=()):
|
|
153
|
+
request = urllib.request.Request(url, headers={"User-Agent": "conta"})
|
|
154
|
+
with _OPENER.open(request, timeout=_CONNECT_TIMEOUT) as response:
|
|
155
|
+
read = getattr(response, "read1", response.read)
|
|
156
|
+
window_start, window_bytes = time.monotonic(), 0
|
|
157
|
+
while True:
|
|
158
|
+
chunk = read(1 << 20)
|
|
159
|
+
if not chunk:
|
|
160
|
+
return
|
|
161
|
+
for digest in digests:
|
|
162
|
+
digest.update(chunk)
|
|
163
|
+
file.write(chunk)
|
|
164
|
+
window_bytes += len(chunk)
|
|
165
|
+
now = time.monotonic()
|
|
166
|
+
if now - window_start >= _STALL_SECONDS: # the socket timeout only catches silence, not a trickle
|
|
167
|
+
if window_bytes < _STALL_SECONDS * _STALL_BYTES_PER_SECOND:
|
|
168
|
+
raise TimeoutError(f"transfer below {_STALL_BYTES_PER_SECOND} bytes/s for {_STALL_SECONDS} s")
|
|
169
|
+
window_start, window_bytes = now, 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _transient(error):
|
|
173
|
+
if isinstance(error, urllib.error.HTTPError):
|
|
174
|
+
return error.code in (408, 429) or error.code >= 500
|
|
175
|
+
return isinstance(getattr(error, "reason", error), (TimeoutError, socket.timeout))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _failure(error):
|
|
179
|
+
reason = getattr(error, "reason", error)
|
|
180
|
+
if isinstance(error, urllib.error.HTTPError):
|
|
181
|
+
return f"HTTP {error.code}" + (", the host does not serve it" if error.code in (404, 410) else "")
|
|
182
|
+
if isinstance(reason, ssl.SSLError):
|
|
183
|
+
return f"TLS failed ({reason}); in an old environment point SSL_CERT_FILE at a current CA bundle"
|
|
184
|
+
if isinstance(reason, FileNotFoundError):
|
|
185
|
+
return "file not found"
|
|
186
|
+
if isinstance(reason, (TimeoutError, socket.timeout)):
|
|
187
|
+
return f"host unreachable or transfer stalled ({reason})"
|
|
188
|
+
return str(reason)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _attempt(action):
|
|
192
|
+
for attempt in range(_DOWNLOAD_ATTEMPTS):
|
|
193
|
+
try:
|
|
194
|
+
action()
|
|
195
|
+
return None
|
|
196
|
+
except (urllib.error.URLError, OSError, ValueError) as error:
|
|
197
|
+
if attempt + 1 == _DOWNLOAD_ATTEMPTS or not _transient(error):
|
|
198
|
+
return _failure(error)
|
|
199
|
+
retry_after = error.headers.get("Retry-After", "") if isinstance(error, urllib.error.HTTPError) else ""
|
|
200
|
+
time.sleep(min(int(retry_after), 60) if retry_after.isdigit() else 1 << attempt)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _fetch_bytes(url):
|
|
204
|
+
buffer = io.BytesIO()
|
|
205
|
+
|
|
206
|
+
def action():
|
|
207
|
+
buffer.seek(0)
|
|
208
|
+
buffer.truncate()
|
|
209
|
+
_stream(url, buffer)
|
|
210
|
+
|
|
211
|
+
failure = _attempt(action)
|
|
212
|
+
return (None, failure) if failure else (buffer.getvalue(), None)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _hash_failure(digests, sha1, sha256):
|
|
216
|
+
actual_sha1, actual_sha256 = (digest.hexdigest() for digest in digests)
|
|
217
|
+
if actual_sha1 != sha1:
|
|
218
|
+
return f"hash mismatch: the content hashes to {actual_sha1}, not caching"
|
|
219
|
+
if sha256 and actual_sha256 != sha256:
|
|
220
|
+
return f"sha256 mismatch: the index lists {sha256} but the content hashes to {actual_sha256}, not caching"
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _plain_result(path, in_memory):
|
|
225
|
+
if not in_memory:
|
|
226
|
+
return path
|
|
227
|
+
try:
|
|
228
|
+
return path.read_bytes()
|
|
229
|
+
except OSError as error:
|
|
230
|
+
raise ContaError(f"conta: cannot read {path}: {error}") from error
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _publish_file(temporary, target):
|
|
234
|
+
try:
|
|
235
|
+
os.replace(temporary, target)
|
|
236
|
+
except OSError:
|
|
237
|
+
if not target.is_file() or not filecmp.cmp(temporary, target, shallow=False):
|
|
238
|
+
raise
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _decode_gzip(path, sha1, sha256, target=None):
|
|
242
|
+
temporary = Path(f"{target}.partial.{_unique_suffix()}") if target is not None else None
|
|
243
|
+
buffer = io.BytesIO() if target is None else None
|
|
244
|
+
digests = [hashlib.sha1(usedforsecurity=False), hashlib.sha256()]
|
|
245
|
+
try:
|
|
246
|
+
with open(path, "rb") as compressed:
|
|
247
|
+
if compressed.read(2) != b"\x1f\x8b":
|
|
248
|
+
return None, "invalid gzip header"
|
|
249
|
+
compressed.seek(0)
|
|
250
|
+
with gzip.GzipFile(fileobj=compressed, mode="rb") as decoded, (open(temporary, "wb") if temporary else buffer) as output:
|
|
251
|
+
while True:
|
|
252
|
+
chunk = decoded.read(1 << 20)
|
|
253
|
+
if not chunk:
|
|
254
|
+
break
|
|
255
|
+
for digest in digests:
|
|
256
|
+
digest.update(chunk)
|
|
257
|
+
output.write(chunk)
|
|
258
|
+
failure = _hash_failure(digests, sha1, sha256)
|
|
259
|
+
if failure:
|
|
260
|
+
return None, failure
|
|
261
|
+
result = buffer.getvalue() if buffer is not None else target
|
|
262
|
+
if temporary:
|
|
263
|
+
_publish_file(temporary, target)
|
|
264
|
+
return result, None
|
|
265
|
+
except (OSError, EOFError, zlib.error) as error:
|
|
266
|
+
return None, f"gzip decode failed: {error}"
|
|
267
|
+
finally:
|
|
268
|
+
if temporary:
|
|
269
|
+
temporary.unlink(missing_ok=True)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _fetch_blob(url, sha1, sha256, description, target, compressed=False, in_memory=False):
|
|
273
|
+
stored = Path(f"{target}.gz") if compressed else target
|
|
274
|
+
temporary = Path(f"{stored}.partial.{_unique_suffix()}")
|
|
275
|
+
described = f"{sha1} ({_printable(description)})" if description else sha1
|
|
276
|
+
print(f"conta: downloading {described} from {url}", file=sys.stderr)
|
|
277
|
+
digests = []
|
|
278
|
+
|
|
279
|
+
def action():
|
|
280
|
+
digests[:] = [hashlib.sha1(usedforsecurity=False), hashlib.sha256()]
|
|
281
|
+
with open(temporary, "wb") as file:
|
|
282
|
+
_stream(url, file, () if compressed else digests)
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
failure = _attempt(action)
|
|
286
|
+
if failure:
|
|
287
|
+
return None, failure
|
|
288
|
+
if compressed:
|
|
289
|
+
result, failure = _decode_gzip(temporary, sha1, sha256, None if in_memory else target)
|
|
290
|
+
if failure:
|
|
291
|
+
return None, failure
|
|
292
|
+
if in_memory:
|
|
293
|
+
_publish_file(temporary, stored)
|
|
294
|
+
return result, None
|
|
295
|
+
failure = _hash_failure(digests, sha1, sha256)
|
|
296
|
+
if failure:
|
|
297
|
+
return None, failure
|
|
298
|
+
_publish_file(temporary, target)
|
|
299
|
+
return _plain_result(target, in_memory), None
|
|
300
|
+
except OSError as error:
|
|
301
|
+
return None, f"failed to cache {sha1}: {error}"
|
|
302
|
+
finally:
|
|
303
|
+
temporary.unlink(missing_ok=True)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _parse_json(data):
|
|
307
|
+
try:
|
|
308
|
+
return json.loads(data.decode("utf-8"))
|
|
309
|
+
except (UnicodeDecodeError, ValueError, RecursionError):
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _parse_meta(data):
|
|
314
|
+
document = _parse_json(data)
|
|
315
|
+
if not isinstance(document, dict) or type(document.get("format")) is not int or document["format"] != 1:
|
|
316
|
+
return None
|
|
317
|
+
index = document.get("index")
|
|
318
|
+
if not isinstance(index, dict) or not isinstance(index.get("location"), str) or not _is_hex(index.get("sha256"), 64):
|
|
319
|
+
return None
|
|
320
|
+
return index["location"], index["sha256"]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@dataclass
|
|
324
|
+
class _Shard:
|
|
325
|
+
id: str
|
|
326
|
+
compressed: list
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@dataclass
|
|
330
|
+
class _Entry:
|
|
331
|
+
sha256: str
|
|
332
|
+
shards: list
|
|
333
|
+
description: str
|
|
334
|
+
notice: str
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@dataclass
|
|
338
|
+
class _Index:
|
|
339
|
+
source: str
|
|
340
|
+
sha256: str
|
|
341
|
+
entry_count: int
|
|
342
|
+
shard_urls: dict
|
|
343
|
+
entries: dict
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _text(value):
|
|
347
|
+
return value if isinstance(value, str) else ""
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _parse_index(data, wanted, source):
|
|
351
|
+
document = _parse_json(data)
|
|
352
|
+
if not isinstance(document, dict) or not isinstance(document.get("shards"), dict) or not isinstance(document.get("entries"), list):
|
|
353
|
+
return None
|
|
354
|
+
shard_urls = {shard: spec["url"] for shard, spec in document["shards"].items() if isinstance(spec, dict) and isinstance(spec.get("url"), str) and spec["url"]}
|
|
355
|
+
entries, entry_count = {}, 0
|
|
356
|
+
for item in document["entries"]:
|
|
357
|
+
if not isinstance(item, dict) or not _is_hex(item.get("sha1"), 40) or not _is_hex(item.get("sha256"), 64):
|
|
358
|
+
continue
|
|
359
|
+
entry_count += 1
|
|
360
|
+
sha1 = item["sha1"]
|
|
361
|
+
if sha1 in wanted and sha1 not in entries:
|
|
362
|
+
shards = item.get("shards") if isinstance(item.get("shards"), list) else []
|
|
363
|
+
locations = []
|
|
364
|
+
for shard in shards:
|
|
365
|
+
if isinstance(shard, dict) and isinstance(shard.get("id"), str) and shard["id"]:
|
|
366
|
+
compressed = shard.get("compressed") if isinstance(shard.get("compressed"), list) else []
|
|
367
|
+
locations.append(_Shard(shard["id"], [extension for extension in compressed if isinstance(extension, str)]))
|
|
368
|
+
entries[sha1] = _Entry(item["sha256"], locations, _text(item.get("description")), _text(item.get("notice")))
|
|
369
|
+
return _Index(source, hashlib.sha256(data).hexdigest(), entry_count, shard_urls, entries)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _is_document_url(url):
|
|
373
|
+
for prefix in ("https://", "http://", "file:///"):
|
|
374
|
+
if url.startswith(prefix):
|
|
375
|
+
rest = url[len(prefix):]
|
|
376
|
+
return bool(rest) and rest[0] in _ALPHANUMERIC and set(rest) <= _URL_CHARACTERS
|
|
377
|
+
return False
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _resolve_location(entry_point, location):
|
|
381
|
+
if "://" in location:
|
|
382
|
+
return location if _is_document_url(location) else None
|
|
383
|
+
if not location or location.startswith("/") or ".." in location or not set(location) <= _LOCATION_CHARACTERS or "/" not in entry_point:
|
|
384
|
+
return None
|
|
385
|
+
return entry_point[:entry_point.rfind("/") + 1] + location
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _shard_location(url_template, sha1, sha256):
|
|
389
|
+
url = url_template.replace("{sha1}", sha1).replace("{sha256}", sha256)
|
|
390
|
+
if "{" in url or "}" in url or not _is_document_url(url):
|
|
391
|
+
return None
|
|
392
|
+
return url
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@dataclass
|
|
396
|
+
class _Session:
|
|
397
|
+
wanted: set
|
|
398
|
+
indexes: list = field(default_factory=list)
|
|
399
|
+
attempted_urls: set = field(default_factory=set)
|
|
400
|
+
next_entry_point: int = 0
|
|
401
|
+
cached_index_loaded: bool = False
|
|
402
|
+
cached_index_sha256: str = ""
|
|
403
|
+
cached_index_entry_count: int = 0
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _find_entry(session, sha1):
|
|
407
|
+
return next((index.entries[sha1] for index in session.indexes if sha1 in index.entries), None)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _try_index(session, index, sha1, target, report, in_memory):
|
|
411
|
+
entry = index.entries.get(sha1)
|
|
412
|
+
if entry is None:
|
|
413
|
+
report.append(f"{index.source}: does not list it")
|
|
414
|
+
return None
|
|
415
|
+
if not entry.shards:
|
|
416
|
+
report.append(f"{index.source}: lists no location")
|
|
417
|
+
for shard in entry.shards:
|
|
418
|
+
if shard.id not in index.shard_urls:
|
|
419
|
+
report.append(f"{index.source}: shard {shard.id} is not in its shard table")
|
|
420
|
+
continue
|
|
421
|
+
url = _shard_location(index.shard_urls[shard.id], sha1, entry.sha256)
|
|
422
|
+
if url is None:
|
|
423
|
+
report.append(f"{index.source}: skipped shard {shard.id}, unsupported url {index.shard_urls[shard.id]}")
|
|
424
|
+
continue
|
|
425
|
+
parts = urllib.parse.urlsplit(url)
|
|
426
|
+
gzip_url = urllib.parse.urlunsplit(parts._replace(path=parts.path + ".gz"))
|
|
427
|
+
urls = [(gzip_url, True), (url, False)] if "gz" in shard.compressed else [(url, False)]
|
|
428
|
+
for location, compressed in urls:
|
|
429
|
+
if location in session.attempted_urls:
|
|
430
|
+
continue
|
|
431
|
+
session.attempted_urls.add(location)
|
|
432
|
+
result, failure = _fetch_blob(location, sha1, entry.sha256, entry.description, target, compressed, in_memory)
|
|
433
|
+
if failure is None:
|
|
434
|
+
return result
|
|
435
|
+
report.append(f"{location}: {failure}")
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _load_cached_index(config, session):
|
|
440
|
+
session.cached_index_loaded = True
|
|
441
|
+
path = Path(config.cache) / CACHED_INDEX_NAME
|
|
442
|
+
try:
|
|
443
|
+
data = path.read_bytes()
|
|
444
|
+
except OSError:
|
|
445
|
+
return
|
|
446
|
+
index = _parse_index(data, session.wanted, str(path))
|
|
447
|
+
if index is None:
|
|
448
|
+
return
|
|
449
|
+
session.cached_index_sha256 = index.sha256
|
|
450
|
+
session.cached_index_entry_count = index.entry_count
|
|
451
|
+
session.indexes.append(index)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _visit_entry_point(config, session, sha1, target, report, in_memory):
|
|
455
|
+
entry_point = config.entry_points[session.next_entry_point]
|
|
456
|
+
session.next_entry_point += 1
|
|
457
|
+
print(f"conta: refreshing the index from {entry_point}", file=sys.stderr)
|
|
458
|
+
meta_data, failure = _fetch_bytes(entry_point)
|
|
459
|
+
if failure:
|
|
460
|
+
report.append(f"{entry_point}: {failure}")
|
|
461
|
+
return None
|
|
462
|
+
meta = _parse_meta(meta_data)
|
|
463
|
+
if meta is None:
|
|
464
|
+
report.append(f"{entry_point}: not a format 1 meta.json")
|
|
465
|
+
return None
|
|
466
|
+
location, listed_sha256 = meta
|
|
467
|
+
if any(index.sha256 == listed_sha256 for index in session.indexes):
|
|
468
|
+
report.append(f"{entry_point}: serves the index already searched")
|
|
469
|
+
return None
|
|
470
|
+
index_url = _resolve_location(entry_point, location)
|
|
471
|
+
if index_url is None:
|
|
472
|
+
report.append(f"{entry_point}: unsupported index.location {location}")
|
|
473
|
+
return None
|
|
474
|
+
data, failure = _fetch_bytes(index_url)
|
|
475
|
+
if failure:
|
|
476
|
+
report.append(f"{index_url}: {failure}")
|
|
477
|
+
return None
|
|
478
|
+
index = _parse_index(data, session.wanted, index_url)
|
|
479
|
+
if index is None:
|
|
480
|
+
report.append(f"{index_url}: not a format 1 index")
|
|
481
|
+
return None
|
|
482
|
+
if index.sha256 != listed_sha256:
|
|
483
|
+
report.append(f"{index_url}: differs from the sha256 in meta.json (a publish may still be propagating), used for this call only")
|
|
484
|
+
elif not session.cached_index_sha256 or index.entry_count >= session.cached_index_entry_count: # entries are never removed, so a smaller index is older and must not replace the cached one
|
|
485
|
+
temporary = Path(config.cache) / f"{CACHED_INDEX_NAME}.partial.{_unique_suffix()}"
|
|
486
|
+
try:
|
|
487
|
+
temporary.write_bytes(data)
|
|
488
|
+
os.replace(temporary, Path(config.cache) / CACHED_INDEX_NAME)
|
|
489
|
+
session.cached_index_sha256 = index.sha256
|
|
490
|
+
session.cached_index_entry_count = index.entry_count
|
|
491
|
+
except OSError:
|
|
492
|
+
temporary.unlink(missing_ok=True)
|
|
493
|
+
session.indexes.append(index)
|
|
494
|
+
return _try_index(session, index, sha1, target, report, in_memory)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _failure_message(session, sha1, report):
|
|
498
|
+
entry = _find_entry(session, sha1)
|
|
499
|
+
message = f"conta: cannot resolve {sha1}"
|
|
500
|
+
if entry is None:
|
|
501
|
+
message += ", which no reachable index lists"
|
|
502
|
+
elif entry.description:
|
|
503
|
+
message += f" ({_printable(entry.description)})"
|
|
504
|
+
if entry is not None and entry.notice:
|
|
505
|
+
message += f"\nnotice: {_printable(entry.notice)}"
|
|
506
|
+
for line in report:
|
|
507
|
+
message += f"\n {_printable(line)}"
|
|
508
|
+
message += f"\nA copy you already have works with CONTA_URL=file:///<directory>/ holding it as {sha1} (verified), or with CONTA_ROOT=<directory> holding it as data/{sha1} (no network)."
|
|
509
|
+
return message
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _cache_path(config, sha1):
|
|
513
|
+
if not config.cache:
|
|
514
|
+
raise ContaError("conta: cannot determine cache directory: set " + ("CONTA_CACHE or LOCALAPPDATA" if _WINDOWS else "CONTA_CACHE, XDG_CACHE_HOME, or HOME"))
|
|
515
|
+
return Path(config.cache) / sha1
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _acquire_one(config, session, reference, in_memory=False):
|
|
519
|
+
sha1, label = _reference_hash(reference)
|
|
520
|
+
if config.root:
|
|
521
|
+
path = Path(config.root) / "data" / sha1
|
|
522
|
+
compressed = Path(f"{path}.gz")
|
|
523
|
+
if path.exists():
|
|
524
|
+
if _is_lfs_pointer(path):
|
|
525
|
+
raise ContaError(f"conta: {path} is a git-lfs pointer file, not the blob. Run \"git lfs pull\" in {config.root}.")
|
|
526
|
+
return _plain_result(path, in_memory)
|
|
527
|
+
if not compressed.exists():
|
|
528
|
+
raise ContaError(f"conta: {sha1} not found at {path} or {compressed}. CONTA_ROOT points to a read-only store, so downloading is disabled; populate the store or unset CONTA_ROOT to enable the download cache.")
|
|
529
|
+
if _is_lfs_pointer(compressed):
|
|
530
|
+
raise ContaError(f"conta: {compressed} is a git-lfs pointer file, not the blob. Run \"git lfs pull\" in {config.root}.")
|
|
531
|
+
target = Path(config.cache) / sha1 if config.cache else None
|
|
532
|
+
if target is not None and target.exists():
|
|
533
|
+
return _plain_result(target, in_memory)
|
|
534
|
+
if not in_memory:
|
|
535
|
+
target = _cache_path(config, sha1)
|
|
536
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
537
|
+
result, failure = _decode_gzip(compressed, sha1, "", None if in_memory else target)
|
|
538
|
+
if failure:
|
|
539
|
+
raise ContaError(f"conta: cannot decode {compressed}: {failure}")
|
|
540
|
+
return result
|
|
541
|
+
target = _cache_path(config, sha1)
|
|
542
|
+
if target.exists():
|
|
543
|
+
return _plain_result(target, in_memory)
|
|
544
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
545
|
+
report = []
|
|
546
|
+
compressed = Path(f"{target}.gz")
|
|
547
|
+
if compressed.exists():
|
|
548
|
+
if not config.url_base and not session.cached_index_loaded:
|
|
549
|
+
_load_cached_index(config, session)
|
|
550
|
+
entry = _find_entry(session, sha1)
|
|
551
|
+
result, failure = _decode_gzip(compressed, sha1, entry.sha256 if entry else "", None if in_memory else target)
|
|
552
|
+
if failure is None:
|
|
553
|
+
return result
|
|
554
|
+
report.append(f"{compressed}: {failure}")
|
|
555
|
+
if config.url_base:
|
|
556
|
+
url = config.url_base + sha1
|
|
557
|
+
result, failure = _fetch_blob(url, sha1, "", label if label != sha1 else "", target, in_memory=in_memory)
|
|
558
|
+
if failure:
|
|
559
|
+
raise ContaError(f"conta: download of {sha1} from {url} failed: {failure}")
|
|
560
|
+
return result
|
|
561
|
+
if not session.cached_index_loaded:
|
|
562
|
+
_load_cached_index(config, session)
|
|
563
|
+
for index in list(session.indexes):
|
|
564
|
+
result = _try_index(session, index, sha1, target, report, in_memory)
|
|
565
|
+
if result is not None:
|
|
566
|
+
return result
|
|
567
|
+
while session.next_entry_point < len(config.entry_points):
|
|
568
|
+
result = _visit_entry_point(config, session, sha1, target, report, in_memory)
|
|
569
|
+
if result is not None:
|
|
570
|
+
return result
|
|
571
|
+
raise ContaError(_failure_message(session, sha1, report))
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _wanted(references):
|
|
575
|
+
wanted = set()
|
|
576
|
+
for reference in references:
|
|
577
|
+
try:
|
|
578
|
+
wanted.add(_reference_hash(reference)[0])
|
|
579
|
+
except ContaError:
|
|
580
|
+
pass
|
|
581
|
+
return wanted
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def resolve(reference, config=None):
|
|
585
|
+
"""Return a plain local path for seeking, mmap, or consumers that require a filename."""
|
|
586
|
+
if config is None:
|
|
587
|
+
config = config_from_environment()
|
|
588
|
+
return _acquire_one(config, _Session(_wanted([reference])), reference)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def load(reference, config=None, *, compressed_cache=None) -> bytes:
|
|
592
|
+
"""Return original bytes; prefer gzip storage unless compressed_cache or CONTA_COMPRESSED_CACHE=0 disables it."""
|
|
593
|
+
if config is None:
|
|
594
|
+
config = config_from_environment()
|
|
595
|
+
in_memory = config.compressed_cache if compressed_cache is None else compressed_cache
|
|
596
|
+
result = _acquire_one(config, _Session(_wanted([reference])), reference, in_memory)
|
|
597
|
+
return result if isinstance(result, bytes) else _plain_result(result, True)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def resolve_all(references, config=None):
|
|
601
|
+
"""Resolve references in order with one walk over the index entry points; the first failure raises."""
|
|
602
|
+
if config is None:
|
|
603
|
+
config = config_from_environment()
|
|
604
|
+
session = _Session(_wanted(references))
|
|
605
|
+
return [_acquire_one(config, session, reference) for reference in references]
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def main(arguments=None):
|
|
609
|
+
arguments = sys.argv[1:] if arguments is None else arguments
|
|
610
|
+
if not arguments:
|
|
611
|
+
print("Usage: conta <sha1|conta:sha1> [more hashes ...]", file=sys.stderr)
|
|
612
|
+
print("Resolves content-addressed blobs to local filesystem paths (downloading into the cache through the conta index if required) and prints one path per line.", file=sys.stderr)
|
|
613
|
+
print("Environment: CONTA_ROOT (read-only store, disables the network), CONTA_CACHE (cache directory), CONTA_URL (fetch <url>/<sha1> directly, without the index).", file=sys.stderr)
|
|
614
|
+
return 1
|
|
615
|
+
try:
|
|
616
|
+
paths = resolve_all(arguments)
|
|
617
|
+
except ContaError as error:
|
|
618
|
+
print(error, file=sys.stderr)
|
|
619
|
+
return 1
|
|
620
|
+
for path in paths:
|
|
621
|
+
print(path)
|
|
622
|
+
return 0
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
if __name__ == "__main__":
|
|
626
|
+
sys.exit(main())
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: conta
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Content Addressed Blob Storage
|
|
5
|
+
Author-email: Jonas Eschmann <jonas.eschmann@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
## Conta: Content Addressed Blob Storage
|
|
11
|
+
|
|
12
|
+
Use `load()` when you want the blob's bytes in memory. It keeps downloaded gzip copies compressed in the cache by default, saving disk space. Use `resolve()` when a library needs a filename, or for seeking, mmap, and blobs too large to load into memory.
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
import conta
|
|
16
|
+
|
|
17
|
+
data = conta.load(reference) # bytes
|
|
18
|
+
path = conta.resolve(reference) # pathlib.Path, plain file
|
|
19
|
+
data = conta.load(reference, compressed_cache=False) # bytes, keep a plain cache
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`reference` is a SHA-1 or a manifest reference accepted by the existing path API. `resolve_all(references)` and the `conta` CLI still return plain filesystem paths.
|
|
23
|
+
|
|
24
|
+
Both APIs share the same cache directory: `<sha1>` contains the original bytes and `<sha1>.gz` contains gzip bytes. Existing plain files take precedence. When only gzip is cached, `load()` decodes directly into memory; `resolve()` and `load(..., compressed_cache=False)` materialize a verified plain file for subsequent calls. Existing alternate copies are preserved. A cold gzip download retains only the requested cache representation; raw-only sources remain raw, with no local recompression or automatic cache migration.
|
|
25
|
+
|
|
26
|
+
To avoid repeated decompression while keeping the memory API, set `CONTA_COMPRESSED_CACHE=0` (default: `1`), or set `Config.compressed_cache=False`. Python's `compressed_cache` keyword overrides the supplied config. Without an explicit config, both APIs use `config_from_environment()`; an explicit config takes precedence over environment settings. The setting changes `load()`'s cache policy, never its returned bytes, and does not change `resolve()`.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
config = conta.config_from_environment()
|
|
30
|
+
config.compressed_cache = False
|
|
31
|
+
data = conta.load(reference, config)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`CONTA_CACHE` selects the cache directory. `CONTA_ROOT` selects an authoritative offline store: plain root files are used directly, and gzip root files can be loaded into memory or expanded into `CONTA_CACHE` for path access. Conta never writes into the root; if neither representation exists there, the lookup fails without network access. `CONTA_URL` remains a direct raw-blob URL override that bypasses index discovery.
|
|
35
|
+
|
|
36
|
+
The v1 index advertises per-location alternatives as `{"id": "primary", "compressed": ["gz"]}`. Clients try supported compressed formats before raw at each location and ignore unknown extensions. Downloads and gzip decoding verify the original content's SHA-1 and, when available, SHA-256 before returning data or publishing cache files atomically. Python uses its standard-library gzip decoder.
|
|
37
|
+
|
|
38
|
+
### C++
|
|
39
|
+
|
|
40
|
+
```cpp
|
|
41
|
+
#include <conta/conta.h>
|
|
42
|
+
#include <stdexcept>
|
|
43
|
+
|
|
44
|
+
std::vector<std::uint8_t> data;
|
|
45
|
+
std::string path, error;
|
|
46
|
+
if(!conta::load(sha1, data, error)) {
|
|
47
|
+
throw std::runtime_error(error);
|
|
48
|
+
}
|
|
49
|
+
if(!conta::resolve(sha1, path, error)) {
|
|
50
|
+
throw std::runtime_error(error);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
auto config = conta::config_from_environment();
|
|
54
|
+
config.compressed_cache = false;
|
|
55
|
+
if(!conta::load(config, sha1, data, error)) {
|
|
56
|
+
throw std::runtime_error(error);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The C++ API uses SHA-1 strings and returns success as `bool`, with diagnostics in `error`. RLtools enables gzip through its existing optional zlib dependency. Standalone header consumers can define `CONTA_ENABLE_ZLIB` and link zlib; without it, online lookups use raw copies, and a gzip-only offline store reports that decoding requires zlib.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
conta/__init__.py,sha256=F69Et9sLqFnV_8VF4lgc7XcnsVMKByahhsdlqT8blQI,24670
|
|
2
|
+
conta-0.0.2.dist-info/METADATA,sha256=4XiNVgHhZYM1QJhqxOaY_uRruhtFPwRV5oHcnMIPUmM,3658
|
|
3
|
+
conta-0.0.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
4
|
+
conta-0.0.2.dist-info/entry_points.txt,sha256=3rYmF0gk5Lkys5H3Vj7tCFFWBO_RRvySPvFUrAeEgsc,37
|
|
5
|
+
conta-0.0.2.dist-info/RECORD,,
|