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,96 @@
1
+ """Natural, 1-based access to Quranic text and morphology."""
2
+
3
+ from .chapter_names import chapter_name
4
+ from .collections import IndexedCollection, OneBasedCollection, QuerySet
5
+ from .corpus import (
6
+ CorpusAlignmentError,
7
+ CorpusError,
8
+ QuranCorpus,
9
+ clear_memory_cache,
10
+ default_data_directory,
11
+ load_quran,
12
+ )
13
+ from .models import (
14
+ VERB_FORM_NAMES,
15
+ Chapter,
16
+ Juz,
17
+ Token,
18
+ TokenAddress,
19
+ Verse,
20
+ VerseAddress,
21
+ Word,
22
+ WordAddress,
23
+ )
24
+ from .similarity import VerseMatch, compare_verses
25
+ from .source_data import (
26
+ DataError,
27
+ DataIntegrityError,
28
+ DataPackageNotInstalledError,
29
+ )
30
+ from .text import (
31
+ BUCKWALTER_DIACRITICS,
32
+ QURANIC_PAUSE_MARKS,
33
+ TranslationNotAvailableError,
34
+ canonical_representation,
35
+ infer_representation,
36
+ normalize_arabic,
37
+ normalize_buckwalter,
38
+ split_surface_words,
39
+ )
40
+ from .translations import (
41
+ Translation,
42
+ TranslationCoverage,
43
+ TranslationDownloadError,
44
+ TranslationIntegrityError,
45
+ TranslationLicenseWarning,
46
+ TranslationSource,
47
+ download_translation,
48
+ load_translation,
49
+ translation_sources,
50
+ )
51
+
52
+ __all__ = [
53
+ "Chapter",
54
+ "BUCKWALTER_DIACRITICS",
55
+ "CorpusAlignmentError",
56
+ "CorpusError",
57
+ "DataError",
58
+ "DataIntegrityError",
59
+ "DataPackageNotInstalledError",
60
+ "IndexedCollection",
61
+ "Juz",
62
+ "OneBasedCollection",
63
+ "QuranCorpus",
64
+ "QURANIC_PAUSE_MARKS",
65
+ "QuerySet",
66
+ "Token",
67
+ "TokenAddress",
68
+ "TranslationNotAvailableError",
69
+ "Verse",
70
+ "VerseAddress",
71
+ "VERB_FORM_NAMES",
72
+ "Word",
73
+ "WordAddress",
74
+ "clear_memory_cache",
75
+ "default_data_directory",
76
+ "canonical_representation",
77
+ "infer_representation",
78
+ "load_quran",
79
+ "normalize_arabic",
80
+ "normalize_buckwalter",
81
+ "chapter_name",
82
+ "split_surface_words",
83
+ "VerseMatch",
84
+ "compare_verses",
85
+ "Translation",
86
+ "TranslationCoverage",
87
+ "TranslationDownloadError",
88
+ "TranslationIntegrityError",
89
+ "TranslationLicenseWarning",
90
+ "TranslationSource",
91
+ "download_translation",
92
+ "load_translation",
93
+ "translation_sources",
94
+ ]
95
+
96
+ __version__ = "0.4.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,263 @@
1
+ """Parse verbatim QAC data and deterministically derive the toolkit representation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import tempfile
10
+ from contextlib import suppress
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ TRANSFORMATION_VERSION = 2
15
+ SUPPORTED_DERIVED_DIGESTS = {
16
+ "a1d12923815341face765083805d2148ed2d9f5cc3f7d6665219d887675d8c46": (
17
+ "01505a13b870567b38bc510cf7ab185072f4f70c473f68b02088c9602826ca79"
18
+ )
19
+ }
20
+
21
+ CONJUGATION = {
22
+ "3MS": 1,
23
+ "3MD": 2,
24
+ "3MP": 3,
25
+ "3FS": 4,
26
+ "3FD": 5,
27
+ "3FP": 6,
28
+ "2MS": 7,
29
+ "2MD": 8,
30
+ "2MP": 9,
31
+ "2FS": 10,
32
+ "2FD": 11,
33
+ "2FP": 12,
34
+ "1S": 13,
35
+ "1P": 14,
36
+ "2D": 8,
37
+ }
38
+ GENDER = {
39
+ "M": "MASC",
40
+ "MP": "MASC",
41
+ "MS": "MASC",
42
+ "MD": "MASC",
43
+ "F": "FEM",
44
+ "FP": "FEM",
45
+ "FS": "FEM",
46
+ "FD": "FEM",
47
+ }
48
+ COUNT = {
49
+ "MP": "PLURAL",
50
+ "MD": "DUAL",
51
+ "MS": "SINGULAR",
52
+ "FP": "PLURAL",
53
+ "FD": "DUAL",
54
+ "FS": "SINGULAR",
55
+ }
56
+ TOKEN_ROLE = {"PREFIX": "PREFIX", "SUFFIX": "SUFFIX", "STEM": "STEM"}
57
+ ASPECT = {"PERF": "PERFECT", "IMPF": "IMPERFECT", "IMPV": "IMPERATIVE"}
58
+ TENSE = {"PERF": "PAST", "IMPF": "PRESENT", "IMPV": "IMPERATIVE"}
59
+
60
+ # Historical coordinate-specific interpretations of the source's ambiguous 2D label.
61
+ # These are an explicit toolkit overlay; the untouched source feature remains available.
62
+ CONJUGATION_OVERRIDES = {
63
+ (4, 171, 4, 1): 9,
64
+ (5, 77, 5, 1): 9,
65
+ (28, 23, 15, 1): 5,
66
+ (35, 41, 7, 1): 5,
67
+ (66, 4, 2, 1): 11,
68
+ }
69
+
70
+
71
+ class SourceFormatError(ValueError):
72
+ """Raised when a supposedly verbatim QAC source file is malformed."""
73
+
74
+
75
+ def parse_source_record(line: str, line_number: int) -> dict[str, Any]:
76
+ """Parse one tab-separated QAC v0.4 record without changing its source values."""
77
+ try:
78
+ location, form, tag, feature_text = line.split("\t")
79
+ chapter, verse, word, part = map(int, location.strip("()").split(":"))
80
+ except ValueError as error:
81
+ raise SourceFormatError(f"Malformed morphology record on line {line_number}") from error
82
+
83
+ record: dict[str, Any] = {
84
+ "CHAPTER": chapter,
85
+ "VERSE": verse,
86
+ "WORD": word,
87
+ "WORD_PART": part,
88
+ "TAG": tag,
89
+ "FORM": form,
90
+ "FEATURES": feature_text,
91
+ }
92
+ features: list[str] = []
93
+ for feature in feature_text.split("|"):
94
+ if ":" in feature:
95
+ key, value = feature.split(":", 1)
96
+ record[key] = value
97
+ else:
98
+ features.append(feature)
99
+ record["SOURCE_FEATURES"] = features
100
+ return record
101
+
102
+
103
+ def load_source_records(path: Path) -> list[dict[str, Any]]:
104
+ """Load every annotation record from a verbatim QAC v0.4 text file."""
105
+ records: list[dict[str, Any]] = []
106
+ with path.open(encoding="utf-8", newline="") as handle:
107
+ for line_number, raw_line in enumerate(handle, 1):
108
+ line = raw_line.rstrip("\r\n")
109
+ if line.startswith("("):
110
+ records.append(parse_source_record(line, line_number))
111
+ if not records:
112
+ raise SourceFormatError(f"No morphology records found in {path}")
113
+ return records
114
+
115
+
116
+ def normalize_record(record: dict[str, Any]) -> dict[str, Any]:
117
+ """Construct one derived record while retaining the original annotation fields."""
118
+ result = {key: value for key, value in record.items() if key != "SOURCE_FEATURES"}
119
+ features: list[str] = record["SOURCE_FEATURES"]
120
+ address = (record["CHAPTER"], record["VERSE"], record["WORD"], record["WORD_PART"])
121
+
122
+ verb_form = next(
123
+ (
124
+ feature[1:-1]
125
+ for feature in features
126
+ if feature.startswith("(") and feature.endswith(")")
127
+ ),
128
+ None,
129
+ )
130
+ if verb_form is None and record.get("POS") == "V":
131
+ verb_form = "I"
132
+ if verb_form is not None:
133
+ if len(record.get("ROOT", "")) == 4:
134
+ verb_form = "r" + verb_form
135
+ result["VERB_FORM"] = verb_form
136
+
137
+ for feature in features:
138
+ if feature in CONJUGATION:
139
+ result["CONJUGATE"] = CONJUGATION[feature]
140
+ break
141
+ if address in CONJUGATION_OVERRIDES:
142
+ result["CONJUGATE"] = CONJUGATION_OVERRIDES[address]
143
+
144
+ for case in ("GEN", "NOM", "ACC"):
145
+ if case in features:
146
+ result["CASE"] = case
147
+ break
148
+ if record.get("MOOD") == "SUBJ":
149
+ result["CASE"] = "ACC"
150
+ elif record.get("MOOD") == "JUS":
151
+ result["CASE"] = "JUS"
152
+ elif "IMPF" in features and "CASE" not in result:
153
+ result["CASE"] = "NOM"
154
+ elif "IMPV" in features:
155
+ result["CASE"] = "MABNI"
156
+
157
+ for feature in features:
158
+ if feature in TOKEN_ROLE:
159
+ result["TOKEN_ROLE"] = TOKEN_ROLE[feature]
160
+ break
161
+ if "3D" in features:
162
+ result["PRON"] = "3D"
163
+
164
+ for source_value, aspect in ASPECT.items():
165
+ if source_value in features:
166
+ result["ASPECT"] = aspect
167
+ result["TENSE"] = TENSE[source_value]
168
+ break
169
+
170
+ if record.get("POS") == "V":
171
+ result["VOICE"] = "PASSIVE" if "PASS" in features else "ACTIVE"
172
+
173
+ for feature in features:
174
+ if feature in GENDER:
175
+ result["GENDER"] = GENDER[feature]
176
+ break
177
+ for feature in features:
178
+ if feature in COUNT:
179
+ result["COUNT"] = COUNT[feature]
180
+ break
181
+ if "PCPL" in features:
182
+ if "ACT" in features:
183
+ result["DERIVED_NOUN"] = "VERB_SUBJECT"
184
+ elif "PASS" in features:
185
+ result["DERIVED_NOUN"] = "VERB_OBJECT"
186
+ if "VN" in features:
187
+ result["DERIVED_NOUN"] = "VERBAL_NOUN"
188
+ if "INDEF" in features:
189
+ result["DEFINITENESS"] = "INDEFINITE"
190
+ if "P" in features:
191
+ result["COUNT"] = "PLURAL"
192
+ result["UNUSUAL_PLURAL_ORIGINALLY_P_FEATURE"] = True
193
+ return result
194
+
195
+
196
+ def rebuild_records(source_records: list[dict[str, Any]]) -> list[dict[str, Any]]:
197
+ """Derive convenience fields for every parsed source record in order."""
198
+ return [normalize_record(record) for record in source_records]
199
+
200
+
201
+ def canonical_records_bytes(records: list[dict[str, Any]]) -> bytes:
202
+ """Return the stable byte representation used for cache and reproducibility checks."""
203
+ return json.dumps(records, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
204
+
205
+
206
+ def records_digest(records: list[dict[str, Any]]) -> str:
207
+ """Return the SHA-256 of the canonical complete derived representation."""
208
+ return hashlib.sha256(canonical_records_bytes(records)).hexdigest()
209
+
210
+
211
+ def write_records(records: list[dict[str, Any]], output: Path) -> None:
212
+ """Atomically write derived records without touching the source data."""
213
+ output.parent.mkdir(parents=True, exist_ok=True)
214
+ descriptor, temporary_name = tempfile.mkstemp(
215
+ prefix=output.name + ".", suffix=".tmp", dir=output.parent
216
+ )
217
+ try:
218
+ with os.fdopen(descriptor, "wb") as handle:
219
+ handle.write(canonical_records_bytes(records))
220
+ os.replace(temporary_name, output)
221
+ except BaseException:
222
+ with suppress(FileNotFoundError):
223
+ os.unlink(temporary_name)
224
+ raise
225
+
226
+
227
+ def main(argv: list[str] | None = None) -> int:
228
+ """Run deterministic verification and/or write an explicit derived artifact."""
229
+ from .source_data import default_data_directory, verify_data_directory
230
+
231
+ parser = argparse.ArgumentParser(description=__doc__)
232
+ parser.add_argument("--data-dir", type=Path)
233
+ parser.add_argument("--output", type=Path, help="Write derived JSON to this explicit path")
234
+ parser.add_argument("--check", action="store_true", help="Verify source and derived records")
235
+ arguments = parser.parse_args(argv)
236
+ if not arguments.check and not arguments.output:
237
+ parser.error("choose --check and/or --output")
238
+
239
+ data_dir = arguments.data_dir or default_data_directory()
240
+ manifest = verify_data_directory(data_dir)
241
+ source = manifest["sources"]["morphology"]
242
+ records = rebuild_records(load_source_records(data_dir / source["filename"]))
243
+ if len(records) != source["records"]:
244
+ print(f"Derived record count differs: {len(records):,} != {source['records']:,}")
245
+ return 1
246
+ digest = records_digest(records)
247
+ if arguments.check:
248
+ expected = SUPPORTED_DERIVED_DIGESTS.get(source["sha256"])
249
+ if expected is None or digest != expected:
250
+ print(f"Derived digest mismatch: {digest} != {expected or 'unsupported source'}")
251
+ return 1
252
+ print(
253
+ f"Verified source {source['sha256']} and deterministically derived "
254
+ f"{len(records):,} records ({digest})"
255
+ )
256
+ if arguments.output:
257
+ write_records(records, arguments.output)
258
+ print(f"Wrote {len(records):,} locally derived records to {arguments.output}")
259
+ return 0
260
+
261
+
262
+ if __name__ == "__main__":
263
+ raise SystemExit(main())
@@ -0,0 +1,142 @@
1
+ """Conventional chapter labels; source attribution is in THIRD_PARTY_NOTICES.md.
2
+
3
+ Arabic/English labels and verse counts: Tanzil metadata v1.0, (C) 2008-2009
4
+ Tanzil.info, upstream license identifier cc-by (version unspecified).
5
+ ASCII transliterations are this project's conventional labels.
6
+ Source: https://tanzil.net/res/text/metadata/quran-data.xml
7
+ Source SHA-256: 8867c1d88191472adec9db694b3cd9f135b1a2ef580574d32cf888dcb22c5c7a
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ # (ASCII transliteration, Arabic name, English meaning, verse count)
13
+ CHAPTERS: tuple[tuple[str, str, str, int], ...] = (
14
+ ("al-Fatihah", "الفاتحة", "The Opening", 7),
15
+ ("al-Baqarah", "البقرة", "The Cow", 286),
16
+ ("Al Imran", "آل عمران", "The Family of Imraan", 200),
17
+ ("an-Nisa", "النساء", "The Women", 176),
18
+ ("al-Maidah", "المائدة", "The Table", 120),
19
+ ("al-Anam", "الأنعام", "The Cattle", 165),
20
+ ("al-Araf", "الأعراف", "The Heights", 206),
21
+ ("al-Anfal", "الأنفال", "The Spoils of War", 75),
22
+ ("at-Tawbah", "التوبة", "The Repentance", 129),
23
+ ("Yunus", "يونس", "Jonas", 109),
24
+ ("Hud", "هود", "Hud", 123),
25
+ ("Yusuf", "يوسف", "Joseph", 111),
26
+ ("ar-Rad", "الرعد", "The Thunder", 43),
27
+ ("Ibrahim", "ابراهيم", "Abraham", 52),
28
+ ("al-Hijr", "الحجر", "The Rock", 99),
29
+ ("an-Nahl", "النحل", "The Bee", 128),
30
+ ("al-Isra", "الإسراء", "The Night Journey", 111),
31
+ ("al-Kahf", "الكهف", "The Cave", 110),
32
+ ("Maryam", "مريم", "Mary", 98),
33
+ ("Ta-Ha", "طه", "Taa-Haa", 135),
34
+ ("al-Anbiya", "الأنبياء", "The Prophets", 112),
35
+ ("al-Hajj", "الحج", "The Pilgrimage", 78),
36
+ ("al-Muminun", "المؤمنون", "The Believers", 118),
37
+ ("an-Nur", "النور", "The Light", 64),
38
+ ("al-Furqan", "الفرقان", "The Criterion", 77),
39
+ ("ash-Shuara", "الشعراء", "The Poets", 227),
40
+ ("an-Naml", "النمل", "The Ant", 93),
41
+ ("al-Qasas", "القصص", "The Stories", 88),
42
+ ("al-Ankabut", "العنكبوت", "The Spider", 69),
43
+ ("ar-Rum", "الروم", "The Romans", 60),
44
+ ("Luqman", "لقمان", "Luqman", 34),
45
+ ("as-Sajdah", "السجدة", "The Prostration", 30),
46
+ ("al-Ahzab", "الأحزاب", "The Clans", 73),
47
+ ("Saba", "سبإ", "Sheba", 54),
48
+ ("Fatir", "فاطر", "The Originator", 45),
49
+ ("Ya-Sin", "يس", "Yaseen", 83),
50
+ ("as-Saffat", "الصافات", "Those drawn up in Ranks", 182),
51
+ ("Sad", "ص", "The letter Saad", 88),
52
+ ("az-Zumar", "الزمر", "The Groups", 75),
53
+ ("Ghafir", "غافر", "The Forgiver", 85),
54
+ ("Fussilat", "فصلت", "Explained in detail", 54),
55
+ ("ash-Shura", "الشورى", "Consultation", 53),
56
+ ("az-Zukhruf", "الزخرف", "Ornaments of gold", 89),
57
+ ("ad-Dukhan", "الدخان", "The Smoke", 59),
58
+ ("al-Jathiyah", "الجاثية", "Crouching", 37),
59
+ ("al-Ahqaf", "الأحقاف", "The Dunes", 35),
60
+ ("Muhammad", "محمد", "Muhammad", 38),
61
+ ("al-Fath", "الفتح", "The Victory", 29),
62
+ ("al-Hujurat", "الحجرات", "The Inner Apartments", 18),
63
+ ("Qaf", "ق", "The letter Qaaf", 45),
64
+ ("adh-Dhariyat", "الذاريات", "The Winnowing Winds", 60),
65
+ ("at-Tur", "الطور", "The Mount", 49),
66
+ ("an-Najm", "النجم", "The Star", 62),
67
+ ("al-Qamar", "القمر", "The Moon", 55),
68
+ ("ar-Rahman", "الرحمن", "The Beneficent", 78),
69
+ ("al-Waqiah", "الواقعة", "The Inevitable", 96),
70
+ ("al-Hadid", "الحديد", "The Iron", 29),
71
+ ("al-Mujadilah", "المجادلة", "The Pleading Woman", 22),
72
+ ("al-Hashr", "الحشر", "The Exile", 24),
73
+ ("al-Mumtahanah", "الممتحنة", "She that is to be examined", 13),
74
+ ("as-Saff", "الصف", "The Ranks", 14),
75
+ ("al-Jumuah", "الجمعة", "Friday", 11),
76
+ ("al-Munafiqun", "المنافقون", "The Hypocrites", 11),
77
+ ("at-Taghabun", "التغابن", "Mutual Disillusion", 18),
78
+ ("at-Talaq", "الطلاق", "Divorce", 12),
79
+ ("at-Tahrim", "التحريم", "The Prohibition", 12),
80
+ ("al-Mulk", "الملك", "The Sovereignty", 30),
81
+ ("al-Qalam", "القلم", "The Pen", 52),
82
+ ("al-Haqqah", "الحاقة", "The Reality", 52),
83
+ ("al-Maarij", "المعارج", "The Ascending Stairways", 44),
84
+ ("Nuh", "نوح", "Noah", 28),
85
+ ("al-Jinn", "الجن", "The Jinn", 28),
86
+ ("al-Muzzammil", "المزمل", "The Enshrouded One", 20),
87
+ ("al-Muddaththir", "المدثر", "The Cloaked One", 56),
88
+ ("al-Qiyamah", "القيامة", "The Resurrection", 40),
89
+ ("al-Insan", "الانسان", "Man", 31),
90
+ ("al-Mursalat", "المرسلات", "The Emissaries", 50),
91
+ ("an-Naba", "النبإ", "The Announcement", 40),
92
+ ("an-Naziat", "النازعات", "Those who drag forth", 46),
93
+ ("Abasa", "عبس", "He frowned", 42),
94
+ ("at-Takwir", "التكوير", "The Overthrowing", 29),
95
+ ("al-Infitar", "الإنفطار", "The Cleaving", 19),
96
+ ("al-Mutaffifin", "المطففين", "Defrauding", 36),
97
+ ("al-Inshiqaq", "الإنشقاق", "The Splitting Open", 25),
98
+ ("al-Buruj", "البروج", "The Constellations", 22),
99
+ ("at-Tariq", "الطارق", "The Morning Star", 17),
100
+ ("al-Ala", "الأعلى", "The Most High", 19),
101
+ ("al-Ghashiyah", "الغاشية", "The Overwhelming", 26),
102
+ ("al-Fajr", "الفجر", "The Dawn", 30),
103
+ ("al-Balad", "البلد", "The City", 20),
104
+ ("ash-Shams", "الشمس", "The Sun", 15),
105
+ ("al-Layl", "الليل", "The Night", 21),
106
+ ("ad-Duha", "الضحى", "The Morning Hours", 11),
107
+ ("ash-Sharh", "الشرح", "The Consolation", 8),
108
+ ("at-Tin", "التين", "The Fig", 8),
109
+ ("al-Alaq", "العلق", "The Clot", 19),
110
+ ("al-Qadr", "القدر", "The Power, Fate", 5),
111
+ ("al-Bayyinah", "البينة", "The Evidence", 8),
112
+ ("az-Zalzalah", "الزلزلة", "The Earthquake", 8),
113
+ ("al-Adiyat", "العاديات", "The Chargers", 11),
114
+ ("al-Qariah", "القارعة", "The Calamity", 11),
115
+ ("at-Takathur", "التكاثر", "Competition", 8),
116
+ ("al-Asr", "العصر", "The Declining Day, Epoch", 3),
117
+ ("al-Humazah", "الهمزة", "The Traducer", 9),
118
+ ("al-Fil", "الفيل", "The Elephant", 5),
119
+ ("Quraysh", "قريش", "Quraysh", 4),
120
+ ("al-Maun", "الماعون", "Almsgiving", 7),
121
+ ("al-Kawthar", "الكوثر", "Abundance", 3),
122
+ ("al-Kafirun", "الكافرون", "The Disbelievers", 6),
123
+ ("an-Nasr", "النصر", "Divine Support", 3),
124
+ ("al-Masad", "المسد", "The Palm Fibre", 5),
125
+ ("al-Ikhlas", "الإخلاص", "Sincerity", 4),
126
+ ("al-Falaq", "الفلق", "The Dawn", 5),
127
+ ("an-Nas", "الناس", "Mankind", 6),
128
+ )
129
+
130
+
131
+ def chapter_name(number: int, representation: str = "transliteration") -> str:
132
+ """Return a chapter name in transliteration, Arabic, or English."""
133
+ if isinstance(number, bool) or not isinstance(number, int) or not 1 <= number <= 114:
134
+ raise ValueError("chapter number must be an integer from 1 through 114")
135
+ aliases = {"transliteration": 0, "ascii": 0, "arabic": 1, "english": 2, "translation": 2}
136
+ try:
137
+ index = aliases[representation.lower()]
138
+ except KeyError as error:
139
+ raise ValueError(
140
+ "name representation must be transliteration, arabic, or english"
141
+ ) from error
142
+ return str(CHAPTERS[number - 1][index])
@@ -0,0 +1,95 @@
1
+ """Command-line inspection, text search, provenance, and validation utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from .corpus import QuranCorpus
9
+ from .validation import validate_corpus
10
+
11
+
12
+ def main(argv: list[str] | None = None) -> int:
13
+ """Run the ``quran-toolkit`` command and return a process exit status."""
14
+ parser = argparse.ArgumentParser(prog="quran-toolkit")
15
+ parser.add_argument("--data-dir", type=Path)
16
+ parser.add_argument("--translation", type=Path, help="User-supplied numbered translation")
17
+ commands = parser.add_subparsers(dest="command", required=True)
18
+ commands.add_parser("stats", help="Show corpus object counts")
19
+ commands.add_parser("sources", help="Show source provenance and licenses")
20
+ commands.add_parser("validate", help="Validate structure and reproducibility")
21
+ word_parser = commands.add_parser("word", help="Inspect a word at chapter:verse:word")
22
+ word_parser.add_argument("address")
23
+ search_parser = commands.add_parser("search", help="Search Arabic or Buckwalter text")
24
+ search_parser.add_argument("text")
25
+ search_parser.add_argument("--level", choices=("verse", "word"), default="verse")
26
+ search_parser.add_argument(
27
+ "--representation",
28
+ choices=("auto", "arabic", "buckwalter", "transliteration", "translation", "english"),
29
+ default="auto",
30
+ )
31
+ search_parser.add_argument("--strip-diacritics", action="store_true")
32
+ search_parser.add_argument("--strip-quranic-marks", action="store_true")
33
+ search_parser.add_argument("--normalize-alif", action="store_true")
34
+ search_parser.add_argument("--normalize-ya", action="store_true")
35
+ search_parser.add_argument("--remove-spaces", action="store_true")
36
+ search_parser.add_argument("--limit", type=int, default=20)
37
+ arguments = parser.parse_args(argv)
38
+
39
+ if arguments.command == "validate":
40
+ issues = validate_corpus(arguments.data_dir)
41
+ if not issues:
42
+ print("Corpus validation passed")
43
+ return 0
44
+ for issue in issues:
45
+ print(f"{issue.severity.upper()} [{issue.code}] {issue.message}")
46
+ return int(any(issue.severity == "error" for issue in issues))
47
+
48
+ corpus = QuranCorpus.load(arguments.data_dir, translation=arguments.translation)
49
+ if arguments.command == "stats":
50
+ for label, count in corpus.stats().items():
51
+ print(f"{label}: {count:,}")
52
+ return 0
53
+ if arguments.command == "search":
54
+ if arguments.limit < 0:
55
+ parser.error("--limit cannot be negative")
56
+ results = corpus.search(
57
+ arguments.text,
58
+ level=arguments.level,
59
+ representation=arguments.representation,
60
+ strip_diacritics=arguments.strip_diacritics,
61
+ strip_quranic_marks=arguments.strip_quranic_marks,
62
+ normalize_alif=arguments.normalize_alif,
63
+ normalize_ya=arguments.normalize_ya,
64
+ remove_spaces=arguments.remove_spaces,
65
+ )
66
+ results.show(
67
+ arguments.representation if arguments.representation != "auto" else "arabic",
68
+ limit=arguments.limit,
69
+ )
70
+ print(f"matches: {len(results):,}")
71
+ return 0
72
+ if arguments.command == "sources":
73
+ for source in corpus.provenance()["sources"].values():
74
+ print(f"{source['title']} {source.get('version', '')}".rstrip())
75
+ print(f" SHA-256: {source['sha256']}")
76
+ print(f" license: {source['license']}")
77
+ if source.get("official_url"):
78
+ print(f" source: {source['official_url']}")
79
+ return 0
80
+ try:
81
+ chapter, verse, word = map(int, arguments.address.strip("()").split(":"))
82
+ except ValueError:
83
+ parser.error("word address must have the form chapter:verse:word")
84
+ item = corpus.word(chapter, verse, word)
85
+ print(f"address: {item.address}")
86
+ print(f"arabic: {item.arabic_text}")
87
+ print(f"transliteration: {item.transliteration}")
88
+ print(f"parts of speech: {item.parts_of_speech}")
89
+ print(f"roots: {item.roots}")
90
+ print(f"lemmas: {item.lemmas}")
91
+ return 0
92
+
93
+
94
+ if __name__ == "__main__":
95
+ raise SystemExit(main())