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,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()