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