alifbe 0.2.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.
- alifbe-0.2.0/LICENSE +21 -0
- alifbe-0.2.0/PKG-INFO +177 -0
- alifbe-0.2.0/README.md +154 -0
- alifbe-0.2.0/alifbe/__init__.py +85 -0
- alifbe-0.2.0/alifbe/__main__.py +95 -0
- alifbe-0.2.0/alifbe/apostrophe.py +71 -0
- alifbe-0.2.0/alifbe/case.py +111 -0
- alifbe-0.2.0/alifbe/confusables.py +81 -0
- alifbe-0.2.0/alifbe/detect.py +66 -0
- alifbe-0.2.0/alifbe/exceptions_data.py +62 -0
- alifbe-0.2.0/alifbe/oldnew.py +110 -0
- alifbe-0.2.0/alifbe/py.typed +0 -0
- alifbe-0.2.0/alifbe/result.py +42 -0
- alifbe-0.2.0/alifbe/searchkey.py +61 -0
- alifbe-0.2.0/alifbe/transliterate.py +306 -0
- alifbe-0.2.0/alifbe.egg-info/PKG-INFO +177 -0
- alifbe-0.2.0/alifbe.egg-info/SOURCES.txt +27 -0
- alifbe-0.2.0/alifbe.egg-info/dependency_links.txt +1 -0
- alifbe-0.2.0/alifbe.egg-info/entry_points.txt +2 -0
- alifbe-0.2.0/alifbe.egg-info/requires.txt +3 -0
- alifbe-0.2.0/alifbe.egg-info/top_level.txt +1 -0
- alifbe-0.2.0/pyproject.toml +39 -0
- alifbe-0.2.0/setup.cfg +4 -0
- alifbe-0.2.0/tests/test_cli.py +54 -0
- alifbe-0.2.0/tests/test_detect.py +44 -0
- alifbe-0.2.0/tests/test_normalization.py +170 -0
- alifbe-0.2.0/tests/test_oldnew.py +96 -0
- alifbe-0.2.0/tests/test_searchkey.py +48 -0
- alifbe-0.2.0/tests/test_transliterate.py +153 -0
alifbe-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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.
|
alifbe-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alifbe
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Robust normalization for real-world Uzbek text: apostrophe chaos, sh/s+h word boundaries, ş/ș confusables, the Turkish-I casing bug, ambiguous Cyrillic letters, and conversion between the pre-2026 and Sept-2026-reform Latin alphabets.
|
|
5
|
+
Author: you
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yourname/alifbe
|
|
8
|
+
Project-URL: Repository, https://github.com/yourname/alifbe
|
|
9
|
+
Keywords: uzbek,unicode,normalization,transliteration,i18n,nlp,cyrillic,latin-alphabet
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
15
|
+
Classifier: Topic :: Software Development :: Internationalization
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# alifbe
|
|
25
|
+
|
|
26
|
+
Robust text normalization for real-world Uzbek text — the eight problems
|
|
27
|
+
that quietly corrupt Uzbek data in most pipelines.
|
|
28
|
+
|
|
29
|
+
| Problem | What alifbe does |
|
|
30
|
+
|---|---|
|
|
31
|
+
| 8+ different "apostrophe" characters (`'` `‘` `’` `` ` `` `´` ...) all meaning oʻ / gʻ / tutuq belgisi | Normalizes all of them to the two *correct* Unicode letters, based on context |
|
|
32
|
+
| `"Isʼhoq"` → `"Işoq"` | Detects s+h morpheme boundaries (via apostrophe *and* a known-word list) so `sh` isn't wrongly merged into one letter |
|
|
33
|
+
| `ş` vs `ș` (cedilla vs. comma-below — different code points, identical glyph) | Detects and normalizes confusable characters so search/dedup actually works |
|
|
34
|
+
| The "Turkish I" bug (`.upper()`/`.lower()` under some locales turns `i` into `İ`) | Locale-independent, explicit-table casing that never produces `İ`/`ı` |
|
|
35
|
+
| Cyrillic `е`, `ц`, `ё` are position/origin-dependent | Transliteration always returns warnings for ambiguous letters instead of silently guessing (and can `raise` instead, if you'd rather fail loudly) |
|
|
36
|
+
| The **Sept 2026 alphabet reform** (sh→ş, ch→ç, oʻ→ö, gʻ→ğ) | `to_new_latin()` / `to_old_latin()` convert between the two orthographies, with brand-name/URL/code protection |
|
|
37
|
+
| "Which script is this text even in?" | `detect_alphabet()` — cyrillic / old-latin / new-latin / mixed / unknown |
|
|
38
|
+
| "oʻzbek", "özbek", and "ўзбек" are the same word, but `==` doesn't think so | `fold_search_key()` gives every spelling the same canonical key |
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
Not yet published to PyPI — the name `alifbe` is free, checked via
|
|
43
|
+
`pip download alifbe` returning no match, but publishing itself is a
|
|
44
|
+
manual step (PyPI account + 2FA + `twine upload`). Install locally for now:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install /path/to/alifbe # normal install
|
|
48
|
+
pip install -e /path/to/alifbe # editable, for development
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
This also registers an `alifbe` command-line tool (see below).
|
|
52
|
+
|
|
53
|
+
## Quick tour
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import alifbe as uz
|
|
57
|
+
|
|
58
|
+
# 1. Apostrophes: every variant collapses to the correct, same string
|
|
59
|
+
uz.normalize_apostrophes("o'zbek") # -> "oʻzbek" (U+02BB, turned comma)
|
|
60
|
+
uz.normalize_apostrophes("o‘zbek") # -> "oʻzbek" (same result)
|
|
61
|
+
uz.normalize_apostrophes("san'at") # -> "sanʼat" (U+02BC — different rule, not after o/g)
|
|
62
|
+
|
|
63
|
+
# 2. "Isʼhoq" stays "Isʼhoq" -- sh is not wrongly merged into ш
|
|
64
|
+
uz.latin_to_cyrillic("Isʼhoq").text # -> "Исъҳоқ" (not "Ишоқ")
|
|
65
|
+
uz.latin_to_cyrillic("Ishoq").warnings # non-empty: flags this as a known
|
|
66
|
+
# s+h boundary word even without
|
|
67
|
+
# the apostrophe
|
|
68
|
+
|
|
69
|
+
# 3. ş vs ș
|
|
70
|
+
uz.find_confusables("Kraiova munșasi") # -> [Confusable(char='ș', codepoint='U+0219', ...)]
|
|
71
|
+
uz.normalize_confusables("munşa") == uz.normalize_confusables("munșa") # -> True
|
|
72
|
+
|
|
73
|
+
# 4. Turkish I bug
|
|
74
|
+
uz.uz_upper("olib") # -> "OLIB" (never "OLİB")
|
|
75
|
+
uz.find_turkish_i_corruption("OLİB") # -> flags the İ as corruption
|
|
76
|
+
uz.fix_turkish_i_corruption("OLİB") # -> "OLIB"
|
|
77
|
+
|
|
78
|
+
# 5. Ambiguous Cyrillic letters warn instead of silently guessing
|
|
79
|
+
result = uz.cyrillic_to_latin("центр")
|
|
80
|
+
result.text # -> "tsentr" (best guess)
|
|
81
|
+
result.warnings # -> non-empty, explains ц is ambiguous
|
|
82
|
+
uz.cyrillic_to_latin("ёлғон", on_ambiguous="raise") # -> raises instead
|
|
83
|
+
|
|
84
|
+
# 6. The Sept 2026 alphabet reform
|
|
85
|
+
uz.to_new_latin("Shahzoda Oʻzbekistonda choy ichdi").text
|
|
86
|
+
# -> "Şahzoda Özbekistonda çoy içdi"
|
|
87
|
+
uz.to_old_latin("Şahzoda Özbekistonda çoy içdi").text
|
|
88
|
+
# -> "Shahzoda Oʻzbekistonda choy ichdi"
|
|
89
|
+
|
|
90
|
+
# ... with brand names / URLs / code protected from conversion
|
|
91
|
+
uz.to_new_latin("MyShop: sotib oling", protected_terms=["MyShop"]).text
|
|
92
|
+
# -> "MyShop: sotib oling" (MyShop untouched, rest still converts if applicable)
|
|
93
|
+
uz.to_new_latin("See https://x.com/shahar for info").text
|
|
94
|
+
# -> "See https://x.com/shahar for info" (URL untouched)
|
|
95
|
+
|
|
96
|
+
# 7. What script is this?
|
|
97
|
+
uz.detect_alphabet("Shahzoda") # -> AlphabetDetection(alphabet='old-latin', confidence=0.65)
|
|
98
|
+
uz.detect_alphabet("Şahzoda") # -> AlphabetDetection(alphabet='new-latin', confidence=0.7)
|
|
99
|
+
uz.detect_alphabet("Шаҳзода") # -> AlphabetDetection(alphabet='cyrillic', confidence=1.0)
|
|
100
|
+
|
|
101
|
+
# 8. One search key regardless of script or apostrophe style
|
|
102
|
+
uz.fold_search_key("o'zbek") == uz.fold_search_key("özbek") == uz.fold_search_key("ўзбек")
|
|
103
|
+
# -> True (all fold to "özbek")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Command line
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
echo "o'zbek" | alifbe normalize-apostrophes # oʻzbek
|
|
110
|
+
alifbe to-new-latin "Shahzoda choy ichdi" # Şahzoda çoy içdi
|
|
111
|
+
alifbe to-cyrillic "Ishoq" # Исъҳоқ (+ warning on stderr)
|
|
112
|
+
alifbe detect "Шаҳзода" # cyrillic (confidence: 1.0)
|
|
113
|
+
alifbe fold-key "oʻzbek" # özbek
|
|
114
|
+
alifbe check "OLİB" # corruption: 'İ' (U+0130) at index 2
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Every subcommand reads from the positional argument if given, or stdin
|
|
118
|
+
otherwise — so it pipes cleanly. Conversion warnings go to stderr, so
|
|
119
|
+
stdout stays clean for piping the result onward.
|
|
120
|
+
|
|
121
|
+
## Design principle
|
|
122
|
+
|
|
123
|
+
Every function that could plausibly get something wrong either:
|
|
124
|
+
- makes the correct choice deterministically (apostrophes, casing,
|
|
125
|
+
alphabet-reform digraphs), or
|
|
126
|
+
- tells you it's not sure, instead of guessing silently (Cyrillic
|
|
127
|
+
е/ц/ё, the sh/s+h boundary when no apostrophe survives).
|
|
128
|
+
|
|
129
|
+
That second category is deliberate: a library that *always* looks
|
|
130
|
+
confident is more dangerous than one that sometimes says "I'm not sure,
|
|
131
|
+
here's my best guess and why." All conversion functions return the same
|
|
132
|
+
`Result(text, warnings)` shape — `warnings` is empty when the library is
|
|
133
|
+
confident, and populated (with a machine-readable `rule` plus a
|
|
134
|
+
human-readable `message`) when it made a judgment call.
|
|
135
|
+
|
|
136
|
+
## On the Sept 2026 alphabet reform
|
|
137
|
+
|
|
138
|
+
Uzbekistan's Senate approved a bill on 10 September 2026 replacing the
|
|
139
|
+
sh/ch/oʻ/gʻ digraphs with single letters ş/ç/ö/ğ. As of this writing the
|
|
140
|
+
bill has been sent to the president and is **not yet in force** — school
|
|
141
|
+
materials are expected to transition starting 2027. `to_new_latin()` is a
|
|
142
|
+
forward-looking convenience, not a claim about which spelling is
|
|
143
|
+
currently mandatory. Check lex.uz or the Ministry of Education for the
|
|
144
|
+
authoritative status before treating conversion as required.
|
|
145
|
+
|
|
146
|
+
## Known simplifications (read before relying on this for anything critical)
|
|
147
|
+
|
|
148
|
+
- `SH_BOUNDARY_EXCEPTIONS` (in `exceptions_data.py`) is a small starting
|
|
149
|
+
list, not a linguistic corpus — extend it via `extra=` for your data,
|
|
150
|
+
and get a native speaker to review it before production use.
|
|
151
|
+
- Cyrillic `ц` has no reliable positional rule (can be "s" or "ts"
|
|
152
|
+
depending on the word's origin); alifbe defaults to "ts" and always
|
|
153
|
+
warns. Cyrillic `е` uses a positional heuristic (ye at word start/after
|
|
154
|
+
a vowel, else e) which is usually right but isn't a certainty for
|
|
155
|
+
borrowed words.
|
|
156
|
+
- `latin_to_cyrillic("c")` maps to "к" (since old-orthography Uzbek Latin
|
|
157
|
+
has no standalone "c" outside the "ch" digraph) — for text with
|
|
158
|
+
loanwords spelled with a bare "c", double-check the output.
|
|
159
|
+
- `fold_search_key_loose()` is intentionally lossy (strips ö/ğ/ş/ç
|
|
160
|
+
diacritics and apostrophes) — never use it as a unique key, only for
|
|
161
|
+
fuzzy "did you mean" style matching.
|
|
162
|
+
|
|
163
|
+
## Extending the sh-boundary word list
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
uz.latin_to_cyrillic("Asʼhad", extra_sh_exceptions={"ashad"})
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Tests
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
pip install -e ".[dev]"
|
|
173
|
+
pytest tests/ -v
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
102 tests across normalization, transliteration, the alphabet-reform
|
|
177
|
+
converter, script detection, search-key folding, and the CLI.
|
alifbe-0.2.0/README.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# alifbe
|
|
2
|
+
|
|
3
|
+
Robust text normalization for real-world Uzbek text — the eight problems
|
|
4
|
+
that quietly corrupt Uzbek data in most pipelines.
|
|
5
|
+
|
|
6
|
+
| Problem | What alifbe does |
|
|
7
|
+
|---|---|
|
|
8
|
+
| 8+ different "apostrophe" characters (`'` `‘` `’` `` ` `` `´` ...) all meaning oʻ / gʻ / tutuq belgisi | Normalizes all of them to the two *correct* Unicode letters, based on context |
|
|
9
|
+
| `"Isʼhoq"` → `"Işoq"` | Detects s+h morpheme boundaries (via apostrophe *and* a known-word list) so `sh` isn't wrongly merged into one letter |
|
|
10
|
+
| `ş` vs `ș` (cedilla vs. comma-below — different code points, identical glyph) | Detects and normalizes confusable characters so search/dedup actually works |
|
|
11
|
+
| The "Turkish I" bug (`.upper()`/`.lower()` under some locales turns `i` into `İ`) | Locale-independent, explicit-table casing that never produces `İ`/`ı` |
|
|
12
|
+
| Cyrillic `е`, `ц`, `ё` are position/origin-dependent | Transliteration always returns warnings for ambiguous letters instead of silently guessing (and can `raise` instead, if you'd rather fail loudly) |
|
|
13
|
+
| The **Sept 2026 alphabet reform** (sh→ş, ch→ç, oʻ→ö, gʻ→ğ) | `to_new_latin()` / `to_old_latin()` convert between the two orthographies, with brand-name/URL/code protection |
|
|
14
|
+
| "Which script is this text even in?" | `detect_alphabet()` — cyrillic / old-latin / new-latin / mixed / unknown |
|
|
15
|
+
| "oʻzbek", "özbek", and "ўзбек" are the same word, but `==` doesn't think so | `fold_search_key()` gives every spelling the same canonical key |
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
Not yet published to PyPI — the name `alifbe` is free, checked via
|
|
20
|
+
`pip download alifbe` returning no match, but publishing itself is a
|
|
21
|
+
manual step (PyPI account + 2FA + `twine upload`). Install locally for now:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install /path/to/alifbe # normal install
|
|
25
|
+
pip install -e /path/to/alifbe # editable, for development
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
This also registers an `alifbe` command-line tool (see below).
|
|
29
|
+
|
|
30
|
+
## Quick tour
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import alifbe as uz
|
|
34
|
+
|
|
35
|
+
# 1. Apostrophes: every variant collapses to the correct, same string
|
|
36
|
+
uz.normalize_apostrophes("o'zbek") # -> "oʻzbek" (U+02BB, turned comma)
|
|
37
|
+
uz.normalize_apostrophes("o‘zbek") # -> "oʻzbek" (same result)
|
|
38
|
+
uz.normalize_apostrophes("san'at") # -> "sanʼat" (U+02BC — different rule, not after o/g)
|
|
39
|
+
|
|
40
|
+
# 2. "Isʼhoq" stays "Isʼhoq" -- sh is not wrongly merged into ш
|
|
41
|
+
uz.latin_to_cyrillic("Isʼhoq").text # -> "Исъҳоқ" (not "Ишоқ")
|
|
42
|
+
uz.latin_to_cyrillic("Ishoq").warnings # non-empty: flags this as a known
|
|
43
|
+
# s+h boundary word even without
|
|
44
|
+
# the apostrophe
|
|
45
|
+
|
|
46
|
+
# 3. ş vs ș
|
|
47
|
+
uz.find_confusables("Kraiova munșasi") # -> [Confusable(char='ș', codepoint='U+0219', ...)]
|
|
48
|
+
uz.normalize_confusables("munşa") == uz.normalize_confusables("munșa") # -> True
|
|
49
|
+
|
|
50
|
+
# 4. Turkish I bug
|
|
51
|
+
uz.uz_upper("olib") # -> "OLIB" (never "OLİB")
|
|
52
|
+
uz.find_turkish_i_corruption("OLİB") # -> flags the İ as corruption
|
|
53
|
+
uz.fix_turkish_i_corruption("OLİB") # -> "OLIB"
|
|
54
|
+
|
|
55
|
+
# 5. Ambiguous Cyrillic letters warn instead of silently guessing
|
|
56
|
+
result = uz.cyrillic_to_latin("центр")
|
|
57
|
+
result.text # -> "tsentr" (best guess)
|
|
58
|
+
result.warnings # -> non-empty, explains ц is ambiguous
|
|
59
|
+
uz.cyrillic_to_latin("ёлғон", on_ambiguous="raise") # -> raises instead
|
|
60
|
+
|
|
61
|
+
# 6. The Sept 2026 alphabet reform
|
|
62
|
+
uz.to_new_latin("Shahzoda Oʻzbekistonda choy ichdi").text
|
|
63
|
+
# -> "Şahzoda Özbekistonda çoy içdi"
|
|
64
|
+
uz.to_old_latin("Şahzoda Özbekistonda çoy içdi").text
|
|
65
|
+
# -> "Shahzoda Oʻzbekistonda choy ichdi"
|
|
66
|
+
|
|
67
|
+
# ... with brand names / URLs / code protected from conversion
|
|
68
|
+
uz.to_new_latin("MyShop: sotib oling", protected_terms=["MyShop"]).text
|
|
69
|
+
# -> "MyShop: sotib oling" (MyShop untouched, rest still converts if applicable)
|
|
70
|
+
uz.to_new_latin("See https://x.com/shahar for info").text
|
|
71
|
+
# -> "See https://x.com/shahar for info" (URL untouched)
|
|
72
|
+
|
|
73
|
+
# 7. What script is this?
|
|
74
|
+
uz.detect_alphabet("Shahzoda") # -> AlphabetDetection(alphabet='old-latin', confidence=0.65)
|
|
75
|
+
uz.detect_alphabet("Şahzoda") # -> AlphabetDetection(alphabet='new-latin', confidence=0.7)
|
|
76
|
+
uz.detect_alphabet("Шаҳзода") # -> AlphabetDetection(alphabet='cyrillic', confidence=1.0)
|
|
77
|
+
|
|
78
|
+
# 8. One search key regardless of script or apostrophe style
|
|
79
|
+
uz.fold_search_key("o'zbek") == uz.fold_search_key("özbek") == uz.fold_search_key("ўзбек")
|
|
80
|
+
# -> True (all fold to "özbek")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Command line
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
echo "o'zbek" | alifbe normalize-apostrophes # oʻzbek
|
|
87
|
+
alifbe to-new-latin "Shahzoda choy ichdi" # Şahzoda çoy içdi
|
|
88
|
+
alifbe to-cyrillic "Ishoq" # Исъҳоқ (+ warning on stderr)
|
|
89
|
+
alifbe detect "Шаҳзода" # cyrillic (confidence: 1.0)
|
|
90
|
+
alifbe fold-key "oʻzbek" # özbek
|
|
91
|
+
alifbe check "OLİB" # corruption: 'İ' (U+0130) at index 2
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Every subcommand reads from the positional argument if given, or stdin
|
|
95
|
+
otherwise — so it pipes cleanly. Conversion warnings go to stderr, so
|
|
96
|
+
stdout stays clean for piping the result onward.
|
|
97
|
+
|
|
98
|
+
## Design principle
|
|
99
|
+
|
|
100
|
+
Every function that could plausibly get something wrong either:
|
|
101
|
+
- makes the correct choice deterministically (apostrophes, casing,
|
|
102
|
+
alphabet-reform digraphs), or
|
|
103
|
+
- tells you it's not sure, instead of guessing silently (Cyrillic
|
|
104
|
+
е/ц/ё, the sh/s+h boundary when no apostrophe survives).
|
|
105
|
+
|
|
106
|
+
That second category is deliberate: a library that *always* looks
|
|
107
|
+
confident is more dangerous than one that sometimes says "I'm not sure,
|
|
108
|
+
here's my best guess and why." All conversion functions return the same
|
|
109
|
+
`Result(text, warnings)` shape — `warnings` is empty when the library is
|
|
110
|
+
confident, and populated (with a machine-readable `rule` plus a
|
|
111
|
+
human-readable `message`) when it made a judgment call.
|
|
112
|
+
|
|
113
|
+
## On the Sept 2026 alphabet reform
|
|
114
|
+
|
|
115
|
+
Uzbekistan's Senate approved a bill on 10 September 2026 replacing the
|
|
116
|
+
sh/ch/oʻ/gʻ digraphs with single letters ş/ç/ö/ğ. As of this writing the
|
|
117
|
+
bill has been sent to the president and is **not yet in force** — school
|
|
118
|
+
materials are expected to transition starting 2027. `to_new_latin()` is a
|
|
119
|
+
forward-looking convenience, not a claim about which spelling is
|
|
120
|
+
currently mandatory. Check lex.uz or the Ministry of Education for the
|
|
121
|
+
authoritative status before treating conversion as required.
|
|
122
|
+
|
|
123
|
+
## Known simplifications (read before relying on this for anything critical)
|
|
124
|
+
|
|
125
|
+
- `SH_BOUNDARY_EXCEPTIONS` (in `exceptions_data.py`) is a small starting
|
|
126
|
+
list, not a linguistic corpus — extend it via `extra=` for your data,
|
|
127
|
+
and get a native speaker to review it before production use.
|
|
128
|
+
- Cyrillic `ц` has no reliable positional rule (can be "s" or "ts"
|
|
129
|
+
depending on the word's origin); alifbe defaults to "ts" and always
|
|
130
|
+
warns. Cyrillic `е` uses a positional heuristic (ye at word start/after
|
|
131
|
+
a vowel, else e) which is usually right but isn't a certainty for
|
|
132
|
+
borrowed words.
|
|
133
|
+
- `latin_to_cyrillic("c")` maps to "к" (since old-orthography Uzbek Latin
|
|
134
|
+
has no standalone "c" outside the "ch" digraph) — for text with
|
|
135
|
+
loanwords spelled with a bare "c", double-check the output.
|
|
136
|
+
- `fold_search_key_loose()` is intentionally lossy (strips ö/ğ/ş/ç
|
|
137
|
+
diacritics and apostrophes) — never use it as a unique key, only for
|
|
138
|
+
fuzzy "did you mean" style matching.
|
|
139
|
+
|
|
140
|
+
## Extending the sh-boundary word list
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
uz.latin_to_cyrillic("Asʼhad", extra_sh_exceptions={"ashad"})
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Tests
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
pip install -e ".[dev]"
|
|
150
|
+
pytest tests/ -v
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
102 tests across normalization, transliteration, the alphabet-reform
|
|
154
|
+
converter, script detection, search-key folding, and the CLI.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
alifbe — robust text normalization utilities for the Uzbek language.
|
|
3
|
+
|
|
4
|
+
Handles the sharp edges of real-world Uzbek text:
|
|
5
|
+
|
|
6
|
+
* Apostrophe chaos (oʻ / gʻ / tutuq belgisi typed with 8+ different glyphs)
|
|
7
|
+
* The "Isʼhoq" problem (s+h is not always the "sh" digraph)
|
|
8
|
+
* ş vs ș confusable Unicode lookalikes
|
|
9
|
+
* The "Turkish I" casing bug
|
|
10
|
+
* Ambiguous Cyrillic letters (е, ц, ё) that depend on context
|
|
11
|
+
* The pre-2026 vs. post-reform (Sept 2026 Senate-approved) Latin
|
|
12
|
+
orthographies -- sh/ch/oʻ/gʻ vs ş/ç/ö/ğ
|
|
13
|
+
* Cross-script/cross-orthography search-key folding, so "oʻzbek",
|
|
14
|
+
"özbek", and "ўзбек" are recognized as the same word
|
|
15
|
+
|
|
16
|
+
Nothing here silently guesses when a guess would corrupt data — ambiguous
|
|
17
|
+
cases are surfaced as warnings so the caller (or a human) can decide.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .apostrophe import normalize_apostrophes, TURNED_COMMA, APOSTROPHE
|
|
21
|
+
from .confusables import normalize_confusables, find_confusables, Confusable
|
|
22
|
+
from .case import (
|
|
23
|
+
uz_lower,
|
|
24
|
+
uz_upper,
|
|
25
|
+
uz_title,
|
|
26
|
+
find_turkish_i_corruption,
|
|
27
|
+
fix_turkish_i_corruption,
|
|
28
|
+
CorruptionHit,
|
|
29
|
+
TURKISH_DOTTED_I,
|
|
30
|
+
TURKISH_DOTLESS_I,
|
|
31
|
+
)
|
|
32
|
+
from .exceptions_data import SH_BOUNDARY_EXCEPTIONS, is_sh_boundary_word
|
|
33
|
+
from .result import Result, Warning_
|
|
34
|
+
from .transliterate import (
|
|
35
|
+
TransliterationResult,
|
|
36
|
+
AmbiguousTransliterationError,
|
|
37
|
+
latin_to_cyrillic,
|
|
38
|
+
cyrillic_to_latin,
|
|
39
|
+
)
|
|
40
|
+
from .oldnew import ConversionResult, to_new_latin, to_old_latin
|
|
41
|
+
from .detect import detect_alphabet, AlphabetDetection
|
|
42
|
+
from .searchkey import fold_search_key, fold_search_key_loose
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
# apostrophe
|
|
46
|
+
"normalize_apostrophes",
|
|
47
|
+
"TURNED_COMMA",
|
|
48
|
+
"APOSTROPHE",
|
|
49
|
+
# confusables
|
|
50
|
+
"normalize_confusables",
|
|
51
|
+
"find_confusables",
|
|
52
|
+
"Confusable",
|
|
53
|
+
# case
|
|
54
|
+
"uz_lower",
|
|
55
|
+
"uz_upper",
|
|
56
|
+
"uz_title",
|
|
57
|
+
"find_turkish_i_corruption",
|
|
58
|
+
"fix_turkish_i_corruption",
|
|
59
|
+
"CorruptionHit",
|
|
60
|
+
"TURKISH_DOTTED_I",
|
|
61
|
+
"TURKISH_DOTLESS_I",
|
|
62
|
+
# exceptions
|
|
63
|
+
"SH_BOUNDARY_EXCEPTIONS",
|
|
64
|
+
"is_sh_boundary_word",
|
|
65
|
+
# shared result type
|
|
66
|
+
"Result",
|
|
67
|
+
"Warning_",
|
|
68
|
+
# transliteration (Latin <-> Cyrillic)
|
|
69
|
+
"TransliterationResult",
|
|
70
|
+
"AmbiguousTransliterationError",
|
|
71
|
+
"latin_to_cyrillic",
|
|
72
|
+
"cyrillic_to_latin",
|
|
73
|
+
# 2026 reform (old Latin <-> new Latin)
|
|
74
|
+
"ConversionResult",
|
|
75
|
+
"to_new_latin",
|
|
76
|
+
"to_old_latin",
|
|
77
|
+
# detection
|
|
78
|
+
"detect_alphabet",
|
|
79
|
+
"AlphabetDetection",
|
|
80
|
+
# search keys
|
|
81
|
+
"fold_search_key",
|
|
82
|
+
"fold_search_key_loose",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface: `python -m alifbe <command> [text]`.
|
|
3
|
+
|
|
4
|
+
Reads TEXT from the positional argument if given, otherwise from stdin
|
|
5
|
+
(so it works well in pipelines). Warnings, if any, go to stderr so stdout
|
|
6
|
+
stays clean for piping the converted text onward.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from . import (
|
|
13
|
+
normalize_apostrophes,
|
|
14
|
+
normalize_confusables,
|
|
15
|
+
uz_upper,
|
|
16
|
+
uz_lower,
|
|
17
|
+
uz_title,
|
|
18
|
+
find_turkish_i_corruption,
|
|
19
|
+
latin_to_cyrillic,
|
|
20
|
+
cyrillic_to_latin,
|
|
21
|
+
to_new_latin,
|
|
22
|
+
to_old_latin,
|
|
23
|
+
detect_alphabet,
|
|
24
|
+
fold_search_key,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _read_text(args) -> str:
|
|
29
|
+
if args.text is not None:
|
|
30
|
+
return args.text
|
|
31
|
+
return sys.stdin.read().rstrip("\n")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _emit(result_text: str, warnings=()) -> None:
|
|
35
|
+
print(result_text)
|
|
36
|
+
for w in warnings:
|
|
37
|
+
print(f"warning: {w.message}", file=sys.stderr)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main(argv=None) -> int:
|
|
41
|
+
parser = argparse.ArgumentParser(
|
|
42
|
+
prog="alifbe",
|
|
43
|
+
description="Normalize and convert Uzbek text (apostrophes, ş/ș confusables, "
|
|
44
|
+
"old/new Latin orthography, Cyrillic transliteration).",
|
|
45
|
+
)
|
|
46
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
47
|
+
|
|
48
|
+
for name in ("normalize-apostrophes", "normalize-confusables",
|
|
49
|
+
"upper", "lower", "title",
|
|
50
|
+
"to-cyrillic", "to-latin", "to-new-latin", "to-old-latin",
|
|
51
|
+
"detect", "fold-key", "check"):
|
|
52
|
+
p = sub.add_parser(name)
|
|
53
|
+
p.add_argument("text", nargs="?", help="Text to process (reads stdin if omitted)")
|
|
54
|
+
|
|
55
|
+
args = parser.parse_args(argv)
|
|
56
|
+
text = _read_text(args)
|
|
57
|
+
|
|
58
|
+
if args.command == "normalize-apostrophes":
|
|
59
|
+
_emit(normalize_apostrophes(text))
|
|
60
|
+
elif args.command == "normalize-confusables":
|
|
61
|
+
_emit(normalize_confusables(text))
|
|
62
|
+
elif args.command == "upper":
|
|
63
|
+
_emit(uz_upper(text))
|
|
64
|
+
elif args.command == "lower":
|
|
65
|
+
_emit(uz_lower(text))
|
|
66
|
+
elif args.command == "title":
|
|
67
|
+
_emit(uz_title(text))
|
|
68
|
+
elif args.command == "to-cyrillic":
|
|
69
|
+
result = latin_to_cyrillic(text)
|
|
70
|
+
_emit(result.text, result.warnings)
|
|
71
|
+
elif args.command == "to-latin":
|
|
72
|
+
result = cyrillic_to_latin(text)
|
|
73
|
+
_emit(result.text, result.warnings)
|
|
74
|
+
elif args.command == "to-new-latin":
|
|
75
|
+
_emit(to_new_latin(text).text)
|
|
76
|
+
elif args.command == "to-old-latin":
|
|
77
|
+
_emit(to_old_latin(text).text)
|
|
78
|
+
elif args.command == "detect":
|
|
79
|
+
detection = detect_alphabet(text)
|
|
80
|
+
print(f"{detection.alphabet} (confidence: {detection.confidence})")
|
|
81
|
+
elif args.command == "fold-key":
|
|
82
|
+
_emit(fold_search_key(text))
|
|
83
|
+
elif args.command == "check":
|
|
84
|
+
hits = find_turkish_i_corruption(text)
|
|
85
|
+
if hits:
|
|
86
|
+
for h in hits:
|
|
87
|
+
print(f"corruption: '{h.char}' ({h.codepoint}) at index {h.index}")
|
|
88
|
+
return 1
|
|
89
|
+
print("clean: no Turkish-I corruption found")
|
|
90
|
+
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Normalize the ~8+ different characters people type for Uzbek apostrophes
|
|
3
|
+
into the two *correct* Unicode code points:
|
|
4
|
+
|
|
5
|
+
ʻ U+02BB MODIFIER LETTER TURNED COMMA -> used only inside oʻ / gʻ
|
|
6
|
+
ʼ U+02BC MODIFIER LETTER APOSTROPHE -> the tutuq belgisi (glottal
|
|
7
|
+
stop) used everywhere else,
|
|
8
|
+
e.g. sanʼat, Isʼhoq, mashʼal
|
|
9
|
+
|
|
10
|
+
People type either of these using: ' (U+0027), ‘ ’ (curly quotes),
|
|
11
|
+
` ´ (grave/acute accent), ʹ (prime), ʻʼ themselves, and more — often
|
|
12
|
+
inconsistently within the same document.
|
|
13
|
+
|
|
14
|
+
The rule used to decide *which* canonical character a lookalike becomes:
|
|
15
|
+
if it immediately follows o/O/g/G it is part of the oʻ/gʻ letter (turned
|
|
16
|
+
comma); otherwise it is the tutuq belgisi (plain apostrophe).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
import unicodedata
|
|
21
|
+
|
|
22
|
+
TURNED_COMMA = "\u02BB" # ʻ — part of the letters oʻ / gʻ
|
|
23
|
+
APOSTROPHE = "\u02BC" # ʼ — tutuq belgisi / glottal stop marker
|
|
24
|
+
|
|
25
|
+
# Every character that, in the wild, gets used to mean "apostrophe" in
|
|
26
|
+
# Uzbek text. This list is intentionally generous.
|
|
27
|
+
_APOSTROPHE_LOOKALIKES = {
|
|
28
|
+
"'", # U+0027 APOSTROPHE (ascii)
|
|
29
|
+
"\u2018", # ‘ LEFT SINGLE QUOTATION MARK
|
|
30
|
+
"\u2019", # ’ RIGHT SINGLE QUOTATION MARK
|
|
31
|
+
"\u201A", # ‚ SINGLE LOW-9 QUOTATION MARK
|
|
32
|
+
"\u201B", # ‛ SINGLE HIGH-REVERSED-9 QUOTATION MARK
|
|
33
|
+
"`", # U+0060 GRAVE ACCENT
|
|
34
|
+
"\u00B4", # ´ ACUTE ACCENT
|
|
35
|
+
"\u02B9", # ʹ MODIFIER LETTER PRIME
|
|
36
|
+
"\u02BA", # ʺ MODIFIER LETTER DOUBLE PRIME
|
|
37
|
+
"\u02BB", # ʻ MODIFIER LETTER TURNED COMMA (already canonical form #1)
|
|
38
|
+
"\u02BC", # ʼ MODIFIER LETTER APOSTROPHE (already canonical form #2)
|
|
39
|
+
"\u02BD", # ʽ MODIFIER LETTER REVERSED COMMA
|
|
40
|
+
"\u02BE", # ʾ MODIFIER LETTER RIGHT HALF RING
|
|
41
|
+
"\u02BF", # ʿ MODIFIER LETTER LEFT HALF RING
|
|
42
|
+
"\u02C8", # ˈ MODIFIER LETTER VERTICAL LINE (stress mark, sometimes misused)
|
|
43
|
+
"\u02CA", # ˊ MODIFIER LETTER ACUTE ACCENT
|
|
44
|
+
"\u02EE", # ˮ MODIFIER LETTER DOUBLE APOSTROPHE
|
|
45
|
+
"\u2032", # ′ PRIME
|
|
46
|
+
"\u0301", # combining acute accent (when it ends up standalone)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_PATTERN = re.compile("[" + "".join(re.escape(c) for c in _APOSTROPHE_LOOKALIKES) + "]")
|
|
50
|
+
|
|
51
|
+
_OG_TRIGGERS = set("oOgG")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def normalize_apostrophes(text: str) -> str:
|
|
55
|
+
"""
|
|
56
|
+
Rewrite every apostrophe-lookalike in `text` to the correct Uzbek
|
|
57
|
+
character: U+02BB (ʻ) right after o/g, otherwise U+02BC (ʼ).
|
|
58
|
+
|
|
59
|
+
NFC-normalizes first so precomposed vs. decomposed forms don't sneak
|
|
60
|
+
through as false negatives.
|
|
61
|
+
"""
|
|
62
|
+
text = unicodedata.normalize("NFC", text)
|
|
63
|
+
|
|
64
|
+
def repl(match: "re.Match[str]") -> str:
|
|
65
|
+
idx = match.start()
|
|
66
|
+
prev_char = text[idx - 1] if idx > 0 else ""
|
|
67
|
+
if prev_char in _OG_TRIGGERS:
|
|
68
|
+
return TURNED_COMMA
|
|
69
|
+
return APOSTROPHE
|
|
70
|
+
|
|
71
|
+
return _PATTERN.sub(repl, text)
|