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.
@@ -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,,