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,611 @@
|
|
|
1
|
+
"""Load verified source data into the public, queryable Quran object graph.
|
|
2
|
+
|
|
3
|
+
This module is the boundary between byte-preserved third-party data and convenient
|
|
4
|
+
Python objects. Loading verifies all source hashes, derives normalized morphology,
|
|
5
|
+
aligns it with Uthmani surface text, and constructs semantic indexes. It never writes
|
|
6
|
+
to the installed data package; the optional disk cache contains only reproducible
|
|
7
|
+
derived JSON in the user's cache directory.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import copy
|
|
13
|
+
import os
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from collections.abc import Callable, Iterable
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from threading import Lock
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .collections import IndexedCollection, OneBasedCollection, QuerySet, resolve_value
|
|
21
|
+
from .models import (
|
|
22
|
+
Chapter,
|
|
23
|
+
Juz,
|
|
24
|
+
Token,
|
|
25
|
+
Verse,
|
|
26
|
+
VerseAddress,
|
|
27
|
+
Word,
|
|
28
|
+
WordAddress,
|
|
29
|
+
)
|
|
30
|
+
from .source_data import (
|
|
31
|
+
DataError,
|
|
32
|
+
default_cache_directory,
|
|
33
|
+
default_data_directory,
|
|
34
|
+
load_normalized_records,
|
|
35
|
+
)
|
|
36
|
+
from .text import QURANIC_PAUSE_TRANSLATION
|
|
37
|
+
from .translations import Translation, TranslationCoverage, load_translation
|
|
38
|
+
|
|
39
|
+
_CACHE: dict[tuple[Any, ...], QuranCorpus] = {}
|
|
40
|
+
_CACHE_LOCK = Lock()
|
|
41
|
+
|
|
42
|
+
# QAC's nominal/particle families, with DET (the segmented article) included
|
|
43
|
+
# among particles. INL (disconnected letters) is deliberately neither family.
|
|
44
|
+
# https://corpus.quran.com/documentation/tagset.jsp
|
|
45
|
+
_NOMINAL_TAGS = ("N", "PN", "ADJ", "IMPN", "PRON", "DEM", "REL", "T", "LOC")
|
|
46
|
+
_PARTICLE_TAGS = (
|
|
47
|
+
"P",
|
|
48
|
+
"EMPH",
|
|
49
|
+
"IMPV",
|
|
50
|
+
"PRP",
|
|
51
|
+
"CONJ",
|
|
52
|
+
"SUB",
|
|
53
|
+
"ACC",
|
|
54
|
+
"AMD",
|
|
55
|
+
"ANS",
|
|
56
|
+
"AVR",
|
|
57
|
+
"CAUS",
|
|
58
|
+
"CERT",
|
|
59
|
+
"CIRC",
|
|
60
|
+
"COM",
|
|
61
|
+
"COND",
|
|
62
|
+
"EQ",
|
|
63
|
+
"EXH",
|
|
64
|
+
"EXL",
|
|
65
|
+
"EXP",
|
|
66
|
+
"FUT",
|
|
67
|
+
"INC",
|
|
68
|
+
"INT",
|
|
69
|
+
"INTG",
|
|
70
|
+
"NEG",
|
|
71
|
+
"PREV",
|
|
72
|
+
"PRO",
|
|
73
|
+
"REM",
|
|
74
|
+
"RES",
|
|
75
|
+
"RET",
|
|
76
|
+
"RSLT",
|
|
77
|
+
"SUP",
|
|
78
|
+
"SUR",
|
|
79
|
+
"VOC",
|
|
80
|
+
"DET",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
CorpusError = DataError
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class CorpusAlignmentError(DataError):
|
|
87
|
+
"""Raised when morphology word boundaries and Uthmani text cannot be aligned."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_translation_path(
|
|
91
|
+
translation: str | os.PathLike[str] | Translation | None,
|
|
92
|
+
) -> Path | None:
|
|
93
|
+
if isinstance(translation, Translation):
|
|
94
|
+
return translation.path
|
|
95
|
+
configured = translation or os.environ.get("QURAN_PROCESSING_TOOLKIT_TRANSLATION")
|
|
96
|
+
if configured is None:
|
|
97
|
+
return None
|
|
98
|
+
path = Path(configured).expanduser().resolve()
|
|
99
|
+
if not path.is_file():
|
|
100
|
+
raise CorpusError(f"Configured translation file does not exist: {path}")
|
|
101
|
+
return path
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _read_numbered_text(path: Path, delimiter: str = "|") -> dict[VerseAddress, str]:
|
|
105
|
+
result: dict[VerseAddress, str] = {}
|
|
106
|
+
with path.open(encoding="utf-8") as handle:
|
|
107
|
+
for line_number, raw_line in enumerate(handle, 1):
|
|
108
|
+
line = raw_line.rstrip("\n")
|
|
109
|
+
if not line or line.startswith("#"):
|
|
110
|
+
continue
|
|
111
|
+
parts = line.split(delimiter, 2)
|
|
112
|
+
if len(parts) != 3:
|
|
113
|
+
raise CorpusError(f"Malformed line {line_number} in {path}")
|
|
114
|
+
try:
|
|
115
|
+
address = VerseAddress(int(parts[0]), int(parts[1]))
|
|
116
|
+
except ValueError as error:
|
|
117
|
+
raise CorpusError(f"Malformed address on line {line_number} in {path}") from error
|
|
118
|
+
if address in result:
|
|
119
|
+
raise CorpusError(f"Duplicate verse {address} in {path}")
|
|
120
|
+
result[address] = parts[2]
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_BASMALA_FORMS = (
|
|
125
|
+
"بِسْمِ ٱللَّهِ ٱلرَّحْمَـٰنِ ٱلرَّحِيمِ ",
|
|
126
|
+
"بِّسْمِ ٱللَّهِ ٱلرَّحْمَـٰنِ ٱلرَّحِيمِ ",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _without_non_verse_basmala(address: VerseAddress, text: str) -> str:
|
|
131
|
+
if address.chapter == 1 or address.verse != 1:
|
|
132
|
+
return text
|
|
133
|
+
for basmala in _BASMALA_FORMS:
|
|
134
|
+
if text.startswith(basmala):
|
|
135
|
+
return text[len(basmala) :]
|
|
136
|
+
return text
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _arabic_words(address: VerseAddress, text: str) -> list[str]:
|
|
140
|
+
# These are orthographic contractions represented as one corpus word.
|
|
141
|
+
text = text.replace("بَعْدَ مَا", "بَعْد###مَا")
|
|
142
|
+
text = text.replace("إِلْ يَاسِينَ", "إِلْ###يَاسِينَ")
|
|
143
|
+
text = text.translate(QURANIC_PAUSE_TRANSLATION)
|
|
144
|
+
return [word.replace("###", " ") for word in text.strip().split()]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class QuranCorpus:
|
|
148
|
+
"""The complete Quran corpus with queryable, semantic 1-based indexes.
|
|
149
|
+
|
|
150
|
+
Constructing this class directly always builds a new object graph. Most programs
|
|
151
|
+
should call :func:`load_quran`, which reuses an in-process instance by default.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
__slots__ = (
|
|
155
|
+
"data_dir",
|
|
156
|
+
"translation_path",
|
|
157
|
+
"translation",
|
|
158
|
+
"chapters",
|
|
159
|
+
"verses",
|
|
160
|
+
"words",
|
|
161
|
+
"tokens",
|
|
162
|
+
"juzs",
|
|
163
|
+
"_word_indexes",
|
|
164
|
+
"_verbs",
|
|
165
|
+
"_nouns",
|
|
166
|
+
"_token_selections",
|
|
167
|
+
"_manifest",
|
|
168
|
+
"_disk_cache",
|
|
169
|
+
"_cache_dir",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self,
|
|
174
|
+
data_dir: str | os.PathLike[str] | None = None,
|
|
175
|
+
*,
|
|
176
|
+
translation: str | os.PathLike[str] | Translation | None = None,
|
|
177
|
+
disk_cache: bool = True,
|
|
178
|
+
cache_dir: str | os.PathLike[str] | None = None,
|
|
179
|
+
) -> None:
|
|
180
|
+
self.data_dir = (
|
|
181
|
+
Path(data_dir).expanduser().resolve() if data_dir else default_data_directory()
|
|
182
|
+
)
|
|
183
|
+
self.translation_path = _resolve_translation_path(translation)
|
|
184
|
+
self.translation = (
|
|
185
|
+
translation
|
|
186
|
+
if isinstance(translation, Translation)
|
|
187
|
+
else load_translation(self.translation_path)
|
|
188
|
+
if self.translation_path
|
|
189
|
+
else None
|
|
190
|
+
)
|
|
191
|
+
self._disk_cache = disk_cache
|
|
192
|
+
self._cache_dir = Path(cache_dir).expanduser().resolve() if cache_dir else None
|
|
193
|
+
self._word_indexes: dict[str, dict[Any, tuple[Word, ...]]] = {}
|
|
194
|
+
self._verbs: QuerySet[Word] | None = None
|
|
195
|
+
self._nouns: QuerySet[Word] | None = None
|
|
196
|
+
self._token_selections: dict[tuple[str, ...], QuerySet[Token]] = {}
|
|
197
|
+
self._build()
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def load(
|
|
201
|
+
cls,
|
|
202
|
+
data_dir: str | os.PathLike[str] | None = None,
|
|
203
|
+
*,
|
|
204
|
+
translation: str | os.PathLike[str] | Translation | None = None,
|
|
205
|
+
cache: bool = True,
|
|
206
|
+
disk_cache: bool = True,
|
|
207
|
+
cache_dir: str | os.PathLike[str] | None = None,
|
|
208
|
+
) -> QuranCorpus:
|
|
209
|
+
"""Load a corpus, reusing an equivalent in-process instance by default.
|
|
210
|
+
|
|
211
|
+
``cache`` controls whole-object reuse within this Python process.
|
|
212
|
+
``disk_cache`` controls the verified derived-record cache between processes.
|
|
213
|
+
They are independent: disabling one does not disable the other.
|
|
214
|
+
"""
|
|
215
|
+
path = Path(data_dir).expanduser().resolve() if data_dir else default_data_directory()
|
|
216
|
+
translation_path = _resolve_translation_path(translation)
|
|
217
|
+
resolved_cache_dir = (
|
|
218
|
+
Path(cache_dir).expanduser().resolve() if cache_dir else default_cache_directory()
|
|
219
|
+
)
|
|
220
|
+
translation_key: tuple[Any, ...] | None = None
|
|
221
|
+
if translation_path:
|
|
222
|
+
stat = translation_path.stat()
|
|
223
|
+
translation_key = (translation_path, stat.st_size, stat.st_mtime_ns)
|
|
224
|
+
if isinstance(translation, Translation):
|
|
225
|
+
# Layer content AND attribution affect corpus identity. A raw path
|
|
226
|
+
# must not reuse a previously labeled downloaded Translation object.
|
|
227
|
+
translation_key += (translation.fingerprint,)
|
|
228
|
+
# Loading options belong in the cache key. Otherwise a previous default load
|
|
229
|
+
# could silently defeat a later request for a specific cache directory.
|
|
230
|
+
key = (path, translation_key, disk_cache, resolved_cache_dir)
|
|
231
|
+
if not cache:
|
|
232
|
+
return cls(
|
|
233
|
+
path,
|
|
234
|
+
translation=translation
|
|
235
|
+
if isinstance(translation, Translation)
|
|
236
|
+
else translation_path,
|
|
237
|
+
disk_cache=disk_cache,
|
|
238
|
+
cache_dir=resolved_cache_dir,
|
|
239
|
+
)
|
|
240
|
+
with _CACHE_LOCK:
|
|
241
|
+
corpus = _CACHE.get(key)
|
|
242
|
+
if corpus is None:
|
|
243
|
+
corpus = cls(
|
|
244
|
+
path,
|
|
245
|
+
translation=translation
|
|
246
|
+
if isinstance(translation, Translation)
|
|
247
|
+
else translation_path,
|
|
248
|
+
disk_cache=disk_cache,
|
|
249
|
+
cache_dir=resolved_cache_dir,
|
|
250
|
+
)
|
|
251
|
+
_CACHE[key] = corpus
|
|
252
|
+
return corpus
|
|
253
|
+
|
|
254
|
+
def _build(self) -> None:
|
|
255
|
+
records, self._manifest = load_normalized_records(
|
|
256
|
+
self.data_dir,
|
|
257
|
+
disk_cache=self._disk_cache,
|
|
258
|
+
cache_dir=self._cache_dir,
|
|
259
|
+
)
|
|
260
|
+
sources = self._manifest["sources"]
|
|
261
|
+
arabic = _read_numbered_text(self.data_dir / sources["arabic_text"]["filename"])
|
|
262
|
+
translations = self.translation.texts if self.translation else {}
|
|
263
|
+
|
|
264
|
+
token_groups: defaultdict[tuple[int, int, int], list[Token]] = defaultdict(list)
|
|
265
|
+
verse_addresses: defaultdict[tuple[int, int], list[tuple[int, int, int]]] = defaultdict(
|
|
266
|
+
list
|
|
267
|
+
)
|
|
268
|
+
all_tokens: list[Token] = []
|
|
269
|
+
last_address: tuple[int, int, int, int] | None = None
|
|
270
|
+
for record in records:
|
|
271
|
+
record_address = (
|
|
272
|
+
int(record["CHAPTER"]),
|
|
273
|
+
int(record["VERSE"]),
|
|
274
|
+
int(record["WORD"]),
|
|
275
|
+
int(record["WORD_PART"]),
|
|
276
|
+
)
|
|
277
|
+
if last_address is not None and record_address <= last_address:
|
|
278
|
+
raise CorpusError(
|
|
279
|
+
f"Morphology records are not strictly ordered at {record_address}"
|
|
280
|
+
)
|
|
281
|
+
last_address = record_address
|
|
282
|
+
token = Token(record)
|
|
283
|
+
word_address = record_address[:3]
|
|
284
|
+
if word_address not in token_groups:
|
|
285
|
+
verse_addresses[word_address[:2]].append(word_address)
|
|
286
|
+
token_groups[word_address].append(token)
|
|
287
|
+
all_tokens.append(token)
|
|
288
|
+
|
|
289
|
+
verse_word_groups: defaultdict[VerseAddress, list[Word]] = defaultdict(list)
|
|
290
|
+
all_words: list[Word] = []
|
|
291
|
+
for verse_key in sorted(verse_addresses):
|
|
292
|
+
verse_address = VerseAddress(*verse_key)
|
|
293
|
+
word_addresses = verse_addresses[verse_key]
|
|
294
|
+
if verse_address not in arabic:
|
|
295
|
+
raise CorpusAlignmentError(f"Missing Arabic text for verse {verse_address}")
|
|
296
|
+
verse_arabic = _without_non_verse_basmala(verse_address, arabic[verse_address])
|
|
297
|
+
surface_words = _arabic_words(verse_address, verse_arabic)
|
|
298
|
+
if len(surface_words) != len(word_addresses):
|
|
299
|
+
raise CorpusAlignmentError(
|
|
300
|
+
f"Verse {verse_address} has {len(surface_words)} Arabic words but "
|
|
301
|
+
f"{len(word_addresses)} morphology words"
|
|
302
|
+
)
|
|
303
|
+
for word_address, surface in zip(word_addresses, surface_words, strict=True):
|
|
304
|
+
word = Word(WordAddress(*word_address), token_groups[word_address], surface)
|
|
305
|
+
verse_word_groups[verse_address].append(word)
|
|
306
|
+
all_words.append(word)
|
|
307
|
+
|
|
308
|
+
chapter_verse_groups: defaultdict[int, list[Verse]] = defaultdict(list)
|
|
309
|
+
all_verses: list[Verse] = []
|
|
310
|
+
for verse_address in sorted(verse_word_groups):
|
|
311
|
+
verse_arabic = _without_non_verse_basmala(verse_address, arabic[verse_address])
|
|
312
|
+
verse = Verse(
|
|
313
|
+
verse_address,
|
|
314
|
+
verse_word_groups[verse_address],
|
|
315
|
+
verse_arabic,
|
|
316
|
+
translations.get((verse_address.chapter, verse_address.verse)),
|
|
317
|
+
)
|
|
318
|
+
chapter_verse_groups[verse_address.chapter].append(verse)
|
|
319
|
+
all_verses.append(verse)
|
|
320
|
+
|
|
321
|
+
all_chapters = [
|
|
322
|
+
Chapter(number, chapter_verse_groups[number]) for number in sorted(chapter_verse_groups)
|
|
323
|
+
]
|
|
324
|
+
self.chapters = OneBasedCollection(all_chapters)
|
|
325
|
+
self.verses = IndexedCollection(
|
|
326
|
+
all_verses,
|
|
327
|
+
key=lambda verse: (verse.address.chapter, verse.address.verse),
|
|
328
|
+
)
|
|
329
|
+
self.words = IndexedCollection(all_words, key=lambda word: word.address.as_tuple())
|
|
330
|
+
self.tokens = IndexedCollection(all_tokens, key=lambda token: token.address_tuple)
|
|
331
|
+
self.juzs = self._build_juzs()
|
|
332
|
+
|
|
333
|
+
def _build_juzs(self) -> OneBasedCollection[Juz]:
|
|
334
|
+
starts: list[VerseAddress] = []
|
|
335
|
+
filename = self._manifest["sources"]["juz_boundaries"]["filename"]
|
|
336
|
+
with (self.data_dir / filename).open(encoding="utf-8") as handle:
|
|
337
|
+
for line_number, line in enumerate(handle, 1):
|
|
338
|
+
parts = line.rstrip("\n").split("\t")
|
|
339
|
+
if len(parts) != 3 or int(parts[0]) != line_number:
|
|
340
|
+
raise CorpusError(f"Malformed juz boundary on line {line_number}")
|
|
341
|
+
starts.append(VerseAddress(int(parts[1]), int(parts[2])))
|
|
342
|
+
verse_positions = {verse.address: index for index, verse in enumerate(self.verses)}
|
|
343
|
+
juzs: list[Juz] = []
|
|
344
|
+
for index, start in enumerate(starts):
|
|
345
|
+
start_position = verse_positions[start]
|
|
346
|
+
end_position = (
|
|
347
|
+
verse_positions[starts[index + 1]] if index + 1 < len(starts) else len(self.verses)
|
|
348
|
+
)
|
|
349
|
+
juzs.append(
|
|
350
|
+
Juz(index + 1, start, QuerySet(self.verses.values()[start_position:end_position]))
|
|
351
|
+
)
|
|
352
|
+
return OneBasedCollection(juzs)
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def verb_words(self) -> QuerySet[Word]:
|
|
356
|
+
"""Return all words with at least one verb stem, computed lazily once."""
|
|
357
|
+
if self._verbs is None:
|
|
358
|
+
self._verbs = self.words.filter(Word.is_verb)
|
|
359
|
+
return self._verbs
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def noun_words(self) -> QuerySet[Word]:
|
|
363
|
+
"""Return all words with at least one noun stem, computed lazily once."""
|
|
364
|
+
if self._nouns is None:
|
|
365
|
+
self._nouns = self.words.filter(Word.is_noun)
|
|
366
|
+
return self._nouns
|
|
367
|
+
|
|
368
|
+
def _tokens_with_tags(self, *tags: str) -> QuerySet[Token]:
|
|
369
|
+
"""Cache each token selection lazily while preserving corpus order."""
|
|
370
|
+
if tags not in self._token_selections:
|
|
371
|
+
wanted = frozenset(tags)
|
|
372
|
+
self._token_selections[tags] = self.tokens.filter(lambda token: token.tag in wanted)
|
|
373
|
+
return self._token_selections[tags]
|
|
374
|
+
|
|
375
|
+
def verbs(self) -> QuerySet[Token]:
|
|
376
|
+
"""Return verb tokens (V), equivalent to ``tokens.with_tag('V')``."""
|
|
377
|
+
return self._tokens_with_tags("V")
|
|
378
|
+
|
|
379
|
+
def nouns(self) -> QuerySet[Token]:
|
|
380
|
+
"""Return noun tokens (N); use proper_nouns() for PN or nominals() for all."""
|
|
381
|
+
return self._tokens_with_tags("N")
|
|
382
|
+
|
|
383
|
+
def proper_nouns(self) -> QuerySet[Token]:
|
|
384
|
+
"""Return proper-noun tokens (PN)."""
|
|
385
|
+
return self._tokens_with_tags("PN")
|
|
386
|
+
|
|
387
|
+
def adjectives(self) -> QuerySet[Token]:
|
|
388
|
+
"""Return adjective tokens (ADJ)."""
|
|
389
|
+
return self._tokens_with_tags("ADJ")
|
|
390
|
+
|
|
391
|
+
def pronouns(self) -> QuerySet[Token]:
|
|
392
|
+
"""Return personal, demonstrative, and relative pronouns (PRON, DEM, REL)."""
|
|
393
|
+
return self._tokens_with_tags("PRON", "DEM", "REL")
|
|
394
|
+
|
|
395
|
+
def adverbs(self) -> QuerySet[Token]:
|
|
396
|
+
"""Return time and location adverb tokens (T, LOC)."""
|
|
397
|
+
return self._tokens_with_tags("T", "LOC")
|
|
398
|
+
|
|
399
|
+
def prepositions(self) -> QuerySet[Token]:
|
|
400
|
+
"""Return preposition tokens (P), including attached prefixes."""
|
|
401
|
+
return self._tokens_with_tags("P")
|
|
402
|
+
|
|
403
|
+
def conjunctions(self) -> QuerySet[Token]:
|
|
404
|
+
"""Return coordinating and subordinating conjunctions (CONJ, SUB)."""
|
|
405
|
+
return self._tokens_with_tags("CONJ", "SUB")
|
|
406
|
+
|
|
407
|
+
def nominals(self) -> QuerySet[Token]:
|
|
408
|
+
"""Return the nominal family: N, PN, ADJ, IMPN, PRON, DEM, REL, T, LOC."""
|
|
409
|
+
return self._tokens_with_tags(*_NOMINAL_TAGS)
|
|
410
|
+
|
|
411
|
+
def particles(self) -> QuerySet[Token]:
|
|
412
|
+
"""Return the QAC particle family plus DET; exclude disconnected letters.
|
|
413
|
+
|
|
414
|
+
Includes prepositions, conjunctions, lam prefixes, and emphatic suffixes,
|
|
415
|
+
not just independent words. See the API reference for the exact tag set.
|
|
416
|
+
"""
|
|
417
|
+
return self._tokens_with_tags(*_PARTICLE_TAGS)
|
|
418
|
+
|
|
419
|
+
def translation_metadata(self) -> dict[str, str] | None:
|
|
420
|
+
"""Return a copy of the supplied translation's provenance, or None."""
|
|
421
|
+
return dict(self.translation.metadata) if self.translation else None
|
|
422
|
+
|
|
423
|
+
def translation_coverage(self, verses: Iterable[Verse] | None = None) -> TranslationCoverage:
|
|
424
|
+
"""Check translation coverage of all verses, or any iterable of Verse objects."""
|
|
425
|
+
addresses = {(v.chapter, v.verse) for v in (self.verses if verses is None else verses)}
|
|
426
|
+
available = self.translation.texts.keys() if self.translation else set()
|
|
427
|
+
missing = tuple(sorted(addresses - available))
|
|
428
|
+
return TranslationCoverage(len(addresses), len(addresses) - len(missing), missing)
|
|
429
|
+
|
|
430
|
+
def require_translation(self, verses: Iterable[Verse] | None = None) -> None:
|
|
431
|
+
"""Raise TranslationNotAvailableError if any requested verse is untranslated."""
|
|
432
|
+
from .text import TranslationNotAvailableError
|
|
433
|
+
|
|
434
|
+
coverage = self.translation_coverage(verses)
|
|
435
|
+
if coverage.missing:
|
|
436
|
+
sample = ", ".join(f"{c}:{v}" for c, v in coverage.missing[:8])
|
|
437
|
+
raise TranslationNotAvailableError(
|
|
438
|
+
f"Translation missing for {len(coverage.missing)} verses: {sample}"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def provenance(self) -> dict[str, Any]:
|
|
442
|
+
"""Return source versions, hashes, origins, and license identifiers."""
|
|
443
|
+
return copy.deepcopy(self._manifest)
|
|
444
|
+
|
|
445
|
+
def licenses(self) -> tuple[dict[str, str], ...]:
|
|
446
|
+
"""Summarize the terms attached to each installed source file."""
|
|
447
|
+
return tuple(
|
|
448
|
+
{
|
|
449
|
+
"source": source["title"],
|
|
450
|
+
"license": source["license"],
|
|
451
|
+
"url": source.get("official_url", ""),
|
|
452
|
+
}
|
|
453
|
+
for source in self._manifest["sources"].values()
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def chapter(self, number: int) -> Chapter:
|
|
457
|
+
"""Return chapter ``number`` using Quranic 1-based numbering."""
|
|
458
|
+
return self.chapters[number]
|
|
459
|
+
|
|
460
|
+
get_chapter = chapter
|
|
461
|
+
|
|
462
|
+
def verse(self, chapter: int, verse: int) -> Verse:
|
|
463
|
+
"""Return one verse by its 1-based chapter and verse numbers."""
|
|
464
|
+
return self.verses[chapter, verse]
|
|
465
|
+
|
|
466
|
+
get_verse = verse
|
|
467
|
+
|
|
468
|
+
def word(self, chapter: int, verse: int, word: int) -> Word:
|
|
469
|
+
"""Return one morphology word by its complete 1-based address."""
|
|
470
|
+
return self.words[chapter, verse, word]
|
|
471
|
+
|
|
472
|
+
get_word = word
|
|
473
|
+
|
|
474
|
+
def token(self, chapter: int, verse: int, word: int, part: int) -> Token:
|
|
475
|
+
"""Return one morphology segment by its complete 1-based address."""
|
|
476
|
+
return self.tokens[chapter, verse, word, part]
|
|
477
|
+
|
|
478
|
+
get_token = token
|
|
479
|
+
|
|
480
|
+
def index_words(self, field: str) -> dict[Any, tuple[Word, ...]]:
|
|
481
|
+
"""Build and cache an inverted word index for a field or property."""
|
|
482
|
+
if field not in self._word_indexes:
|
|
483
|
+
index: defaultdict[Any, list[Word]] = defaultdict(list)
|
|
484
|
+
for word in self.words:
|
|
485
|
+
value = resolve_value(word, field)
|
|
486
|
+
values = value if isinstance(value, tuple) else (value,)
|
|
487
|
+
for member in values:
|
|
488
|
+
index[member].append(word)
|
|
489
|
+
self._word_indexes[field] = {key: tuple(words) for key, words in index.items()}
|
|
490
|
+
return self._word_indexes[field]
|
|
491
|
+
|
|
492
|
+
def find_words(self, **criteria: Any) -> QuerySet[Word]:
|
|
493
|
+
"""Find words by one or more exact attributes.
|
|
494
|
+
|
|
495
|
+
The first equality criterion uses a lazily built inverted index; remaining
|
|
496
|
+
criteria filter only those candidates. Tuple-valued word fields such as
|
|
497
|
+
``roots`` use membership semantics through :meth:`QuerySet.where`.
|
|
498
|
+
"""
|
|
499
|
+
if not criteria:
|
|
500
|
+
return QuerySet(self.words)
|
|
501
|
+
field, expected = next(iter(criteria.items()))
|
|
502
|
+
if callable(expected):
|
|
503
|
+
return self.words.where(**criteria)
|
|
504
|
+
candidates = QuerySet(self.index_words(field).get(expected, ()))
|
|
505
|
+
return candidates.where(**criteria)
|
|
506
|
+
|
|
507
|
+
def search(
|
|
508
|
+
self,
|
|
509
|
+
text: str,
|
|
510
|
+
*,
|
|
511
|
+
level: str = "verse",
|
|
512
|
+
representation: str = "auto",
|
|
513
|
+
**normalization: Any,
|
|
514
|
+
) -> QuerySet[Verse] | QuerySet[Word]:
|
|
515
|
+
"""Search verse or word text without requiring a predicate.
|
|
516
|
+
|
|
517
|
+
ASCII ``text`` is interpreted as Buckwalter when ``representation='auto'``;
|
|
518
|
+
non-ASCII text is interpreted as Arabic. ``level`` accepts singular or plural
|
|
519
|
+
``'verse'`` and ``'word'``. Normalization options are the same as
|
|
520
|
+
:meth:`~quran_processing_toolkit.text.TextMixin.contains`.
|
|
521
|
+
"""
|
|
522
|
+
level = level.lower().rstrip("s")
|
|
523
|
+
if level == "verse":
|
|
524
|
+
return self.verses.contains(text, representation=representation, **normalization)
|
|
525
|
+
if level == "word":
|
|
526
|
+
return self.words.contains(text, representation=representation, **normalization)
|
|
527
|
+
raise ValueError("search() level must be 'verse' or 'word'")
|
|
528
|
+
|
|
529
|
+
def stats(self) -> dict[str, int]:
|
|
530
|
+
"""Return canonical counts; legacy verbs/nouns entries count whole words."""
|
|
531
|
+
return {
|
|
532
|
+
"chapters": len(self.chapters),
|
|
533
|
+
"verses": len(self.verses),
|
|
534
|
+
"words": len(self.words),
|
|
535
|
+
"tokens": len(self.tokens),
|
|
536
|
+
"juzs": len(self.juzs),
|
|
537
|
+
"verbs": len(self.verb_words),
|
|
538
|
+
"nouns": len(self.noun_words),
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
def longest_word_sequence(
|
|
542
|
+
self,
|
|
543
|
+
predicate: Callable[[Word], bool],
|
|
544
|
+
*,
|
|
545
|
+
cross_verse_boundaries: bool = False,
|
|
546
|
+
) -> QuerySet[Word]:
|
|
547
|
+
"""Find the longest contiguous sequence of words matching a predicate."""
|
|
548
|
+
if cross_verse_boundaries:
|
|
549
|
+
return QuerySet(self.words).longest_run(predicate)
|
|
550
|
+
best = QuerySet[Word]()
|
|
551
|
+
for verse in self.verses:
|
|
552
|
+
candidate = QuerySet(verse.words).longest_run(predicate)
|
|
553
|
+
if len(candidate) > len(best):
|
|
554
|
+
best = candidate
|
|
555
|
+
return best
|
|
556
|
+
|
|
557
|
+
def longest_word_sequence_without_letters(
|
|
558
|
+
self,
|
|
559
|
+
letters: str,
|
|
560
|
+
*,
|
|
561
|
+
representation: str = "auto",
|
|
562
|
+
cross_verse_boundaries: bool = False,
|
|
563
|
+
**normalization: Any,
|
|
564
|
+
) -> QuerySet[Word]:
|
|
565
|
+
"""Find the longest word run avoiding letters, with ASCII meaning Buckwalter."""
|
|
566
|
+
return self.longest_word_sequence(
|
|
567
|
+
lambda word: word.contains_no_letters(
|
|
568
|
+
letters, representation=representation, **normalization
|
|
569
|
+
),
|
|
570
|
+
cross_verse_boundaries=cross_verse_boundaries,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def load_quran(
|
|
575
|
+
data_dir: str | os.PathLike[str] | None = None,
|
|
576
|
+
*,
|
|
577
|
+
translation: str | os.PathLike[str] | Translation | None = None,
|
|
578
|
+
cache: bool = True,
|
|
579
|
+
disk_cache: bool = True,
|
|
580
|
+
cache_dir: str | os.PathLike[str] | None = None,
|
|
581
|
+
) -> QuranCorpus:
|
|
582
|
+
"""Load the Quran corpus with optional user data and two levels of caching.
|
|
583
|
+
|
|
584
|
+
Args:
|
|
585
|
+
data_dir: Directory containing a supported source-data manifest and files.
|
|
586
|
+
When omitted, discovery checks the environment override, installed data
|
|
587
|
+
distribution, and finally the monorepo checkout.
|
|
588
|
+
translation: Optional numbered UTF-8/structured JSON file, or a Translation
|
|
589
|
+
returned by load_translation() or download_translation(). The
|
|
590
|
+
``QURAN_PROCESSING_TOOLKIT_TRANSLATION`` environment variable is used
|
|
591
|
+
when this argument is omitted.
|
|
592
|
+
cache: Reuse an equivalent :class:`QuranCorpus` in this process.
|
|
593
|
+
disk_cache: Read and write the verified derived morphology cache.
|
|
594
|
+
cache_dir: Override the derived cache location for this load.
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
A fully validated, immutable-by-convention corpus object graph.
|
|
598
|
+
"""
|
|
599
|
+
return QuranCorpus.load(
|
|
600
|
+
data_dir,
|
|
601
|
+
translation=translation,
|
|
602
|
+
cache=cache,
|
|
603
|
+
disk_cache=disk_cache,
|
|
604
|
+
cache_dir=cache_dir,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def clear_memory_cache() -> None:
|
|
609
|
+
"""Forget all in-process corpus instances without deleting the disk cache."""
|
|
610
|
+
with _CACHE_LOCK:
|
|
611
|
+
_CACHE.clear()
|