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/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
+ }
@@ -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