yotext 0.1.0__tar.gz

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 @@
1
+ * text=auto eol=lf
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
yotext-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adedeji Makinde
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
yotext-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.5
2
+ Name: yotext
3
+ Version: 0.1.0
4
+ Summary: Orthographic normalization and diacritic handling for Yorùbá text
5
+ Project-URL: Homepage, https://github.com/adedejimakinde/yotext
6
+ Project-URL: Issues, https://github.com/adedejimakinde/yotext/issues
7
+ Author-email: Adedeji Makinde <makindeadedeji5@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: african-languages,diacritics,low-resource,nlp,text-normalization,unicode,yoruba
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Text Processing :: Linguistic
18
+ Requires-Python: >=3.9
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # yotext
24
+
25
+ Orthographic normalization and diacritic handling for Yorùbá text. Zero runtime dependencies. Requires Python 3.9 or newer.
26
+
27
+ ## Why this exists
28
+
29
+ Yorùbá text in circulation is encoded inconsistently. The underdot that marks ẹ, ọ, and ṣ shows up as U+0323, U+0329, U+0331, or U+032D depending on the keyboard and the era it was typed on. Combining marks often arrive out of canonical order too. The same word ends up as several different byte sequences that look identical on screen and compare unequal in code. `standardize()` folds all of that into one canonical form.
30
+
31
+ ## Tone marks and underdots are different things
32
+
33
+ This is the part that matters most. The underdot in ẹ, ọ, and ṣ is segmental. ẹ and e are separate phonemes, and removing the underdot turns one word into a different word. Tone marks, the acute in á and the grave in à, are suprasegmental. They mark pitch on top of a vowel, and they do not change which phoneme the vowel is.
34
+
35
+ Most text preprocessing code lumps both of these under the single label "diacritics" and strips them together. That conflates two different linguistic categories, and it produces wrong output for any task that depends on the ẹ/e or ọ/o distinction.
36
+
37
+ `strip_tones()` removes tone marks and keeps the underdot. `strip_diacritics()` removes both. I kept both functions because they answer different questions, and code that only offers one of them is answering the wrong question at least some of the time.
38
+
39
+ ## Install
40
+
41
+ ```
42
+ pip install yotext
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ ```python
48
+ from yotext import standardize, strip_tones, strip_diacritics, tone_pattern
49
+
50
+ # Messy input: wrong underdot codepoint (U+0329 instead of U+0323)
51
+ # and a tone mark placed before the underdot instead of after.
52
+ messy = "e\u0300\u0329ko\u0301\u0331"
53
+ standardize(messy)
54
+ # 'ẹ̀kọ́'
55
+
56
+ strip_tones("ẹ̀kọ́")
57
+ # 'ẹkọ' tone dropped, underdot kept, ẹ and e stay different words
58
+
59
+ strip_diacritics("ẹ̀kọ́")
60
+ # 'eko' fully undiacritized, the baseline used in ablation experiments
61
+
62
+ tone_pattern("bàbá")
63
+ # 'LH' useful for checking tone distribution across a corpus
64
+ ```
65
+
66
+ Two strings can look identical and still compare unequal, if one uses a precomposed vowel and the other uses a decomposed one with a non-canonical underdot codepoint.
67
+
68
+ ```python
69
+ s1 = "\u1eb9" # precomposed ẹ
70
+ s2 = "e\u0329" # decomposed, non-canonical underdot
71
+ s1 == s2 # False
72
+ standardize(s1) == standardize(s2) # True
73
+ ```
74
+
75
+ Running `standardize()` on a corpus before deduplicating or indexing it collapses these variants so equal words compare equal.
76
+
77
+ ## What it guarantees
78
+
79
+ `standardize()` is idempotent. Running it twice gives the same result as running it once.
80
+
81
+ `strip_diacritics(s)` equals `strip_diacritics(strip_tones(s))` for any input. Removing tone first and then removing what remains lands on the same undiacritized form as removing everything at once.
82
+
83
+ Combining marks always come out in canonical order, combining class 220 before combining class 230.
84
+
85
+ ## What it does not do
86
+
87
+ Version 0.1 does normalization only. It does not restore diacritics. Predicting bẹ̀rẹ̀ from bere, meaning guessing which vowels carry an underdot or a tone mark from plain ASCII input, is not implemented. That is planned for a later release.
88
+
89
+ ## License
90
+
91
+ MIT.
yotext-0.1.0/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # yotext
2
+
3
+ Orthographic normalization and diacritic handling for Yorùbá text. Zero runtime dependencies. Requires Python 3.9 or newer.
4
+
5
+ ## Why this exists
6
+
7
+ Yorùbá text in circulation is encoded inconsistently. The underdot that marks ẹ, ọ, and ṣ shows up as U+0323, U+0329, U+0331, or U+032D depending on the keyboard and the era it was typed on. Combining marks often arrive out of canonical order too. The same word ends up as several different byte sequences that look identical on screen and compare unequal in code. `standardize()` folds all of that into one canonical form.
8
+
9
+ ## Tone marks and underdots are different things
10
+
11
+ This is the part that matters most. The underdot in ẹ, ọ, and ṣ is segmental. ẹ and e are separate phonemes, and removing the underdot turns one word into a different word. Tone marks, the acute in á and the grave in à, are suprasegmental. They mark pitch on top of a vowel, and they do not change which phoneme the vowel is.
12
+
13
+ Most text preprocessing code lumps both of these under the single label "diacritics" and strips them together. That conflates two different linguistic categories, and it produces wrong output for any task that depends on the ẹ/e or ọ/o distinction.
14
+
15
+ `strip_tones()` removes tone marks and keeps the underdot. `strip_diacritics()` removes both. I kept both functions because they answer different questions, and code that only offers one of them is answering the wrong question at least some of the time.
16
+
17
+ ## Install
18
+
19
+ ```
20
+ pip install yotext
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```python
26
+ from yotext import standardize, strip_tones, strip_diacritics, tone_pattern
27
+
28
+ # Messy input: wrong underdot codepoint (U+0329 instead of U+0323)
29
+ # and a tone mark placed before the underdot instead of after.
30
+ messy = "e\u0300\u0329ko\u0301\u0331"
31
+ standardize(messy)
32
+ # 'ẹ̀kọ́'
33
+
34
+ strip_tones("ẹ̀kọ́")
35
+ # 'ẹkọ' tone dropped, underdot kept, ẹ and e stay different words
36
+
37
+ strip_diacritics("ẹ̀kọ́")
38
+ # 'eko' fully undiacritized, the baseline used in ablation experiments
39
+
40
+ tone_pattern("bàbá")
41
+ # 'LH' useful for checking tone distribution across a corpus
42
+ ```
43
+
44
+ Two strings can look identical and still compare unequal, if one uses a precomposed vowel and the other uses a decomposed one with a non-canonical underdot codepoint.
45
+
46
+ ```python
47
+ s1 = "\u1eb9" # precomposed ẹ
48
+ s2 = "e\u0329" # decomposed, non-canonical underdot
49
+ s1 == s2 # False
50
+ standardize(s1) == standardize(s2) # True
51
+ ```
52
+
53
+ Running `standardize()` on a corpus before deduplicating or indexing it collapses these variants so equal words compare equal.
54
+
55
+ ## What it guarantees
56
+
57
+ `standardize()` is idempotent. Running it twice gives the same result as running it once.
58
+
59
+ `strip_diacritics(s)` equals `strip_diacritics(strip_tones(s))` for any input. Removing tone first and then removing what remains lands on the same undiacritized form as removing everything at once.
60
+
61
+ Combining marks always come out in canonical order, combining class 220 before combining class 230.
62
+
63
+ ## What it does not do
64
+
65
+ Version 0.1 does normalization only. It does not restore diacritics. Predicting bẹ̀rẹ̀ from bere, meaning guessing which vowels carry an underdot or a tone mark from plain ASCII input, is not implemented. That is planned for a later release.
66
+
67
+ ## License
68
+
69
+ MIT.
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "yotext"
7
+ version = "0.1.0"
8
+ description = "Orthographic normalization and diacritic handling for Yorùbá text"
9
+ requires-python = ">=3.9"
10
+ dependencies = []
11
+ authors = [{name = "Adedeji Makinde", email = "makindeadedeji5@gmail.com"}]
12
+ readme = "README.md"
13
+ license = "MIT"
14
+ keywords = ["yoruba", "nlp", "unicode", "diacritics", "text-normalization", "african-languages", "low-resource"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Science/Research",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Operating System :: OS Independent",
22
+ "Topic :: Text Processing :: Linguistic",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["pytest"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/adedejimakinde/yotext"
30
+ Issues = "https://github.com/adedejimakinde/yotext/issues"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/yotext"]
34
+ artifacts = ["src/yotext/data/*"]
@@ -0,0 +1,11 @@
1
+ """yotext: orthographic normalization and diacritic handling for Yorùbá text.
2
+
3
+ Provides text standardization, tone-mark stripping/extraction, and
4
+ diacritic handling for Yorùbá orthography.
5
+ """
6
+
7
+ from .standardize import standardize
8
+ from .tones import strip_tones, strip_diacritics, tone_pattern
9
+
10
+ __version__ = "0.1.0"
11
+ __all__ = ["standardize", "strip_tones", "strip_diacritics", "tone_pattern", "__version__"]
@@ -0,0 +1,44 @@
1
+ """Constants used across the yotext package."""
2
+
3
+ # Combining diacritical marks (Unicode combining characters).
4
+ #
5
+ # DOT_BELOW is SEGMENTAL: it distinguishes separate phonemes (e.g. e-dot-below
6
+ # vs e, o-dot-below vs o) and is part of the letter identity, not the tone.
7
+ # ACUTE, GRAVE, and MACRON are SUPRASEGMENTAL tone marks layered on top of a
8
+ # vowel/syllabic nasal to indicate pitch (high, low, mid). These two
9
+ # categories must never be conflated: stripping "diacritics" for
10
+ # tone-insensitive comparison must not strip DOT_BELOW, and
11
+ # normalizing/stripping underdots must not touch tone marks.
12
+ DOT_BELOW = "\u0323"
13
+ ACUTE = "\u0301"
14
+ GRAVE = "\u0300"
15
+ MACRON = "\u0304"
16
+
17
+ # Suprasegmental tone marks only. Does not include DOT_BELOW, which is
18
+ # segmental (see note above).
19
+ TONE_MARKS = frozenset({ACUTE, GRAVE, MACRON})
20
+
21
+ # Non-canonical combining-below marks sometimes substituted for DOT_BELOW
22
+ # (e.g. by fonts, keyboards, or OCR), mapped to the canonical DOT_BELOW.
23
+ UNDERDOT_VARIANTS = {
24
+ "\u0329": DOT_BELOW,
25
+ "\u0331": DOT_BELOW,
26
+ "\u032D": DOT_BELOW,
27
+ }
28
+
29
+ # Base Latin vowel letters (uppercase and lowercase), before any diacritics.
30
+ VOWELS = frozenset("aeiouAEIOU")
31
+
32
+ # Syllabic nasal consonant letters (uppercase and lowercase).
33
+ NASALS = frozenset("nmNM")
34
+
35
+ # Invisible/zero-width Unicode characters to strip during cleanup, mapped
36
+ # via str.translate (values are None to delete).
37
+ INVISIBLES = dict.fromkeys(map(ord, "\u200b\u200c\u200d\ufeff\u00ad"), None)
38
+
39
+ # Typographic punctuation variants normalized to their plain ASCII forms.
40
+ PUNCT_FIXES = {
41
+ "\u2018": "'", "\u2019": "'",
42
+ "\u201c": '"', "\u201d": '"',
43
+ "\u2013": "-", "\u2014": "-",
44
+ }
File without changes
@@ -0,0 +1,36 @@
1
+ """Orthographic standardization utilities for Yorùbá text."""
2
+
3
+ import unicodedata
4
+
5
+ from .constants import INVISIBLES, PUNCT_FIXES, UNDERDOT_VARIANTS
6
+
7
+
8
+ def standardize(text: str, *, compose: bool = True) -> str:
9
+ """Normalize Yorùbá text to a canonical orthographic form.
10
+
11
+ Invisible characters are dropped, smart quotes/dashes are replaced with
12
+ their plain ASCII equivalents, and non-canonical combining-below marks
13
+ are folded onto the canonical DOT_BELOW. The text is renormalized to NFD
14
+ a second time after that substitution because swapping one combining
15
+ mark for another can leave the combining-mark sequence out of Unicode
16
+ canonical order: combining class 220 (below) must sort before combining
17
+ class 230 (above), and a substituted mark does not automatically end up
18
+ in the right position relative to its neighbors. Re-running NFD
19
+ canonically reorders the sequence so the result composes correctly.
20
+
21
+ Args:
22
+ text: The input text.
23
+ compose: If True (default), return the NFC form. If False, return
24
+ the NFD form.
25
+
26
+ Returns:
27
+ The standardized text, in NFC or NFD form depending on `compose`.
28
+ """
29
+ text = text.translate(INVISIBLES)
30
+ for old, new in PUNCT_FIXES.items():
31
+ text = text.replace(old, new)
32
+ text = unicodedata.normalize("NFD", text)
33
+ for old, new in UNDERDOT_VARIANTS.items():
34
+ text = text.replace(old, new)
35
+ text = unicodedata.normalize("NFD", text)
36
+ return unicodedata.normalize("NFC", text) if compose else text
@@ -0,0 +1,86 @@
1
+ """Tone mark and diacritic handling for Yorùbá text.
2
+
3
+ Underdots are SEGMENTAL: they distinguish separate phonemes (ẹ vs e, ọ vs o)
4
+ and are part of a letter's identity. Tone marks (acute, grave, and the
5
+ implicit mid tone) are SUPRASEGMENTAL: they indicate pitch on top of a
6
+ vowel/syllabic nasal, independent of which phoneme it is. Because of this,
7
+ stripping tone marks and stripping diacritics are genuinely different
8
+ operations: strip_tones removes only pitch information and must leave
9
+ underdots untouched, while strip_diacritics removes every combining mark,
10
+ underdots included, collapsing segmental distinctions along with tone.
11
+ """
12
+
13
+ import unicodedata
14
+
15
+ from .constants import ACUTE, GRAVE, TONE_MARKS, VOWELS
16
+ from .standardize import standardize
17
+
18
+
19
+ def strip_tones(text: str) -> str:
20
+ """Remove suprasegmental tone marks, preserving segmental underdots.
21
+
22
+ Only characters in TONE_MARKS are removed; DOT_BELOW is not a tone mark
23
+ and survives, since it marks a distinct phoneme (e.g. ẹ vs e) rather than
24
+ pitch.
25
+
26
+ Args:
27
+ text: The input text.
28
+
29
+ Returns:
30
+ The text with tone marks removed, in NFC form.
31
+ """
32
+ decomposed = standardize(text, compose=False)
33
+ filtered = "".join(c for c in decomposed if c not in TONE_MARKS)
34
+ return unicodedata.normalize("NFC", filtered)
35
+
36
+
37
+ def strip_diacritics(text: str) -> str:
38
+ """Remove every combining mark, including segmental underdots.
39
+
40
+ Unlike strip_tones, this removes all combining characters (any character
41
+ for which unicodedata.combining() is non-zero), which collapses
42
+ segmental distinctions such as ẹ/e and ọ/o along with tone information.
43
+
44
+ Args:
45
+ text: The input text.
46
+
47
+ Returns:
48
+ The text with all combining marks removed.
49
+ """
50
+ decomposed = standardize(text, compose=False)
51
+ return "".join(c for c in decomposed if not unicodedata.combining(c))
52
+
53
+
54
+ def tone_pattern(text: str) -> str:
55
+ """Derive the tone pattern of a text as a string of H/M/L markers.
56
+
57
+ Walks the decomposed text; for each base character, the combining marks
58
+ that immediately follow it are inspected. Non-vowel bases are skipped.
59
+ For vowel bases, "H" is emitted if ACUTE is present among its marks, "L"
60
+ if GRAVE is present, and "M" otherwise (mid tone, unmarked).
61
+
62
+ Args:
63
+ text: The input text.
64
+
65
+ Returns:
66
+ A string of "H", "M", and "L" characters, one per vowel.
67
+ """
68
+ decomposed = standardize(text, compose=False)
69
+ pattern = []
70
+ i = 0
71
+ n = len(decomposed)
72
+ while i < n:
73
+ base = decomposed[i]
74
+ i += 1
75
+ marks = []
76
+ while i < n and unicodedata.combining(decomposed[i]) != 0:
77
+ marks.append(decomposed[i])
78
+ i += 1
79
+ if base in VOWELS:
80
+ if ACUTE in marks:
81
+ pattern.append("H")
82
+ elif GRAVE in marks:
83
+ pattern.append("L")
84
+ else:
85
+ pattern.append("M")
86
+ return "".join(pattern)
@@ -0,0 +1,43 @@
1
+ """Tests for yotext.standardize."""
2
+
3
+ from yotext.standardize import standardize
4
+
5
+
6
+ def test_mark_ordering_reorders_combining_classes():
7
+ result = standardize("e\u0301\u0329", compose=False)
8
+ assert result == "e\u0323\u0301"
9
+
10
+
11
+ def test_idempotence():
12
+ for s in ["", "e\u0301\u0329", "plain text", "\u2018hi\u2019", "\u200bzero width"]:
13
+ once = standardize(s)
14
+ twice = standardize(once)
15
+ assert once == twice
16
+
17
+
18
+ def test_invisibles_removed():
19
+ assert standardize("a\u200bb") == "ab"
20
+ assert standardize("a\u200cb") == "ab"
21
+ assert standardize("a\u200db") == "ab"
22
+ assert standardize("a\ufeffb") == "ab"
23
+ assert standardize("a\u00adb") == "ab"
24
+
25
+
26
+ def test_punctuation_normalized():
27
+ assert standardize("\u2018hi\u2019") == "'hi'"
28
+ assert standardize("\u201chi\u201d") == '"hi"'
29
+ assert standardize("a\u2013b") == "a-b"
30
+ assert standardize("a\u2014b") == "a-b"
31
+
32
+
33
+ def test_underdot_variants_fold_to_canonical():
34
+ for variant in ("\u0329", "\u0331", "\u032D"):
35
+ result = standardize("e" + variant, compose=False)
36
+ assert result == "e\u0323"
37
+
38
+
39
+ def test_compose_flag():
40
+ nfd = standardize("e\u0323\u0301", compose=False)
41
+ nfc = standardize("e\u0323\u0301", compose=True)
42
+ assert nfd == "e\u0323\u0301"
43
+ assert nfc == "\u1eb9\u0301"
@@ -0,0 +1,57 @@
1
+ """Tests for yotext.tones."""
2
+
3
+ from yotext.tones import strip_tones, strip_diacritics, tone_pattern
4
+
5
+
6
+ def test_strip_tones_removes_acute():
7
+ result = strip_tones("e\u0301")
8
+ assert result == "e"
9
+
10
+
11
+ def test_strip_tones_removes_grave():
12
+ result = strip_tones("e\u0300")
13
+ assert result == "e"
14
+
15
+
16
+ def test_strip_tones_removes_macron():
17
+ result = strip_tones("e\u0304")
18
+ assert result == "e"
19
+
20
+
21
+ def test_strip_tones_preserves_underdot():
22
+ result = strip_tones("\u1eb9\u0301")
23
+ assert result == "\u1eb9"
24
+
25
+
26
+ def test_strip_diacritics_removes_everything_combining():
27
+ result = strip_diacritics("\u1eb9\u0301")
28
+ assert result == "e"
29
+
30
+
31
+ def test_consistency_invariant():
32
+ for s in [
33
+ "\u1eb9\u0300k\u1ecd\u0301",
34
+ "b\u00e0b\u00e1",
35
+ "plain",
36
+ "",
37
+ "\u1ecd\u0300w\u1ecd\u0300",
38
+ "e\u0301\u0329",
39
+ "\u1e63\u00e9",
40
+ ]:
41
+ assert strip_diacritics(s) == strip_diacritics(strip_tones(s))
42
+
43
+
44
+ def test_tone_pattern_known_words():
45
+ assert tone_pattern("\u1eb9\u0300k\u1ecd\u0301") == "LH"
46
+ assert tone_pattern("b\u00e0b\u00e1") == "LH"
47
+ assert tone_pattern("k\u00e1\u00e0r\u1ecd\u0300") == "HLL"
48
+
49
+
50
+ def test_tone_pattern_unmarked_vowels_yield_mid():
51
+ assert tone_pattern("aeiou") == "MMMMM"
52
+
53
+
54
+ def test_empty_string():
55
+ assert strip_tones("") == ""
56
+ assert strip_diacritics("") == ""
57
+ assert tone_pattern("") == ""
File without changes