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