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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ conta = conta:main