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,98 @@
1
+ """Pairwise surface-word comparison; corpus-wide research searches live in client scripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .collections import QuerySet
8
+ from .models import Verse
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class VerseMatch:
13
+ """One verse pair and a deterministic one-word edit explanation.
14
+
15
+ Positions refer to the 1-based surface-word sequence, NOT QAC word addresses.
16
+ For addition A is shorter and position_a/word_a are None. Substitution uses
17
+ earlier-addressed A. Shift is oriented so A's word moves later in B; among
18
+ ambiguous explanations, earliest A, B, and positions win. Identical texts do
19
+ not match, even at different addresses. Original Verse objects are retained.
20
+ """
21
+
22
+ kind: str
23
+ verse_a: Verse
24
+ verse_b: Verse
25
+ position_a: int | None
26
+ position_b: int
27
+ word_a: str | None
28
+ word_b: str
29
+
30
+ @property
31
+ def verses(self) -> tuple[Verse, Verse]:
32
+ """Return the original (verse A, verse B) objects."""
33
+ return self.verse_a, self.verse_b
34
+
35
+
36
+ def _address(verse: Verse) -> tuple[int, int]:
37
+ return verse.chapter, verse.verse
38
+
39
+
40
+ def compare_verses(
41
+ verse_a: Verse,
42
+ verse_b: Verse,
43
+ *,
44
+ ignore_pause_marks: bool = False,
45
+ ) -> QuerySet[VerseMatch]:
46
+ """Explain a one-word insertion, substitution, or later movement between two verses.
47
+
48
+ Only these two inputs are compared; this is not a corpus-wide search. Original
49
+ display text is preserved. Addition puts the shorter verse first; substitution
50
+ uses address order; shifts choose the earliest valid A/B addresses and positions.
51
+ """
52
+ if _address(verse_a) == _address(verse_b):
53
+ return QuerySet()
54
+ first, second = sorted((verse_a, verse_b), key=_address)
55
+ a = first.surface_words(ignore_pause_marks=ignore_pause_marks)
56
+ b = second.surface_words(ignore_pause_marks=ignore_pause_marks)
57
+ if a == b:
58
+ return QuerySet()
59
+ if abs(len(a) - len(b)) == 1:
60
+ shorter, longer, short_text, long_text = (
61
+ (first, second, a, b) if len(a) < len(b) else (second, first, b, a)
62
+ )
63
+ for pos, word in enumerate(long_text):
64
+ if long_text[:pos] + long_text[pos + 1 :] == short_text:
65
+ return QuerySet(
66
+ (VerseMatch("addition", shorter, longer, None, pos + 1, None, word),)
67
+ )
68
+ return QuerySet()
69
+ if len(a) != len(b):
70
+ return QuerySet()
71
+ changed = [pos for pos, (left, right) in enumerate(zip(a, b, strict=True)) if left != right]
72
+ if len(changed) == 1:
73
+ pos = changed[0]
74
+ return QuerySet(
75
+ (VerseMatch("substitution", first, second, pos + 1, pos + 1, a[pos], b[pos]),)
76
+ )
77
+ candidates = []
78
+ for source, target, left, right in ((first, second, a, b), (second, first, b, a)):
79
+ for start, word in enumerate(left):
80
+ remainder = left[:start] + left[start + 1 :]
81
+ for end in range(start + 1, len(right)):
82
+ if right[end] == word and right[:end] + right[end + 1 :] == remainder:
83
+ candidates.append(
84
+ VerseMatch("shift", source, target, start + 1, end + 1, word, word)
85
+ )
86
+ if not candidates:
87
+ return QuerySet()
88
+ chosen = min(
89
+ candidates,
90
+ key=lambda m: (
91
+ _address(m.verse_a),
92
+ _address(m.verse_b),
93
+ m.position_a or 0,
94
+ m.position_b,
95
+ m.word_b,
96
+ ),
97
+ )
98
+ return QuerySet((chosen,))
@@ -0,0 +1,201 @@
1
+ """Discovery, integrity verification, and local caching for source data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import tempfile
9
+ from contextlib import suppress
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ SUPPORTED_SOURCE_SHA256 = {
14
+ "quranic-corpus-morphology-0.4.txt": (
15
+ "a1d12923815341face765083805d2148ed2d9f5cc3f7d6665219d887675d8c46"
16
+ ),
17
+ "uthmani-numbered.txt": ("7919b8a04d63b164273a743e0d2538a9704582dd72c2f49e8d557b77dd819e40"),
18
+ "juzz-breakdown.tsv": ("ee3e1e72be6bebe5e0dea0571b2a03a16ebc1fc3ae33397576341a529fb087f2"),
19
+ }
20
+
21
+
22
+ class DataError(RuntimeError):
23
+ """Base class for source discovery, integrity, and corpus-loading failures."""
24
+
25
+
26
+ class DataPackageNotInstalledError(DataError):
27
+ """Raised when no override, installed provider, or checkout data is available."""
28
+
29
+
30
+ class DataIntegrityError(DataError):
31
+ """Raised when source bytes or deterministic derivation fail verification."""
32
+
33
+
34
+ def default_data_directory() -> Path:
35
+ """Find an override, installed data provider, or monorepo source package."""
36
+ override = os.environ.get("QURAN_PROCESSING_TOOLKIT_DATA")
37
+ if override:
38
+ path = Path(override).expanduser().resolve()
39
+ if not path.is_dir():
40
+ raise DataError(f"Configured source-data directory does not exist: {path}")
41
+ return path
42
+
43
+ try:
44
+ from quran_processing_toolkit_qac_data import data_directory
45
+
46
+ return data_directory()
47
+ except ImportError:
48
+ pass
49
+
50
+ checkout = (
51
+ Path(__file__).resolve().parents[1]
52
+ / "packages"
53
+ / "quran_processing_toolkit_qac_data"
54
+ / "src"
55
+ / "quran_processing_toolkit_qac_data"
56
+ / "data"
57
+ )
58
+ if (checkout / "manifest.json").is_file():
59
+ return checkout
60
+ raise DataPackageNotInstalledError(
61
+ "Quran source data is not installed. Install "
62
+ "quran-processing-toolkit-qac-data or pass data_dir=... to load_quran()."
63
+ )
64
+
65
+
66
+ def _sha256(path: Path) -> str:
67
+ digest = hashlib.sha256()
68
+ with path.open("rb") as handle:
69
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
70
+ digest.update(chunk)
71
+ return digest.hexdigest()
72
+
73
+
74
+ def load_manifest(data_dir: Path) -> dict[str, Any]:
75
+ """Load and minimally validate the source-data manifest schema."""
76
+ path = data_dir / "manifest.json"
77
+ try:
78
+ with path.open(encoding="utf-8") as handle:
79
+ manifest: dict[str, Any] = json.load(handle)
80
+ except FileNotFoundError as error:
81
+ raise DataIntegrityError(f"Source-data manifest is missing: {path}") from error
82
+ if manifest.get("schema_version") != 1 or not isinstance(manifest.get("sources"), dict):
83
+ raise DataIntegrityError(f"Unsupported source-data manifest: {path}")
84
+ return manifest
85
+
86
+
87
+ def verify_data_directory(data_dir: Path) -> dict[str, Any]:
88
+ """Verify every source file against its declared byte size and SHA-256."""
89
+ data_dir = data_dir.expanduser().resolve()
90
+ manifest = load_manifest(data_dir)
91
+ for name, source in manifest["sources"].items():
92
+ path = data_dir / source["filename"]
93
+ expected_digest = SUPPORTED_SOURCE_SHA256.get(path.name)
94
+ if expected_digest is None or source["sha256"] != expected_digest:
95
+ raise DataIntegrityError(
96
+ f"Unsupported declared SHA-256 for {path.name}: {source['sha256']}"
97
+ )
98
+ if not path.is_file():
99
+ raise DataIntegrityError(f"Missing {name} source file: {path}")
100
+ actual_size = path.stat().st_size
101
+ if actual_size != source["bytes"]:
102
+ raise DataIntegrityError(
103
+ f"Size mismatch for {path.name}: {actual_size} != {source['bytes']}"
104
+ )
105
+ actual_digest = _sha256(path)
106
+ if actual_digest != source["sha256"]:
107
+ raise DataIntegrityError(
108
+ f"SHA-256 mismatch for {path.name}: {actual_digest} != {source['sha256']}"
109
+ )
110
+ return manifest
111
+
112
+
113
+ def default_cache_directory() -> Path:
114
+ """Return the environment override or platform-style user cache directory."""
115
+ override = os.environ.get("QURAN_PROCESSING_TOOLKIT_CACHE")
116
+ if override:
117
+ return Path(override).expanduser().resolve()
118
+ base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
119
+ return base / "quran-processing-toolkit"
120
+
121
+
122
+ def _read_cached_records(path: Path, expected_digest: str) -> list[dict[str, Any]] | None:
123
+ try:
124
+ payload = path.read_bytes()
125
+ if hashlib.sha256(payload).hexdigest() != expected_digest:
126
+ return None
127
+ try:
128
+ import orjson # type: ignore[import-not-found]
129
+
130
+ records: list[dict[str, Any]] = orjson.loads(payload)
131
+ except ImportError:
132
+ records = json.loads(payload)
133
+ return records
134
+ except (FileNotFoundError, OSError, ValueError, TypeError):
135
+ return None
136
+
137
+
138
+ def _write_cache(path: Path, payload: bytes) -> None:
139
+ try:
140
+ path.parent.mkdir(parents=True, exist_ok=True)
141
+ descriptor, temporary_name = tempfile.mkstemp(
142
+ prefix=path.name + ".", suffix=".tmp", dir=path.parent
143
+ )
144
+ try:
145
+ with os.fdopen(descriptor, "wb") as handle:
146
+ handle.write(payload)
147
+ os.replace(temporary_name, path)
148
+ except BaseException:
149
+ with suppress(FileNotFoundError):
150
+ os.unlink(temporary_name)
151
+ raise
152
+ except OSError:
153
+ # Read-only homes and restricted execution environments should still load.
154
+ return
155
+
156
+
157
+ def load_normalized_records(
158
+ data_dir: Path,
159
+ *,
160
+ disk_cache: bool = True,
161
+ cache_dir: Path | None = None,
162
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
163
+ """Verify source bytes, then load or deterministically derive normalized records."""
164
+ from .build import (
165
+ SUPPORTED_DERIVED_DIGESTS,
166
+ TRANSFORMATION_VERSION,
167
+ canonical_records_bytes,
168
+ load_source_records,
169
+ rebuild_records,
170
+ )
171
+
172
+ manifest = verify_data_directory(data_dir)
173
+ source = manifest["sources"]["morphology"]
174
+ try:
175
+ expected_digest = SUPPORTED_DERIVED_DIGESTS[source["sha256"]]
176
+ except KeyError as error:
177
+ raise DataIntegrityError(
178
+ f"Unsupported morphology source SHA-256: {source['sha256']}"
179
+ ) from error
180
+ cache_path = (cache_dir or default_cache_directory()) / (
181
+ f"morphology-v{TRANSFORMATION_VERSION}-{source['sha256'][:16]}.json"
182
+ )
183
+ if disk_cache:
184
+ cached = _read_cached_records(cache_path, expected_digest)
185
+ if cached is not None and len(cached) == source["records"]:
186
+ return cached, manifest
187
+
188
+ records = rebuild_records(load_source_records(data_dir / source["filename"]))
189
+ if len(records) != source["records"]:
190
+ raise DataIntegrityError(
191
+ f"Derived {len(records)} morphology records; expected {source['records']}"
192
+ )
193
+ payload = canonical_records_bytes(records)
194
+ actual_digest = hashlib.sha256(payload).hexdigest()
195
+ if actual_digest != expected_digest:
196
+ raise DataIntegrityError(
197
+ f"Derived morphology digest mismatch: {actual_digest} != {expected_digest}"
198
+ )
199
+ if disk_cache:
200
+ _write_cache(cache_path, payload)
201
+ return records, manifest
@@ -0,0 +1,259 @@
1
+ """Unicode-aware normalization, representation selection, and text predicates.
2
+
3
+ The toolkit never normalizes stored text in place. Each comparison starts from the
4
+ preserved Arabic or Buckwalter form and applies only options explicitly requested by
5
+ the caller. With ``representation='auto'``, wholly ASCII input means Buckwalter and
6
+ non-ASCII input means Arabic.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import unicodedata
13
+ from collections.abc import Collection
14
+ from typing import Any
15
+
16
+ QURANIC_PAUSE_MARKS = frozenset("ۛۖۗۚۙۘ۩ۜ")
17
+ QURANIC_PAUSE_TRANSLATION = str.maketrans("", "", "".join(QURANIC_PAUSE_MARKS))
18
+ # Extended Buckwalter vowel and recitation marks. Long-vowel letters A, w, and Y/y
19
+ # are deliberately not included.
20
+ BUCKWALTER_DIACRITICS = frozenset("aiuoFNK~`^#@")
21
+ _ARABIC_DIACRITIC_RE = re.compile("[\u0610-\u061a\u064b-\u065f\u0670\u06d6-\u06ed]")
22
+
23
+
24
+ class TranslationNotAvailableError(ValueError):
25
+ """Raised when English output is requested without a translation for an item."""
26
+
27
+
28
+ def split_surface_words(text: str, *, ignore_pause_marks: bool = False) -> tuple[str, ...]:
29
+ """Split at literal spaces, optionally deleting only known Quranic pause marks.
30
+
31
+ Empty fragments are omitted. No Unicode normalization, vowel removal, spelling
32
+ changes, or morphology-based contractions are applied. Tabs are not delimiters.
33
+ The original input is not modified; these are not QAC morphology word indices.
34
+ """
35
+ if ignore_pause_marks:
36
+ text = text.translate(QURANIC_PAUSE_TRANSLATION)
37
+ return tuple(part for part in text.split(" ") if part)
38
+
39
+
40
+ def normalize_arabic(
41
+ text: str,
42
+ *,
43
+ strip_diacritics: bool = False,
44
+ strip_quranic_marks: bool = False,
45
+ normalize_alif: bool = False,
46
+ normalize_ya: bool = False,
47
+ remove_spaces: bool = False,
48
+ ) -> str:
49
+ """Return NFC Arabic text with only the requested optional transformations.
50
+
51
+ The conservative defaults are intentional: alif variants, alif maqsura, Quranic
52
+ marks, and vowel marks can all be linguistically significant for a particular
53
+ analysis. Callers choose which distinctions to collapse.
54
+ """
55
+ result = unicodedata.normalize("NFC", text)
56
+ if strip_quranic_marks:
57
+ result = "".join(character for character in result if character not in QURANIC_PAUSE_MARKS)
58
+ if strip_diacritics:
59
+ result = _ARABIC_DIACRITIC_RE.sub("", result)
60
+ if normalize_alif:
61
+ result = result.translate(str.maketrans("أإآٱ", "اااا"))
62
+ if normalize_ya:
63
+ result = result.translate(str.maketrans("ىی", "يي"))
64
+ if remove_spaces:
65
+ result = "".join(result.split())
66
+ return result
67
+
68
+
69
+ def normalize_buckwalter(
70
+ text: str,
71
+ *,
72
+ strip_diacritics: bool = False,
73
+ normalize_alif: bool = False,
74
+ normalize_ya: bool = False,
75
+ remove_spaces: bool = False,
76
+ **_: Any,
77
+ ) -> str:
78
+ """Normalize Buckwalter with options parallel to Arabic normalization."""
79
+ result = text
80
+ if strip_diacritics:
81
+ result = "".join(
82
+ character for character in result if character not in BUCKWALTER_DIACRITICS
83
+ )
84
+ if normalize_alif:
85
+ result = result.translate(str.maketrans("><|{", "AAAA"))
86
+ if normalize_ya:
87
+ result = result.replace("Y", "y")
88
+ if remove_spaces:
89
+ result = "".join(result.split())
90
+ return result
91
+
92
+
93
+ def canonical_representation(representation: str) -> str:
94
+ """Accept concise names for the three user-facing text representations."""
95
+ aliases = {
96
+ "arabic": "arabic",
97
+ "transliteration": "transliteration",
98
+ "buckwalter": "transliteration",
99
+ "translation": "translation",
100
+ "english": "translation",
101
+ }
102
+ try:
103
+ return aliases[representation.lower()]
104
+ except KeyError as error:
105
+ raise ValueError(
106
+ f"Unknown representation {representation!r}; choose Arabic, "
107
+ "transliteration/Buckwalter, or translation/English"
108
+ ) from error
109
+
110
+
111
+ def infer_representation(text: str, representation: str = "auto") -> str:
112
+ """Treat ASCII input as Buckwalter and non-ASCII input as Arabic."""
113
+ if representation == "auto":
114
+ return "transliteration" if text.isascii() else "arabic"
115
+ return canonical_representation(representation)
116
+
117
+
118
+ class TextMixin:
119
+ """Display and comparison operations shared by tokens, words, and verses."""
120
+
121
+ def get_text(self, representation: str = "arabic") -> str:
122
+ """Return Arabic, Buckwalter, or supplied translation text."""
123
+ representation = canonical_representation(representation)
124
+ candidates = {
125
+ "arabic": "arabic_text",
126
+ "translation": "translation_text",
127
+ "transliteration": "transliteration",
128
+ }
129
+ attribute = candidates[representation]
130
+ value = getattr(self, attribute, None)
131
+ if value is None:
132
+ if representation == "translation":
133
+ raise TranslationNotAvailableError(
134
+ f"{type(self).__name__} has no translation representation; "
135
+ "pass translation=... to load_quran()"
136
+ )
137
+ raise ValueError(f"{type(self).__name__} has no {representation} representation")
138
+ return value
139
+
140
+ def normalized_text(self, representation: str = "arabic", **options: Any) -> str:
141
+ """Return one representation after applying explicit normalization options."""
142
+ representation = canonical_representation(representation)
143
+ text = self.get_text(representation)
144
+ if representation == "arabic":
145
+ return normalize_arabic(text, **options)
146
+ if representation == "transliteration":
147
+ return normalize_buckwalter(text, **options)
148
+ return text
149
+
150
+ def display(self, representation: str = "arabic") -> str:
151
+ """Return Arabic, English translation, or Buckwalter for display."""
152
+ return self.get_text(representation)
153
+
154
+ def get_arabic(self) -> str:
155
+ """Return preserved Arabic surface text."""
156
+ return self.get_text("arabic")
157
+
158
+ def get_transliteration(self) -> str:
159
+ """Return Buckwalter transliteration assembled from morphology tokens."""
160
+ return self.get_text("transliteration")
161
+
162
+ def get_translation(self) -> str:
163
+ """Return user-supplied translation text or raise a clear error."""
164
+ return self.get_text("translation")
165
+
166
+ @staticmethod
167
+ def _normalize_input(text: str, representation: str, options: dict[str, Any]) -> str:
168
+ if representation == "arabic":
169
+ return normalize_arabic(text, **options)
170
+ if representation == "transliteration":
171
+ return normalize_buckwalter(text, **options)
172
+ return text
173
+
174
+ def starts_with(
175
+ self, prefix: str, *, representation: str = "auto", **normalization: Any
176
+ ) -> bool:
177
+ """Test a normalized prefix; ASCII input automatically means Buckwalter."""
178
+ representation = infer_representation(prefix, representation)
179
+ text = self.normalized_text(representation, **normalization)
180
+ expected = self._normalize_input(prefix, representation, normalization)
181
+ return text.startswith(expected)
182
+
183
+ def ends_with(self, suffix: str, *, representation: str = "auto", **normalization: Any) -> bool:
184
+ """Test a normalized suffix; ASCII input automatically means Buckwalter."""
185
+ representation = infer_representation(suffix, representation)
186
+ text = self.normalized_text(representation, **normalization)
187
+ expected = self._normalize_input(suffix, representation, normalization)
188
+ return text.endswith(expected)
189
+
190
+ def contains(
191
+ self, fragment: str, *, representation: str = "auto", **normalization: Any
192
+ ) -> bool:
193
+ """Test a normalized substring; ASCII input automatically means Buckwalter."""
194
+ representation = infer_representation(fragment, representation)
195
+ text = self.normalized_text(representation, **normalization)
196
+ expected = self._normalize_input(fragment, representation, normalization)
197
+ return expected in text
198
+
199
+ def equals(
200
+ self, expected_text: str, *, representation: str = "auto", **normalization: Any
201
+ ) -> bool:
202
+ """Test normalized whole-text equality."""
203
+ representation = infer_representation(expected_text, representation)
204
+ text = self.normalized_text(representation, **normalization)
205
+ expected = self._normalize_input(expected_text, representation, normalization)
206
+ return text == expected
207
+
208
+ def contains_letter(
209
+ self, letter: str, *, representation: str = "auto", **normalization: Any
210
+ ) -> bool:
211
+ """Return whether a letter occurs; ASCII letters mean Buckwalter."""
212
+ if len(letter) != 1:
213
+ raise ValueError("contains_letter() expects exactly one character")
214
+ return self.contains(letter, representation=representation, **normalization)
215
+
216
+ def contains_any_letter(
217
+ self, letters: str, *, representation: str = "auto", **normalization: Any
218
+ ) -> bool:
219
+ """Return whether at least one requested character occurs."""
220
+ return any(
221
+ self.contains_letter(letter, representation=representation, **normalization)
222
+ for letter in letters
223
+ )
224
+
225
+ def contains_all_letters(
226
+ self, letters: str, *, representation: str = "auto", **normalization: Any
227
+ ) -> bool:
228
+ """Return whether every requested character occurs at least once."""
229
+ return all(
230
+ self.contains_letter(letter, representation=representation, **normalization)
231
+ for letter in letters
232
+ )
233
+
234
+ def contains_no_letters(
235
+ self, letters: str, *, representation: str = "auto", **normalization: Any
236
+ ) -> bool:
237
+ """Return whether none of the requested characters occur."""
238
+ return not self.contains_any_letter(letters, representation=representation, **normalization)
239
+
240
+ def count_characters(
241
+ self,
242
+ characters: str | Collection[str],
243
+ *,
244
+ representation: str = "auto",
245
+ **normalization: Any,
246
+ ) -> int:
247
+ """Count occurrences belonging to a set of requested characters."""
248
+ sample = characters if isinstance(characters, str) else "".join(characters)
249
+ representation = infer_representation(sample, representation)
250
+ wanted = set(self._normalize_input(sample, representation, normalization))
251
+ return sum(
252
+ character in wanted
253
+ for character in self.normalized_text(representation, **normalization)
254
+ )
255
+
256
+ def letter_count(self, *, representation: str = "arabic", **normalization: Any) -> int:
257
+ """Count alphabetic characters after optional normalization."""
258
+ text = self.normalized_text(representation, **normalization)
259
+ return sum(character.isalpha() for character in text)