synthocr 2.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- synthocr/__init__.py +3 -0
- synthocr/__main__.py +3 -0
- synthocr/cli.py +540 -0
- synthocr/config.py +589 -0
- synthocr/corpora.py +160 -0
- synthocr/corpus_store.py +222 -0
- synthocr/downloads.py +77 -0
- synthocr/experiments.py +399 -0
- synthocr/font_catalog.json +579 -0
- synthocr/font_profiles.json +18 -0
- synthocr/fonts.py +225 -0
- synthocr/home.py +150 -0
- synthocr/kurdish_characters.json +442 -0
- synthocr/kurdish_spelling.json +45 -0
- synthocr/lexicons.json +82 -0
- synthocr/pipeline.py +512 -0
- synthocr/preview.py +112 -0
- synthocr/render.py +555 -0
- synthocr/spelling.py +111 -0
- synthocr/static/app.css +1111 -0
- synthocr/static/app.js +1759 -0
- synthocr/static/brand.svg +5 -0
- synthocr/static/index.html +773 -0
- synthocr/static/theme.js +28 -0
- synthocr/text.py +437 -0
- synthocr/verify.py +307 -0
- synthocr/web.py +2259 -0
- synthocr-2.1.0.dist-info/METADATA +261 -0
- synthocr-2.1.0.dist-info/RECORD +34 -0
- synthocr-2.1.0.dist-info/WHEEL +4 -0
- synthocr-2.1.0.dist-info/entry_points.txt +3 -0
- synthocr-2.1.0.dist-info/licenses/LICENSE-APACHE +202 -0
- synthocr-2.1.0.dist-info/licenses/LICENSE-MIT +21 -0
- synthocr-2.1.0.dist-info/licenses/NOTICE +25 -0
synthocr/corpora.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Versioned Tatoeba imports with author attribution and reproducible source hashes."""
|
|
2
|
+
|
|
3
|
+
import bz2
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tempfile
|
|
9
|
+
from datetime import date
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .corpus_store import LEGACY_MACROLANGUAGES, canonical_language
|
|
13
|
+
from .downloads import download, sha256, within
|
|
14
|
+
|
|
15
|
+
LOG = logging.getLogger(__name__)
|
|
16
|
+
LICENSE = "CC-BY-2.0-FR"
|
|
17
|
+
LICENSE_URL = "https://creativecommons.org/licenses/by/2.0/fr/"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _convert(
|
|
21
|
+
archive: Path, target: Path, language: str, limit: int, conversion_version: int = 1
|
|
22
|
+
) -> dict:
|
|
23
|
+
count = unattributed = 0
|
|
24
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
temporary = None
|
|
26
|
+
try:
|
|
27
|
+
with tempfile.NamedTemporaryFile(
|
|
28
|
+
mode="w", encoding="utf-8", dir=target.parent, delete=False
|
|
29
|
+
) as output:
|
|
30
|
+
temporary = Path(output.name)
|
|
31
|
+
with bz2.open(archive, "rt", encoding="utf-8", errors="strict") as source:
|
|
32
|
+
while line := source.readline(1_000_001):
|
|
33
|
+
if len(line) > 1_000_000:
|
|
34
|
+
raise ValueError("Tatoeba source line exceeds 1,000,000 characters")
|
|
35
|
+
fields = line.rstrip("\r\n").split("\t")
|
|
36
|
+
if len(fields) != 6 or fields[1] != language or not fields[0].isdigit():
|
|
37
|
+
raise ValueError("Unexpected Tatoeba detailed TSV record")
|
|
38
|
+
identifier, _, text, author, added, modified = fields
|
|
39
|
+
if author in {"", "\\N"}:
|
|
40
|
+
unattributed += 1
|
|
41
|
+
continue
|
|
42
|
+
row = {
|
|
43
|
+
"text": text,
|
|
44
|
+
"language": (
|
|
45
|
+
LEGACY_MACROLANGUAGES.get(language, canonical_language(language))
|
|
46
|
+
if conversion_version in {1, 2}
|
|
47
|
+
else canonical_language(language)
|
|
48
|
+
),
|
|
49
|
+
"genre": "dialogue",
|
|
50
|
+
"attribution": {
|
|
51
|
+
"source": "Tatoeba",
|
|
52
|
+
"source_id": identifier,
|
|
53
|
+
"url": f"https://tatoeba.org/en/sentences/show/{identifier}",
|
|
54
|
+
"author": author,
|
|
55
|
+
"license": LICENSE,
|
|
56
|
+
"license_url": LICENSE_URL,
|
|
57
|
+
"date_added": added,
|
|
58
|
+
"date_modified": modified,
|
|
59
|
+
"modified": False,
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
if conversion_version >= 2:
|
|
63
|
+
row["attribution"]["source_language"] = language
|
|
64
|
+
output.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
65
|
+
count += 1
|
|
66
|
+
if count == limit:
|
|
67
|
+
break
|
|
68
|
+
if count == 0:
|
|
69
|
+
raise ValueError(f"No attributed Tatoeba records found for {language}")
|
|
70
|
+
os.link(temporary, target)
|
|
71
|
+
finally:
|
|
72
|
+
if temporary is not None:
|
|
73
|
+
temporary.unlink(missing_ok=True)
|
|
74
|
+
return {"records": count, "skipped_unattributed": unattributed, "raw_sha256": sha256(target)}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def snapshot(
|
|
78
|
+
raw: Path,
|
|
79
|
+
catalog: Path,
|
|
80
|
+
cache: Path,
|
|
81
|
+
languages: list[str],
|
|
82
|
+
snapshot_date: str,
|
|
83
|
+
limit: int = 10000,
|
|
84
|
+
) -> dict:
|
|
85
|
+
date.fromisoformat(snapshot_date)
|
|
86
|
+
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", snapshot_date) or limit < 1:
|
|
87
|
+
raise ValueError("Use an ISO snapshot date and a positive record limit")
|
|
88
|
+
if not languages or any(not re.fullmatch("[a-z]{3}", lang) for lang in languages):
|
|
89
|
+
raise ValueError("Languages must use ISO 639-3 codes, for example ckb or eng")
|
|
90
|
+
document = (
|
|
91
|
+
json.loads(catalog.read_text(encoding="utf-8"))
|
|
92
|
+
if catalog.exists()
|
|
93
|
+
else {"schema_version": 1, "sources": []}
|
|
94
|
+
)
|
|
95
|
+
result = []
|
|
96
|
+
for language in dict.fromkeys(languages):
|
|
97
|
+
key = f"tatoeba-{snapshot_date}-{language}"
|
|
98
|
+
existing = next((s for s in document["sources"] if s["id"] == key), None)
|
|
99
|
+
relative = f"tatoeba/{snapshot_date}/{language}.jsonl"
|
|
100
|
+
target = within(raw, relative)
|
|
101
|
+
if existing:
|
|
102
|
+
if existing["limit"] != limit:
|
|
103
|
+
raise ValueError("Snapshot limit changed; choose a new snapshot date")
|
|
104
|
+
if target.is_file() and sha256(target) == existing["raw_sha256"]:
|
|
105
|
+
result.append(existing | {"cached": True})
|
|
106
|
+
continue
|
|
107
|
+
if target.exists():
|
|
108
|
+
raise ValueError(f"Raw snapshot has changed: {target}; it will not be overwritten")
|
|
109
|
+
elif target.exists():
|
|
110
|
+
raise ValueError(f"Unregistered raw data already exists: {target}")
|
|
111
|
+
url = (
|
|
112
|
+
f"https://downloads.tatoeba.org/exports/per_language/{language}/"
|
|
113
|
+
f"{language}_sentences_detailed.tsv.bz2"
|
|
114
|
+
)
|
|
115
|
+
archive = cache / snapshot_date / f"{language}.tsv.bz2"
|
|
116
|
+
digest = download(
|
|
117
|
+
url,
|
|
118
|
+
existing["archive_sha256"] if existing else None,
|
|
119
|
+
archive,
|
|
120
|
+
128 * 1024 * 1024,
|
|
121
|
+
{"downloads.tatoeba.org"},
|
|
122
|
+
)
|
|
123
|
+
conversion_version = existing.get("conversion_version", 1) if existing else 3
|
|
124
|
+
converted = _convert(archive, target, language, limit, conversion_version)
|
|
125
|
+
if existing and converted["raw_sha256"] != existing["raw_sha256"]:
|
|
126
|
+
# Only remove this failed reproduction, never a preexisting raw snapshot.
|
|
127
|
+
target.unlink()
|
|
128
|
+
raise ValueError("Converted snapshot differs from its catalog checksum")
|
|
129
|
+
entry = {
|
|
130
|
+
"id": key,
|
|
131
|
+
"provider": "Tatoeba",
|
|
132
|
+
"language": language,
|
|
133
|
+
"language_tag": canonical_language(language),
|
|
134
|
+
"conversion_version": conversion_version,
|
|
135
|
+
"snapshot": snapshot_date,
|
|
136
|
+
"url": url,
|
|
137
|
+
"archive_sha256": digest,
|
|
138
|
+
"output": relative,
|
|
139
|
+
"limit": limit,
|
|
140
|
+
"license": LICENSE,
|
|
141
|
+
"license_url": LICENSE_URL,
|
|
142
|
+
**converted,
|
|
143
|
+
}
|
|
144
|
+
if not existing:
|
|
145
|
+
document["sources"].append(entry)
|
|
146
|
+
catalog.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
with tempfile.NamedTemporaryFile(
|
|
148
|
+
mode="w", encoding="utf-8", dir=catalog.parent, delete=False
|
|
149
|
+
) as stream:
|
|
150
|
+
json.dump(document, stream, ensure_ascii=False, indent=2)
|
|
151
|
+
stream.write("\n")
|
|
152
|
+
temp_catalog = Path(stream.name)
|
|
153
|
+
os.replace(temp_catalog, catalog)
|
|
154
|
+
result.append(entry)
|
|
155
|
+
LOG.info("Imported %s: %s attributed sentences", language, entry["records"])
|
|
156
|
+
return {
|
|
157
|
+
"sources": len(result),
|
|
158
|
+
"records": sum(s["records"] for s in result),
|
|
159
|
+
"catalog": str(catalog),
|
|
160
|
+
}
|
synthocr/corpus_store.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Explicit source-language identities for a single corpus per language tag.
|
|
2
|
+
|
|
3
|
+
Only known code aliases are folded. Script, region and dialect subtags remain
|
|
4
|
+
distinct; this module never infers a language from a filename or its text.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import sqlite3
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
LANGUAGE = re.compile(r"[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*\Z")
|
|
14
|
+
# These are the exact ISO 639-3 source codes used by our imported collections.
|
|
15
|
+
# Keep this mapping explicit: generic Kurdish is not Sorani or Kurmanji, and
|
|
16
|
+
# an unrecognised language is not guessed from a neighbouring code or script.
|
|
17
|
+
ALIASES = {
|
|
18
|
+
"amh": "am",
|
|
19
|
+
"ara": "ar",
|
|
20
|
+
"aze": "az",
|
|
21
|
+
"ben": "bn",
|
|
22
|
+
"deu": "de",
|
|
23
|
+
"ell": "el",
|
|
24
|
+
"eng": "en",
|
|
25
|
+
"fra": "fr",
|
|
26
|
+
"heb": "he",
|
|
27
|
+
"hin": "hi",
|
|
28
|
+
"hye": "hy",
|
|
29
|
+
"ind": "id",
|
|
30
|
+
"ita": "it",
|
|
31
|
+
"jpn": "ja",
|
|
32
|
+
"kat": "ka",
|
|
33
|
+
"kor": "ko",
|
|
34
|
+
"nld": "nl",
|
|
35
|
+
"pol": "pl",
|
|
36
|
+
"por": "pt",
|
|
37
|
+
"rus": "ru",
|
|
38
|
+
"spa": "es",
|
|
39
|
+
"tam": "ta",
|
|
40
|
+
"tha": "th",
|
|
41
|
+
"tur": "tr",
|
|
42
|
+
"uig": "ug",
|
|
43
|
+
"ukr": "uk",
|
|
44
|
+
"urd": "ur",
|
|
45
|
+
"vie": "vi",
|
|
46
|
+
}
|
|
47
|
+
# These are specific languages within macrolanguages, not interchangeable
|
|
48
|
+
# aliases. Historical imports used the broader tag; only explicit provenance
|
|
49
|
+
# may recover the precise source identity without rewriting those raw files.
|
|
50
|
+
LEGACY_MACROLANGUAGES = {"cmn": "zh", "pes": "fa", "swh": "sw"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def canonical_language(tag: str) -> str:
|
|
54
|
+
"""Return a stable, case-insensitive identity without collapsing dialects."""
|
|
55
|
+
if not isinstance(tag, str) or (tag and not LANGUAGE.fullmatch(tag)):
|
|
56
|
+
raise ValueError("Source language must be empty or a valid language tag")
|
|
57
|
+
value = tag.casefold()
|
|
58
|
+
base, separator, subtags = value.partition("-")
|
|
59
|
+
return ALIASES.get(base, base) + separator + subtags
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_source_language(declared: str, attribution: dict, registered: str = "") -> str:
|
|
63
|
+
"""Recover only reviewed historical macro/specific pairs from metadata.
|
|
64
|
+
|
|
65
|
+
Arbitrary conflicting language fields cannot override a record's declared
|
|
66
|
+
language. A generic user language remains generic without explicit source
|
|
67
|
+
metadata identifying its specific variety.
|
|
68
|
+
"""
|
|
69
|
+
canonical = canonical_language(declared)
|
|
70
|
+
for candidate in (attribution.get("source_language"), registered):
|
|
71
|
+
if not isinstance(candidate, str) or not LANGUAGE.fullmatch(candidate):
|
|
72
|
+
continue
|
|
73
|
+
precise = canonical_language(candidate)
|
|
74
|
+
if LEGACY_MACROLANGUAGES.get(precise) == canonical:
|
|
75
|
+
return candidate
|
|
76
|
+
return declared
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def registered_source_languages(raw: Path, sources: list[dict]) -> dict[str, str]:
|
|
80
|
+
"""Read explicit source identities only from matching pinned catalog rows.
|
|
81
|
+
|
|
82
|
+
The companion catalog is optional for mounted or custom raw collections.
|
|
83
|
+
Paths identify registered source files; they never imply a language.
|
|
84
|
+
"""
|
|
85
|
+
catalog = raw.parent / "corpora.json"
|
|
86
|
+
if not catalog.is_file():
|
|
87
|
+
return {}
|
|
88
|
+
document = json.loads(catalog.read_text(encoding="utf-8"))
|
|
89
|
+
if not isinstance(document, dict) or not isinstance(document.get("sources"), list):
|
|
90
|
+
raise ValueError("Corpus source catalog must contain a sources list")
|
|
91
|
+
checksums = {source["path"]: source["sha256"] for source in sources}
|
|
92
|
+
result = {}
|
|
93
|
+
for entry in document["sources"]:
|
|
94
|
+
if not isinstance(entry, dict):
|
|
95
|
+
raise ValueError("Corpus source catalog entries must be objects")
|
|
96
|
+
path, language = entry.get("output"), entry.get("language")
|
|
97
|
+
if (
|
|
98
|
+
isinstance(path, str)
|
|
99
|
+
and isinstance(language, str)
|
|
100
|
+
and language in LEGACY_MACROLANGUAGES
|
|
101
|
+
and checksums.get(path) == entry.get("raw_sha256")
|
|
102
|
+
and path in checksums
|
|
103
|
+
):
|
|
104
|
+
if path in result and result[path] != language:
|
|
105
|
+
raise ValueError(f"Conflicting source languages in corpus catalog: {path}")
|
|
106
|
+
result[path] = language
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def create_origins(db: sqlite3.Connection) -> None:
|
|
111
|
+
"""Store provenance separately so repeated labels never build huge arrays."""
|
|
112
|
+
db.execute(
|
|
113
|
+
"CREATE TABLE origins (id INTEGER PRIMARY KEY, text_id INTEGER NOT NULL, "
|
|
114
|
+
"source TEXT NOT NULL, line INTEGER NOT NULL, genre TEXT NOT NULL, "
|
|
115
|
+
"source_language TEXT NOT NULL, attribution TEXT, origin_hash BLOB NOT NULL, "
|
|
116
|
+
"UNIQUE(text_id,origin_hash))"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def add_origin(
|
|
121
|
+
db: sqlite3.Connection,
|
|
122
|
+
text_id: int,
|
|
123
|
+
source: str,
|
|
124
|
+
line: int,
|
|
125
|
+
genre: str,
|
|
126
|
+
source_language: str,
|
|
127
|
+
attribution: dict,
|
|
128
|
+
) -> None:
|
|
129
|
+
encoded = json.dumps(attribution, ensure_ascii=False, sort_keys=True)
|
|
130
|
+
first = db.execute(
|
|
131
|
+
"SELECT source,line,genre,attribution FROM texts WHERE id=?", (text_id,)
|
|
132
|
+
).fetchone()
|
|
133
|
+
if first is None:
|
|
134
|
+
raise ValueError("A corpus origin must reference an existing text")
|
|
135
|
+
# The first origin reuses the attribution already stored on its text row.
|
|
136
|
+
# Repeating that JSON in every origin would almost double the database.
|
|
137
|
+
payload = (
|
|
138
|
+
None
|
|
139
|
+
if first[:3] == (source, line, genre) and json.loads(first[3]) == attribution
|
|
140
|
+
else encoded
|
|
141
|
+
)
|
|
142
|
+
digest = hashlib.sha256(
|
|
143
|
+
json.dumps(
|
|
144
|
+
[source, line, genre, source_language, attribution],
|
|
145
|
+
ensure_ascii=False,
|
|
146
|
+
sort_keys=True,
|
|
147
|
+
).encode()
|
|
148
|
+
).digest()
|
|
149
|
+
db.execute(
|
|
150
|
+
"INSERT OR IGNORE INTO origins"
|
|
151
|
+
"(text_id,source,line,genre,source_language,attribution,origin_hash) VALUES(?,?,?,?,?,?,?)",
|
|
152
|
+
(
|
|
153
|
+
text_id,
|
|
154
|
+
source,
|
|
155
|
+
line,
|
|
156
|
+
genre,
|
|
157
|
+
source_language,
|
|
158
|
+
payload,
|
|
159
|
+
digest,
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def iter_origins(db: sqlite3.Connection, text_id: int | None = None):
|
|
165
|
+
"""Yield all origins, or those of one label, in deterministic insertion order."""
|
|
166
|
+
where, parameters = (
|
|
167
|
+
(" WHERE origins.text_id=?", (text_id,)) if text_id is not None else ("", ())
|
|
168
|
+
)
|
|
169
|
+
cursor = db.execute(
|
|
170
|
+
"SELECT origins.text_id,origins.source,origins.line,origins.genre,source_language,"
|
|
171
|
+
"COALESCE(origins.attribution,texts.attribution) FROM origins "
|
|
172
|
+
"JOIN texts ON texts.id=origins.text_id" + where + " ORDER BY origins.text_id,origins.id",
|
|
173
|
+
parameters,
|
|
174
|
+
)
|
|
175
|
+
for identifier, source, line, genre, language, attribution in cursor:
|
|
176
|
+
yield {
|
|
177
|
+
"text_id": identifier,
|
|
178
|
+
"source": source,
|
|
179
|
+
"line": line,
|
|
180
|
+
"genre": genre,
|
|
181
|
+
"source_language": language,
|
|
182
|
+
"attribution": json.loads(attribution),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def materialize_primary_origin(db: sqlite3.Connection, text_id: int) -> None:
|
|
187
|
+
"""Preserve the old attribution before choosing a better primary origin."""
|
|
188
|
+
db.execute(
|
|
189
|
+
"UPDATE origins SET attribution=(SELECT attribution FROM texts WHERE id=?) "
|
|
190
|
+
"WHERE text_id=? AND attribution IS NULL",
|
|
191
|
+
(text_id, text_id),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def corpus_registry(db: sqlite3.Connection, fingerprint: str, sources: list[dict]) -> dict:
|
|
196
|
+
"""Describe one logical corpus per language without copying any corpus text."""
|
|
197
|
+
source_index = {source["path"]: source for source in sources}
|
|
198
|
+
corpora = []
|
|
199
|
+
for language, count, diagnostics in db.execute(
|
|
200
|
+
"SELECT language,COUNT(*),SUM(genre='spelling') FROM texts GROUP BY language "
|
|
201
|
+
"ORDER BY language"
|
|
202
|
+
):
|
|
203
|
+
paths = (
|
|
204
|
+
row[0]
|
|
205
|
+
for row in db.execute(
|
|
206
|
+
"SELECT DISTINCT origins.source FROM origins "
|
|
207
|
+
"JOIN texts ON texts.id=origins.text_id WHERE texts.language=? "
|
|
208
|
+
"ORDER BY origins.source",
|
|
209
|
+
(language,),
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
entries = [source_index[path] for path in paths]
|
|
213
|
+
corpora.append(
|
|
214
|
+
{
|
|
215
|
+
"language": language,
|
|
216
|
+
"records": count,
|
|
217
|
+
"diagnostic_records": diagnostics,
|
|
218
|
+
"source_count": len(entries),
|
|
219
|
+
"sources": entries,
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
return {"schema_version": 1, "fingerprint": fingerprint, "corpora": corpora}
|
synthocr/downloads.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Bounded, checksummed downloads shared by fonts and corpus snapshots."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
from urllib.request import urlopen
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def sha256(path: Path) -> str:
|
|
12
|
+
with path.open("rb") as stream:
|
|
13
|
+
return hashlib.file_digest(stream, "sha256").hexdigest()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def download(
|
|
17
|
+
url: str, digest: str | None, target: Path, max_bytes: int, hosts: set[str], opener=None
|
|
18
|
+
) -> str:
|
|
19
|
+
parsed = urlparse(url)
|
|
20
|
+
if (
|
|
21
|
+
parsed.scheme != "https"
|
|
22
|
+
or parsed.hostname not in hosts
|
|
23
|
+
or parsed.username
|
|
24
|
+
or parsed.password
|
|
25
|
+
):
|
|
26
|
+
raise ValueError(f"Downloads require HTTPS on {', '.join(sorted(hosts))}")
|
|
27
|
+
if target.is_file():
|
|
28
|
+
if target.stat().st_size > max_bytes:
|
|
29
|
+
raise ValueError(f"Existing download exceeds {max_bytes} bytes: {target}")
|
|
30
|
+
actual = sha256(target)
|
|
31
|
+
if digest is None or actual == digest:
|
|
32
|
+
return actual
|
|
33
|
+
raise ValueError(f"Existing file has a different checksum: {target}")
|
|
34
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
temporary = None
|
|
36
|
+
try:
|
|
37
|
+
with (opener or urlopen)(url, timeout=60) as response:
|
|
38
|
+
final = urlparse(response.geturl()) if hasattr(response, "geturl") else parsed
|
|
39
|
+
if final.scheme != "https" or final.hostname not in hosts:
|
|
40
|
+
raise ValueError("Download redirected outside the allowed HTTPS hosts")
|
|
41
|
+
size = response.headers.get("Content-Length") if hasattr(response, "headers") else None
|
|
42
|
+
if size and int(size) > max_bytes:
|
|
43
|
+
raise ValueError(f"Download exceeds {max_bytes} bytes: {url}")
|
|
44
|
+
with tempfile.NamedTemporaryFile(dir=target.parent, delete=False) as stream:
|
|
45
|
+
temporary = Path(stream.name)
|
|
46
|
+
hasher = hashlib.sha256()
|
|
47
|
+
total = 0
|
|
48
|
+
while chunk := response.read(65536):
|
|
49
|
+
total += len(chunk)
|
|
50
|
+
if total > max_bytes:
|
|
51
|
+
raise ValueError(f"Download exceeds {max_bytes} bytes: {url}")
|
|
52
|
+
hasher.update(chunk)
|
|
53
|
+
stream.write(chunk)
|
|
54
|
+
actual = hasher.hexdigest()
|
|
55
|
+
if digest is not None and actual != digest:
|
|
56
|
+
raise ValueError(f"SHA-256 verification failed: {url}")
|
|
57
|
+
# Temporary files are owner-only; give the asset ordinary read permissions
|
|
58
|
+
# so non-root users (such as the container's) can read it.
|
|
59
|
+
temporary.chmod(0o644)
|
|
60
|
+
# Link is atomic and refuses an existing destination, even after a race.
|
|
61
|
+
os.link(temporary, target)
|
|
62
|
+
return actual
|
|
63
|
+
finally:
|
|
64
|
+
if temporary is not None:
|
|
65
|
+
temporary.unlink(missing_ok=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def within(root: Path, name: str) -> Path:
|
|
69
|
+
relative = Path(name)
|
|
70
|
+
target = (root / relative).resolve()
|
|
71
|
+
if (
|
|
72
|
+
relative.is_absolute()
|
|
73
|
+
or ".." in relative.parts
|
|
74
|
+
or not target.is_relative_to(root.resolve())
|
|
75
|
+
):
|
|
76
|
+
raise ValueError("Catalog paths must stay inside the destination")
|
|
77
|
+
return target
|