quran-processing-toolkit 0.4.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.
- quran_processing_toolkit/__init__.py +96 -0
- quran_processing_toolkit/__main__.py +3 -0
- quran_processing_toolkit/build.py +263 -0
- quran_processing_toolkit/chapter_names.py +142 -0
- quran_processing_toolkit/cli.py +95 -0
- quran_processing_toolkit/collections.py +615 -0
- quran_processing_toolkit/corpus.py +611 -0
- quran_processing_toolkit/models.py +672 -0
- quran_processing_toolkit/py.typed +1 -0
- quran_processing_toolkit/similarity.py +98 -0
- quran_processing_toolkit/source_data.py +201 -0
- quran_processing_toolkit/text.py +259 -0
- quran_processing_toolkit/translations.py +313 -0
- quran_processing_toolkit/validation.py +73 -0
- quran_processing_toolkit-0.4.0.dist-info/METADATA +269 -0
- quran_processing_toolkit-0.4.0.dist-info/RECORD +21 -0
- quran_processing_toolkit-0.4.0.dist-info/WHEEL +5 -0
- quran_processing_toolkit-0.4.0.dist-info/entry_points.txt +3 -0
- quran_processing_toolkit-0.4.0.dist-info/licenses/LICENSE +201 -0
- quran_processing_toolkit-0.4.0.dist-info/licenses/licenses/TANZIL-METADATA-NOTICE.txt +15 -0
- quran_processing_toolkit-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Explicit, hash-verified translation downloads and user-installed text layers.
|
|
2
|
+
|
|
3
|
+
Nothing is downloaded at import or ordinary corpus load. Translations are never
|
|
4
|
+
bundled in either distribution or written into the installed data directory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
import warnings
|
|
14
|
+
from collections.abc import Iterable, Mapping
|
|
15
|
+
from contextlib import suppress
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from types import MappingProxyType
|
|
20
|
+
from typing import Any
|
|
21
|
+
from urllib.error import URLError
|
|
22
|
+
from urllib.parse import urlsplit
|
|
23
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
24
|
+
|
|
25
|
+
from .chapter_names import CHAPTERS
|
|
26
|
+
from .source_data import DataError, default_cache_directory
|
|
27
|
+
from .text import TranslationNotAvailableError
|
|
28
|
+
|
|
29
|
+
Address = tuple[int, int]
|
|
30
|
+
_MAX_BYTES = 8 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TranslationDownloadError(DataError):
|
|
34
|
+
"""An explicit translation download or offline-cache lookup failed."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TranslationIntegrityError(DataError):
|
|
38
|
+
"""Downloaded or cached translation bytes differ from the pinned source hash."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TranslationLicenseWarning(UserWarning):
|
|
42
|
+
"""A translation download has third-party usage terms distinct from the code."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _HTTPSOnlyRedirect(HTTPRedirectHandler):
|
|
46
|
+
"""Reject a transport downgrade before urllib sends the redirected request."""
|
|
47
|
+
|
|
48
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
49
|
+
if urlsplit(newurl).scheme.lower() != "https":
|
|
50
|
+
raise TranslationDownloadError("Refusing a non-HTTPS translation redirect")
|
|
51
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _open_translation(request: Request, *, timeout: float):
|
|
55
|
+
return build_opener(_HTTPSOnlyRedirect()).open(request, timeout=timeout)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class TranslationSource:
|
|
60
|
+
"""One curated external translation, including a reproducible content pin."""
|
|
61
|
+
|
|
62
|
+
identifier: str
|
|
63
|
+
name: str
|
|
64
|
+
translator: str
|
|
65
|
+
language: str
|
|
66
|
+
url: str
|
|
67
|
+
sha256: str
|
|
68
|
+
terms_url: str
|
|
69
|
+
terms: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_SOURCES = (
|
|
73
|
+
TranslationSource(
|
|
74
|
+
"en.qarai",
|
|
75
|
+
"Qarai",
|
|
76
|
+
"Ali Quli Qarai",
|
|
77
|
+
"English",
|
|
78
|
+
"https://tanzil.net/trans/en.qarai",
|
|
79
|
+
"1207b98ee7ae47891b4dd01b608416da2278820acf54cdaf3943d0a6f9823fdf",
|
|
80
|
+
"https://tanzil.net/trans/",
|
|
81
|
+
"Tanzil provides translations for non-commercial purposes only; other use "
|
|
82
|
+
"requires permission from the translator or publisher. Check the source terms "
|
|
83
|
+
"before use or redistribution. The Python code's Apache license does not apply.",
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def translation_sources() -> tuple[TranslationSource, ...]:
|
|
89
|
+
"""List the curated download catalog without contacting the network."""
|
|
90
|
+
return _SOURCES
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class TranslationCoverage:
|
|
95
|
+
"""Coverage of a requested set of verse addresses by a translation layer."""
|
|
96
|
+
|
|
97
|
+
total: int
|
|
98
|
+
translated: int
|
|
99
|
+
missing: tuple[Address, ...]
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def complete(self) -> bool:
|
|
103
|
+
"""Return whether every requested verse has nonempty translation text."""
|
|
104
|
+
return not self.missing
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def fraction(self) -> float:
|
|
108
|
+
"""Return translated/total, treating an empty requested set as complete."""
|
|
109
|
+
return self.translated / self.total if self.total else 1.0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class Translation:
|
|
114
|
+
"""An immutable user-installed translation with text and source metadata.
|
|
115
|
+
|
|
116
|
+
Use load_translation() or download_translation() rather than constructing it
|
|
117
|
+
directly. Texts retain their supplied content, including embedded newlines.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
path: Path
|
|
121
|
+
texts: Mapping[Address, str]
|
|
122
|
+
metadata: Mapping[str, str]
|
|
123
|
+
fingerprint: str = field(init=False)
|
|
124
|
+
|
|
125
|
+
def __post_init__(self) -> None:
|
|
126
|
+
object.__setattr__(self, "path", self.path.expanduser().resolve())
|
|
127
|
+
object.__setattr__(self, "texts", MappingProxyType(dict(self.texts)))
|
|
128
|
+
object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata)))
|
|
129
|
+
identity = json.dumps(
|
|
130
|
+
(sorted(self.texts.items()), sorted(self.metadata.items())), ensure_ascii=False
|
|
131
|
+
).encode("utf-8")
|
|
132
|
+
object.__setattr__(self, "fingerprint", hashlib.sha256(identity).hexdigest())
|
|
133
|
+
|
|
134
|
+
def coverage(self, addresses: Iterable[Address] | None = None) -> TranslationCoverage:
|
|
135
|
+
"""Check all 6,236 canonical verses, or a caller-supplied address subset."""
|
|
136
|
+
expected = set(addresses) if addresses is not None else _canonical_addresses()
|
|
137
|
+
missing = tuple(sorted(expected - self.texts.keys()))
|
|
138
|
+
return TranslationCoverage(len(expected), len(expected) - len(missing), missing)
|
|
139
|
+
|
|
140
|
+
def require_complete(self, addresses: Iterable[Address] | None = None) -> None:
|
|
141
|
+
"""Raise TranslationNotAvailableError when any requested verse is missing."""
|
|
142
|
+
coverage = self.coverage(addresses)
|
|
143
|
+
if coverage.missing:
|
|
144
|
+
sample = ", ".join(f"{c}:{v}" for c, v in coverage.missing[:8])
|
|
145
|
+
raise TranslationNotAvailableError(
|
|
146
|
+
f"Translation missing for {len(coverage.missing)} verses: {sample}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _canonical_addresses() -> set[Address]:
|
|
151
|
+
return {(c, v) for c, row in enumerate(CHAPTERS, 1) for v in range(1, row[3] + 1)}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _parse(payload: bytes, *, json_format: bool) -> dict[Address, str]:
|
|
155
|
+
text = payload.decode("utf-8-sig")
|
|
156
|
+
rows: list[tuple[Any, Any, Any]] = []
|
|
157
|
+
if json_format:
|
|
158
|
+
data = json.loads(text)
|
|
159
|
+
for surah in data["surahs"]:
|
|
160
|
+
for verse in surah["verses"]:
|
|
161
|
+
rows.append((surah["number"], verse["number"], verse["text"]))
|
|
162
|
+
else:
|
|
163
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
164
|
+
if not line.strip() or line.startswith("#"):
|
|
165
|
+
continue
|
|
166
|
+
fields = line.split("|", 2)
|
|
167
|
+
if len(fields) != 3:
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"Malformed translation line {number}: expected chapter|verse|text"
|
|
170
|
+
)
|
|
171
|
+
rows.append((fields[0], fields[1], fields[2]))
|
|
172
|
+
canonical = _canonical_addresses()
|
|
173
|
+
result = {}
|
|
174
|
+
for chapter, verse_number, value in rows:
|
|
175
|
+
# Reject lossy conversions such as JSON floats or boolean verse numbers.
|
|
176
|
+
if isinstance(chapter, bool) or isinstance(verse_number, bool):
|
|
177
|
+
raise ValueError("Translation addresses must be integer verse numbers")
|
|
178
|
+
if not str(chapter).isdigit() or not str(verse_number).isdigit():
|
|
179
|
+
raise ValueError("Translation addresses must be integer verse numbers")
|
|
180
|
+
address = int(chapter), int(verse_number)
|
|
181
|
+
if address not in canonical:
|
|
182
|
+
raise ValueError(f"Invalid translation address {address[0]}:{address[1]}")
|
|
183
|
+
if address in result:
|
|
184
|
+
raise ValueError(f"Duplicate translation verse {address[0]}:{address[1]}")
|
|
185
|
+
if not isinstance(value, str) or not value.strip():
|
|
186
|
+
raise ValueError(f"Empty translation at {address[0]}:{address[1]}")
|
|
187
|
+
result[address] = value
|
|
188
|
+
if not result:
|
|
189
|
+
raise ValueError("Translation contains no verse records")
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def load_translation(
|
|
194
|
+
path: str | os.PathLike[str],
|
|
195
|
+
*,
|
|
196
|
+
name: str | None = None,
|
|
197
|
+
translator: str | None = None,
|
|
198
|
+
language: str | None = None,
|
|
199
|
+
) -> Translation:
|
|
200
|
+
"""Load numbered UTF-8 text or structured surahs/verses JSON without a network call.
|
|
201
|
+
|
|
202
|
+
Local files may be partial. Metadata supplied by the caller is descriptive,
|
|
203
|
+
not a certification of authorship. The SHA-256 identifies the exact file bytes.
|
|
204
|
+
"""
|
|
205
|
+
path = Path(path).expanduser().resolve()
|
|
206
|
+
payload = path.read_bytes()
|
|
207
|
+
metadata = {"path": str(path), "sha256": hashlib.sha256(payload).hexdigest()}
|
|
208
|
+
metadata.update(
|
|
209
|
+
{
|
|
210
|
+
k: v
|
|
211
|
+
for k, v in (("name", name), ("translator", translator), ("language", language))
|
|
212
|
+
if v is not None
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
return Translation(path, _parse(payload, json_format=path.suffix.lower() == ".json"), metadata)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _atomic_write(path: Path, payload: bytes) -> None:
|
|
219
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
descriptor, temporary = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
|
|
221
|
+
try:
|
|
222
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
223
|
+
output.write(payload)
|
|
224
|
+
os.replace(temporary, path)
|
|
225
|
+
finally:
|
|
226
|
+
with suppress(FileNotFoundError):
|
|
227
|
+
os.unlink(temporary)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def download_translation(
|
|
231
|
+
identifier: str = "en.qarai",
|
|
232
|
+
*,
|
|
233
|
+
cache_dir: str | os.PathLike[str] | None = None,
|
|
234
|
+
offline: bool = False,
|
|
235
|
+
refresh: bool = False,
|
|
236
|
+
timeout: float = 30.0,
|
|
237
|
+
) -> Translation:
|
|
238
|
+
"""Explicitly download a curated translation, verify its pin, and cache verbatim bytes.
|
|
239
|
+
|
|
240
|
+
The default cache is the toolkit user cache's translations/ subdirectory, never
|
|
241
|
+
either installed package. A verified cached copy is reused without networking.
|
|
242
|
+
offline=True forbids networking; refresh=True deliberately re-fetches the SAME
|
|
243
|
+
pinned revision, not an unreviewed upstream update. Unknown IDs fail locally.
|
|
244
|
+
Third-party usage terms are retained in metadata and warned about before fetching.
|
|
245
|
+
"""
|
|
246
|
+
identifier = {"qarai": "en.qarai"}.get(identifier.lower(), identifier.lower())
|
|
247
|
+
source = next((s for s in _SOURCES if s.identifier == identifier), None)
|
|
248
|
+
if source is None:
|
|
249
|
+
raise ValueError(f"Unknown translation {identifier!r}; see translation_sources()")
|
|
250
|
+
if offline and refresh:
|
|
251
|
+
raise ValueError("offline=True and refresh=True cannot be combined")
|
|
252
|
+
if timeout <= 0:
|
|
253
|
+
raise ValueError("timeout must be positive")
|
|
254
|
+
directory = (
|
|
255
|
+
Path(cache_dir).expanduser().resolve()
|
|
256
|
+
if cache_dir
|
|
257
|
+
else default_cache_directory() / "translations"
|
|
258
|
+
)
|
|
259
|
+
path = directory / f"{source.identifier}-{source.sha256}.txt"
|
|
260
|
+
manifest_path = path.with_suffix(".metadata.json")
|
|
261
|
+
downloaded_at = ""
|
|
262
|
+
if path.exists() and not refresh:
|
|
263
|
+
payload = path.read_bytes()
|
|
264
|
+
if manifest_path.exists():
|
|
265
|
+
with suppress(ValueError, OSError, TypeError, AttributeError):
|
|
266
|
+
downloaded_at = str(
|
|
267
|
+
json.loads(manifest_path.read_text(encoding="utf-8")).get("downloaded_at", "")
|
|
268
|
+
)
|
|
269
|
+
elif offline:
|
|
270
|
+
raise TranslationDownloadError(f"No offline cached translation at {path}")
|
|
271
|
+
else:
|
|
272
|
+
warnings.warn(
|
|
273
|
+
f"{source.translator}: {source.terms} {source.terms_url}",
|
|
274
|
+
TranslationLicenseWarning,
|
|
275
|
+
stacklevel=2,
|
|
276
|
+
)
|
|
277
|
+
request = Request(source.url, headers={"User-Agent": "quran-processing-toolkit"})
|
|
278
|
+
try:
|
|
279
|
+
with _open_translation(request, timeout=timeout) as response:
|
|
280
|
+
if not response.geturl().startswith("https://"):
|
|
281
|
+
raise TranslationDownloadError("Refusing a non-HTTPS translation redirect")
|
|
282
|
+
payload = response.read(_MAX_BYTES + 1)
|
|
283
|
+
except (URLError, OSError) as error:
|
|
284
|
+
raise TranslationDownloadError(f"Could not download {source.url}: {error}") from error
|
|
285
|
+
downloaded_at = datetime.now(timezone.utc).isoformat()
|
|
286
|
+
if len(payload) > _MAX_BYTES:
|
|
287
|
+
raise TranslationDownloadError("Translation exceeds the 8 MiB size limit")
|
|
288
|
+
digest = hashlib.sha256(payload).hexdigest()
|
|
289
|
+
if digest != source.sha256:
|
|
290
|
+
raise TranslationIntegrityError(
|
|
291
|
+
f"Translation SHA-256 mismatch for {source.identifier}: {digest} != {source.sha256}. "
|
|
292
|
+
"An upstream change requires a reviewed catalog update; cached corruption can "
|
|
293
|
+
"be repaired with refresh=True."
|
|
294
|
+
)
|
|
295
|
+
texts = _parse(payload, json_format=False)
|
|
296
|
+
if texts.keys() != _canonical_addresses():
|
|
297
|
+
raise TranslationIntegrityError("Curated translation does not cover all 6,236 verses")
|
|
298
|
+
metadata = {
|
|
299
|
+
"identifier": source.identifier,
|
|
300
|
+
"name": source.name,
|
|
301
|
+
"translator": source.translator,
|
|
302
|
+
"language": source.language,
|
|
303
|
+
"source_url": source.url,
|
|
304
|
+
"terms_url": source.terms_url,
|
|
305
|
+
"terms": source.terms,
|
|
306
|
+
"sha256": digest,
|
|
307
|
+
"downloaded_at": downloaded_at,
|
|
308
|
+
"path": str(path),
|
|
309
|
+
}
|
|
310
|
+
if not path.exists() or refresh:
|
|
311
|
+
_atomic_write(path, payload)
|
|
312
|
+
_atomic_write(manifest_path, json.dumps(metadata, ensure_ascii=False, indent=2).encode())
|
|
313
|
+
return Translation(path, texts, metadata)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Structural, provenance, and semantic validation of installed source data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .build import SUPPORTED_DERIVED_DIGESTS, load_source_records, rebuild_records, records_digest
|
|
10
|
+
from .corpus import QuranCorpus
|
|
11
|
+
from .source_data import DataError, default_data_directory, verify_data_directory
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class ValidationIssue:
|
|
16
|
+
"""One machine-readable validation finding and human-readable explanation."""
|
|
17
|
+
|
|
18
|
+
severity: str
|
|
19
|
+
code: str
|
|
20
|
+
message: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_corpus(data_dir: Path | None = None) -> tuple[ValidationIssue, ...]:
|
|
24
|
+
"""Return every structural or semantic validation issue for a data directory."""
|
|
25
|
+
issues: list[ValidationIssue] = []
|
|
26
|
+
path = data_dir or default_data_directory()
|
|
27
|
+
try:
|
|
28
|
+
manifest = verify_data_directory(path)
|
|
29
|
+
corpus = QuranCorpus(path)
|
|
30
|
+
except DataError as error:
|
|
31
|
+
return (ValidationIssue("error", "source-data", str(error)),)
|
|
32
|
+
|
|
33
|
+
expected_counts = {"chapters": 114, "verses": 6236, "words": 77429, "tokens": 128219}
|
|
34
|
+
for field, expected in expected_counts.items():
|
|
35
|
+
actual = len(getattr(corpus, field))
|
|
36
|
+
if actual != expected:
|
|
37
|
+
issues.append(
|
|
38
|
+
ValidationIssue(
|
|
39
|
+
"error", "unexpected-count", f"{field}: {actual}, expected {expected}"
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
if len(corpus.juzs) != 30 or sum(len(juz) for juz in corpus.juzs) != len(corpus.verses):
|
|
43
|
+
issues.append(
|
|
44
|
+
ValidationIssue(
|
|
45
|
+
"error", "juz-coverage", "Juz boundaries do not cover 30 complete parts"
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
source = manifest["sources"]["morphology"]
|
|
50
|
+
records = rebuild_records(load_source_records(path / source["filename"]))
|
|
51
|
+
digest = records_digest(records)
|
|
52
|
+
expected_digest = SUPPORTED_DERIVED_DIGESTS.get(source["sha256"])
|
|
53
|
+
if digest != expected_digest:
|
|
54
|
+
issues.append(
|
|
55
|
+
ValidationIssue(
|
|
56
|
+
"error",
|
|
57
|
+
"derived-digest",
|
|
58
|
+
f"derived morphology SHA-256 is {digest}, expected {expected_digest}",
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
aspects = Counter(record.get("ASPECT") for record in records if record.get("POS") == "V")
|
|
63
|
+
expected_aspects = Counter(PERFECT=9150, IMPERFECT=8330, IMPERATIVE=1876)
|
|
64
|
+
if aspects != expected_aspects:
|
|
65
|
+
issues.append(ValidationIssue("error", "verb-aspects", f"verb aspects differ: {aspects}"))
|
|
66
|
+
passive = sum(
|
|
67
|
+
record.get("VOICE") == "PASSIVE" for record in records if record.get("POS") == "V"
|
|
68
|
+
)
|
|
69
|
+
if passive != 1140:
|
|
70
|
+
issues.append(
|
|
71
|
+
ValidationIssue("error", "passive-verbs", f"passive finite verbs: {passive} != 1140")
|
|
72
|
+
)
|
|
73
|
+
return tuple(issues)
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quran-processing-toolkit
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Natural, queryable access to Quranic text and morphology
|
|
5
|
+
Author: Maysum Panju
|
|
6
|
+
License-Expression: Apache-2.0 AND LicenseRef-Tanzil-Metadata-CC-BY
|
|
7
|
+
Project-URL: Homepage, https://github.com/mhpanju/QuranProcessingToolkit
|
|
8
|
+
Project-URL: Documentation, https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/README.md
|
|
9
|
+
Project-URL: Repository, https://github.com/mhpanju/QuranProcessingToolkit
|
|
10
|
+
Project-URL: Issues, https://github.com/mhpanju/QuranProcessingToolkit/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/mhpanju/QuranProcessingToolkit/blob/master/CHANGELOG.md
|
|
12
|
+
Keywords: quran,arabic,corpus,morphology,linguistics,buckwalter
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Education
|
|
16
|
+
Classifier: Intended Audience :: Science/Research
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
License-File: licenses/TANZIL-METADATA-NOTICE.txt
|
|
30
|
+
Provides-Extra: fast
|
|
31
|
+
Requires-Dist: orjson>=3.10; extra == "fast"
|
|
32
|
+
Provides-Extra: data
|
|
33
|
+
Requires-Dist: quran-processing-toolkit-qac-data==0.4.0; extra == "data"
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
36
|
+
Requires-Dist: mypy>=1.17; extra == "dev"
|
|
37
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
38
|
+
Requires-Dist: ruff>=0.12; extra == "dev"
|
|
39
|
+
Requires-Dist: twine>=6; extra == "dev"
|
|
40
|
+
Dynamic: license-file
|
|
41
|
+
|
|
42
|
+
# Quran Processing Toolkit
|
|
43
|
+
|
|
44
|
+
Typed Python access to Quranic text, morphology, and composable research queries.
|
|
45
|
+
Search in Arabic or ASCII Buckwalter, inspect every original annotation, and address
|
|
46
|
+
chapters, verses, words, and tokens using Quranic 1-based indexes.
|
|
47
|
+
|
|
48
|
+
Requires **Python 3.10+**. Version **0.4.0** is the first production PyPI release;
|
|
49
|
+
the API is early-stage and future minor releases may introduce documented changes.
|
|
50
|
+
|
|
51
|
+
## Install and run
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
python -m pip install "quran-processing-toolkit[data]"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from quran_processing_toolkit import load_quran
|
|
59
|
+
|
|
60
|
+
quran = load_quran()
|
|
61
|
+
assert len(quran.chapters) == 114
|
|
62
|
+
assert len(quran.verses) == 6236
|
|
63
|
+
|
|
64
|
+
print(quran.chapter(2).name) # al-Baqarah
|
|
65
|
+
print(quran.verse(2, 255).get_arabic())
|
|
66
|
+
|
|
67
|
+
# ASCII search input is Buckwalter, so an Arabic keyboard is optional.
|
|
68
|
+
quran.search("muwsaY").show("arabic", limit=3)
|
|
69
|
+
|
|
70
|
+
# These are verb tokens: all filters apply to the same morphological stem.
|
|
71
|
+
present = quran.verbs().with_tense("present")
|
|
72
|
+
print(present.most_common_forms(limit=5))
|
|
73
|
+
|
|
74
|
+
quran.verses.ends_with("n", strip_diacritics=True).show("arabic", limit=3)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Loading is offline and verifies the installed source data. Nothing is downloaded
|
|
78
|
+
implicitly. The core has no mandatory third-party Python dependencies. Code and data
|
|
79
|
+
are separate distributions because their licenses differ; the `data` extra installs
|
|
80
|
+
the matching source-data package. For optional faster cache decoding, install
|
|
81
|
+
`"quran-processing-toolkit[data,fast]"`.
|
|
82
|
+
|
|
83
|
+
## What you can do
|
|
84
|
+
|
|
85
|
+
| Task | API | Guide |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| Look up an address | `quran.verse(2, 255)` | [Getting started](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/GETTING_STARTED.md) |
|
|
88
|
+
| Search Arabic or Buckwalter | `quran.search("muwsaY")` | [Query cookbook](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/QUERY_COOKBOOK.md) |
|
|
89
|
+
| Filter stems by root, form, tense, voice, person, or number | `quran.verbs().with_root("qwl")` | [Morphology](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/MORPHOLOGY.md) |
|
|
90
|
+
| Select nouns, pronouns, particles, and other parts of speech | `quran.nouns()`, `quran.pronouns()` | [API reference](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/API_REFERENCE.md) |
|
|
91
|
+
| Count, group, and export results | `results.count_by("root")`, `results.select("address")` | [Cookbook](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/QUERY_COOKBOOK.md) |
|
|
92
|
+
| Compare two verses for one-word edits | `compare_verses(a, b)` | [Comparison API](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/API_REFERENCE.md#chapter-names-and-surface-word-comparison) |
|
|
93
|
+
| Add a local translation or explicitly download one | `load_translation()`, `download_translation()` | [Translations](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/TRANSLATIONS.md) |
|
|
94
|
+
| Inspect sources and validate the corpus | `quran.provenance()`, `quran-toolkit validate` | [Data provenance](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/DATA_PROVENANCE.md) |
|
|
95
|
+
|
|
96
|
+
SQL and a database server are not required. Named helpers cover ordinary queries;
|
|
97
|
+
`where()`, `filter()`, `group_by()`, and `count_by()` support custom analyses.
|
|
98
|
+
|
|
99
|
+
## Addresses, words, and tokens
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
chapter = quran.chapters[1]
|
|
103
|
+
verse = quran.verses[2, 255]
|
|
104
|
+
word = quran.words[2, 255, 1]
|
|
105
|
+
token = quran.tokens[2, 255, 1, 1]
|
|
106
|
+
juz = quran.juzs[1]
|
|
107
|
+
|
|
108
|
+
results = quran.verbs().in_chapter(2)
|
|
109
|
+
first_result = results[0] # filtered results use ordinary zero-based positions
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
A **word** is a QAC-aligned orthographic word. A **token** is one morphological
|
|
113
|
+
segment: prefixes, stems, and suffixes can be separate tokens inside a word.
|
|
114
|
+
`quran.verbs()` and `quran.nouns()` return tokens; `quran.verb_words` and
|
|
115
|
+
`quran.noun_words` return whole words. Chained word filters aggregate stem values,
|
|
116
|
+
so use token queries when all conditions must match a single stem.
|
|
117
|
+
|
|
118
|
+
On a token, `form` is its surface spelling and `verb_form` is its Roman-numeral
|
|
119
|
+
morphological form. The query `with_form("IV")` selects the morphological form.
|
|
120
|
+
Roots and lemmas use case-sensitive Buckwalter; categorical aliases such as
|
|
121
|
+
`"present"`, `"IMPF"`, `"passive"`, and `"PASS"` are accepted by the relevant helpers.
|
|
122
|
+
|
|
123
|
+
See [Data model and indexing](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/DATA_MODEL.md)
|
|
124
|
+
for multi-stem behavior and the full hierarchy.
|
|
125
|
+
|
|
126
|
+
## Compose research queries
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
# Roots found in first-person plural verbs but never first-person singular verbs.
|
|
130
|
+
plural = quran.verbs().with_person(1).with_number("plural")
|
|
131
|
+
singular = quran.verbs().with_person(1).with_number("singular")
|
|
132
|
+
plural_only_roots = plural.roots() - singular.roots()
|
|
133
|
+
|
|
134
|
+
# Three-letter noun roots with at least two weak letters.
|
|
135
|
+
weak_roots = quran.nouns().with_root_length(3).with_minimum_root_letter_count("Awy", 2)
|
|
136
|
+
|
|
137
|
+
# Explicit nun-of-emphasis annotations associated with their verb stem.
|
|
138
|
+
emphatic = quran.verbs().with_nun_of_emphasis()
|
|
139
|
+
assert len(emphatic) == 243
|
|
140
|
+
|
|
141
|
+
# The longest within-verse word run containing neither mim nor nun.
|
|
142
|
+
longest = quran.longest_word_sequence_without_letters("mn", strip_diacritics=True)
|
|
143
|
+
|
|
144
|
+
# Preserve access to every original annotation.
|
|
145
|
+
token = quran.token(2, 38, 6, 1)
|
|
146
|
+
print(token.source_feature_text)
|
|
147
|
+
print(token.source_features)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Text matching is literal by default. ASCII input selects Buckwalter; non-ASCII
|
|
151
|
+
input selects Arabic. Pass normalization options explicitly, for example
|
|
152
|
+
`strip_diacritics=True`, `strip_quranic_marks=True`, or `normalize_alif=True`.
|
|
153
|
+
Use `representation="translation"` for translation search so English input is
|
|
154
|
+
not interpreted as Buckwalter.
|
|
155
|
+
|
|
156
|
+
## Translations and verse comparison
|
|
157
|
+
|
|
158
|
+
Translations are optional. Load your own UTF-8 `chapter|verse|text` file or supported
|
|
159
|
+
JSON file:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
translated = load_quran(translation="/path/to/translation.txt")
|
|
163
|
+
print(translated.verse(1, 1).get_translation())
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
An explicit, hash-verified downloader is also available:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
from quran_processing_toolkit import compare_verses, download_translation
|
|
170
|
+
|
|
171
|
+
# This call opts into a network download on first use.
|
|
172
|
+
translated = load_quran(translation=download_translation("en.qarai"))
|
|
173
|
+
translated.require_translation() # require coverage of all 6,236 verses
|
|
174
|
+
|
|
175
|
+
changes = compare_verses(
|
|
176
|
+
quran.verse(27, 68),
|
|
177
|
+
quran.verse(23, 83),
|
|
178
|
+
ignore_pause_marks=True,
|
|
179
|
+
)
|
|
180
|
+
for change in changes:
|
|
181
|
+
print(change.kind, change.verse_a.address, change.verse_b.address)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The catalog currently contains Ali Quli Qarai's English translation from Tanzil.
|
|
185
|
+
Its terms specify non-commercial use unless permission is obtained from the
|
|
186
|
+
translator or publisher. Downloads are cached outside the package and support
|
|
187
|
+
`offline=True`. Missing translation output raises `TranslationNotAvailableError`.
|
|
188
|
+
See [Translations](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/TRANSLATIONS.md)
|
|
189
|
+
for terms, formats, coverage checks, and provenance.
|
|
190
|
+
|
|
191
|
+
`compare_verses()` explains a single-word addition, substitution, or shift between
|
|
192
|
+
two verses. It returns zero or one match, rather than a general similarity score.
|
|
193
|
+
Its space-delimited surface positions differ from QAC word addresses; it performs
|
|
194
|
+
no spelling or vowel normalization.
|
|
195
|
+
|
|
196
|
+
## Command line
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
quran-toolkit stats
|
|
200
|
+
quran-toolkit search muwsaY --level verse --limit 5
|
|
201
|
+
quran-toolkit word 2:255:1
|
|
202
|
+
quran-toolkit sources
|
|
203
|
+
quran-toolkit validate
|
|
204
|
+
quran-rebuild --check
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The [CLI reference](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/CLI.md)
|
|
208
|
+
explains all commands, options, and exit behavior.
|
|
209
|
+
|
|
210
|
+
## Integrity and caching
|
|
211
|
+
|
|
212
|
+
Every newly constructed corpus verifies source filenames, sizes, and SHA-256 hashes,
|
|
213
|
+
plus the canonical derived morphology digest. Later equivalent `load_quran()` calls
|
|
214
|
+
reuse the same in-process object. A local derived JSON cache speeds up new processes;
|
|
215
|
+
installed source files are never changed.
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
fresh = load_quran(cache=False) # a new object graph
|
|
219
|
+
uncached = load_quran(cache=False, disk_cache=False) # rederive from source
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Set `QURAN_PROCESSING_TOOLKIT_CACHE` or pass `cache_dir=...` to relocate the derived
|
|
223
|
+
cache. Read-only cache locations fall back to uncached loading. See
|
|
224
|
+
[Architecture](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/ARCHITECTURE.md).
|
|
225
|
+
|
|
226
|
+
## Documentation for people and AI assistants
|
|
227
|
+
|
|
228
|
+
Start at the [documentation index](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/README.md).
|
|
229
|
+
The [AI usage guide](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/AI_GUIDE.md)
|
|
230
|
+
provides a compact, executable introduction, task-to-API mapping, and common mistakes.
|
|
231
|
+
[`llms.txt`](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/llms.txt)
|
|
232
|
+
links to the authoritative Markdown references.
|
|
233
|
+
|
|
234
|
+
- [Getting started](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/GETTING_STARTED.md)
|
|
235
|
+
- [Query cookbook](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/QUERY_COOKBOOK.md)
|
|
236
|
+
- [API reference](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/API_REFERENCE.md)
|
|
237
|
+
- [Morphology reference](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/MORPHOLOGY.md)
|
|
238
|
+
- [Release notes and preview migration](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/CHANGELOG.md)
|
|
239
|
+
- [Runnable examples](https://github.com/mhpanju/QuranProcessingToolkit/tree/master/examples), including a [complete verb JSON exporter](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/examples/export_verb_index.py)
|
|
240
|
+
- [Known limits and roadmap](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/ROADMAP.md)
|
|
241
|
+
- [Contributing](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/CONTRIBUTING.md)
|
|
242
|
+
|
|
243
|
+
## Development
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
python -m venv .venv
|
|
247
|
+
. .venv/bin/activate
|
|
248
|
+
python -m pip install -e packages/quran_processing_toolkit_qac_data
|
|
249
|
+
python -m pip install -e ".[dev]"
|
|
250
|
+
ruff check .
|
|
251
|
+
ruff format --check .
|
|
252
|
+
mypy quran_processing_toolkit packages/quran_processing_toolkit_qac_data/src
|
|
253
|
+
python -m unittest discover -s tests -v
|
|
254
|
+
python -m quran_processing_toolkit.build --check
|
|
255
|
+
python -m quran_processing_toolkit validate
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
The historical `from tools import QuranCorpus` import is available in source
|
|
259
|
+
checkouts. Installed applications should import `quran_processing_toolkit`.
|
|
260
|
+
|
|
261
|
+
## License and attribution
|
|
262
|
+
|
|
263
|
+
The Python code is Apache-2.0. The core also includes separately attributed Tanzil
|
|
264
|
+
chapter-label metadata, whose upstream declaration is CC-BY without a version;
|
|
265
|
+
see [Third-party notices](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/THIRD_PARTY_NOTICES.md).
|
|
266
|
+
The data package preserves the QAC and Tanzil sources verbatim under their respective
|
|
267
|
+
upstream terms, with full notices. No translation is bundled. Read
|
|
268
|
+
[Data provenance and licensing](https://github.com/mhpanju/QuranProcessingToolkit/blob/master/docs/DATA_PROVENANCE.md)
|
|
269
|
+
before redistributing data or derived artifacts.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
quran_processing_toolkit/__init__.py,sha256=xWCGLZp8nb_dS1pKsPKIoXmQuSJZ1KO3tKfrpzDqQBs,2139
|
|
2
|
+
quran_processing_toolkit/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
quran_processing_toolkit/build.py,sha256=35NzxpzEgXfW8u8TfysiRtkgradMeRyDJy5JPJXBQPc,8839
|
|
4
|
+
quran_processing_toolkit/chapter_names.py,sha256=K0f5mmzCPOM_tTCnh9PvZ2kBKR7NaSI5LSZfNdAb4dc,7356
|
|
5
|
+
quran_processing_toolkit/cli.py,sha256=B4e-WW5yxaTb3ROjx3dQZv79uixFFK29UUYGkUIJQNI,4198
|
|
6
|
+
quran_processing_toolkit/collections.py,sha256=jlyBY7CXxJIsoBMo3Z4h1PPfppqbQg_DWC6qrDmT1qk,23418
|
|
7
|
+
quran_processing_toolkit/corpus.py,sha256=S3ratqlt2JYyldkAj7ItmKM-in6fiTw0rSj6_lmeGeE,24170
|
|
8
|
+
quran_processing_toolkit/models.py,sha256=c04YDKF1B1GwVs5-uuiei6gzfKTC8w-l9k6zY7H-_88,22669
|
|
9
|
+
quran_processing_toolkit/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
10
|
+
quran_processing_toolkit/similarity.py,sha256=3e6tYjfH1M4c19HSPVesX6y0f8uVV2W14A60JhP8hgM,3535
|
|
11
|
+
quran_processing_toolkit/source_data.py,sha256=mpTDcyzcD0Bd7TM95PuhZqxo0kOc53FBMqWj1_LVP9w,7299
|
|
12
|
+
quran_processing_toolkit/text.py,sha256=mtb9rEeZXUrmPnXHfOBqg-ieWznBh8QYeNeZCuLI1vk,10823
|
|
13
|
+
quran_processing_toolkit/translations.py,sha256=fCQXcZiZD6RZtnj-xXQL_M1BqMrr5CfMIDUFhe3YWM4,12469
|
|
14
|
+
quran_processing_toolkit/validation.py,sha256=F6lp4OGx_a_j8mAfQQGTPbB5jetgMYvRqNprXRTdsrg,2816
|
|
15
|
+
quran_processing_toolkit-0.4.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
16
|
+
quran_processing_toolkit-0.4.0.dist-info/licenses/licenses/TANZIL-METADATA-NOTICE.txt,sha256=7qYNCx9uLVEv17eoYt57qWNYNlnfO-43MEB8umS8eho,730
|
|
17
|
+
quran_processing_toolkit-0.4.0.dist-info/METADATA,sha256=3-O3Byn-kzi9Pa0wJ8QvmEV0-bTyzoGh4wx3_FPjk5M,12818
|
|
18
|
+
quran_processing_toolkit-0.4.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
19
|
+
quran_processing_toolkit-0.4.0.dist-info/entry_points.txt,sha256=vPVscdjPGg4HqIcnshkNhaYaI7e9tQJ9YTZby-aFkrM,120
|
|
20
|
+
quran_processing_toolkit-0.4.0.dist-info/top_level.txt,sha256=RFTOfcqLLatlwvm6UY_UFvcaZnB8-JpY0hlDFwFYWlc,25
|
|
21
|
+
quran_processing_toolkit-0.4.0.dist-info/RECORD,,
|