akademtranslit 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.
Files changed (40) hide show
  1. akademtranslit-0.2.0/CHANGELOG.md +101 -0
  2. akademtranslit-0.2.0/LICENSE +21 -0
  3. akademtranslit-0.2.0/MANIFEST.in +5 -0
  4. akademtranslit-0.2.0/PKG-INFO +411 -0
  5. akademtranslit-0.2.0/README.md +379 -0
  6. akademtranslit-0.2.0/golden/SHA256SUMS +3 -0
  7. akademtranslit-0.2.0/golden/golden-0.1.1.json +7854 -0
  8. akademtranslit-0.2.0/golden/golden-0.2.0.json +39559 -0
  9. akademtranslit-0.2.0/golden/inputs.json +8393 -0
  10. akademtranslit-0.2.0/pyproject.toml +63 -0
  11. akademtranslit-0.2.0/setup.cfg +4 -0
  12. akademtranslit-0.2.0/src/akademtranslit/__init__.py +195 -0
  13. akademtranslit-0.2.0/src/akademtranslit/apostrophes.py +83 -0
  14. akademtranslit-0.2.0/src/akademtranslit/collation.py +136 -0
  15. akademtranslit-0.2.0/src/akademtranslit/detection.py +83 -0
  16. akademtranslit-0.2.0/src/akademtranslit/normalize.py +149 -0
  17. akademtranslit-0.2.0/src/akademtranslit/py.typed +0 -0
  18. akademtranslit-0.2.0/src/akademtranslit/rules.py +310 -0
  19. akademtranslit-0.2.0/src/akademtranslit/tables.py +241 -0
  20. akademtranslit-0.2.0/src/akademtranslit.egg-info/PKG-INFO +411 -0
  21. akademtranslit-0.2.0/src/akademtranslit.egg-info/SOURCES.txt +38 -0
  22. akademtranslit-0.2.0/src/akademtranslit.egg-info/dependency_links.txt +1 -0
  23. akademtranslit-0.2.0/src/akademtranslit.egg-info/requires.txt +3 -0
  24. akademtranslit-0.2.0/src/akademtranslit.egg-info/top_level.txt +1 -0
  25. akademtranslit-0.2.0/tests/test_alphabets.py +75 -0
  26. akademtranslit-0.2.0/tests/test_apostrophes.py +134 -0
  27. akademtranslit-0.2.0/tests/test_changes_0_2_0.py +226 -0
  28. akademtranslit-0.2.0/tests/test_collation.py +168 -0
  29. akademtranslit-0.2.0/tests/test_detection.py +98 -0
  30. akademtranslit-0.2.0/tests/test_golden.py +45 -0
  31. akademtranslit-0.2.0/tests/test_normalize.py +138 -0
  32. akademtranslit-0.2.0/tests/test_public_api_compat.py +102 -0
  33. akademtranslit-0.2.0/tests/test_readme.py +14 -0
  34. akademtranslit-0.2.0/tests/test_regressions.py +459 -0
  35. akademtranslit-0.2.0/tests/test_roundtrip.py +201 -0
  36. akademtranslit-0.2.0/tests/test_to_cyrillic.py +146 -0
  37. akademtranslit-0.2.0/tests/test_to_latin.py +129 -0
  38. akademtranslit-0.2.0/tools/bench.py +80 -0
  39. akademtranslit-0.2.0/tools/check_classifiers.py +74 -0
  40. akademtranslit-0.2.0/tools/golden.py +213 -0
@@ -0,0 +1,101 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 — 2026-11-08
4
+
5
+ ### Added
6
+
7
+ - `sort_key(text, orthography)`: alphabetical order in `cyrillic`,
8
+ `latin_1995` or `latin_2026`. In `latin_2026`, `ç` comes after `ş`. In
9
+ `latin_1995`, `oʻ gʻ sh ch` are single letters after `z`. In Cyrillic,
10
+ `Ў Қ Ғ Ҳ` come after `Я`. The orthography is required; there is no
11
+ detection.
12
+ - `normalize_apostrophes(text, style="unicode")`: rewrites the marks in Latin
13
+ words to `ʻ`/`ʼ` (`"unicode"`) or `'` (`"ascii"`) without converting anything
14
+ else.
15
+ - `script_class(text)` (`latin`, `cyrillic` or `mixed`) and
16
+ `cyrillic_language(text)` (`uz`, `ru`, `mixed` or `unknown`): closed-set
17
+ answers for routing. `detect_script` is unchanged.
18
+ - `LATIN_1995_ALPHABET` (28 units: 24 letters, then `oʻ gʻ sh ch`) and
19
+ `CYRILLIC_ALPHABET` (35 letters).
20
+ - Golden vectors in `golden/`: 1121 inputs, the 0.1.1 and 0.2.0 outputs and
21
+ their SHA-256 sums, with `tools/golden.py` to write, check and diff them.
22
+ - The README examples and the docstring examples run as tests. A test pins the
23
+ module-level names that downstream code imports.
24
+
25
+ ### Changed
26
+
27
+ - `LATIN_2026_ALPHABET` is in the order of Law No. O'RQ-1184: `ç` moved from
28
+ after `b` to after `ş`. No conversion output depends on it.
29
+ - `__version__` is the only place the version is written.
30
+
31
+ ### Fixed
32
+
33
+ - Cyrillic → Latin: `ъ` is dropped after a consonant before `е ё ю я`
34
+ (`объект` → `obyekt`, `съёмка` → `syomka`) and after `ў` (`мўъжиза` →
35
+ `moʻjiza`), as the 1995 spelling writes them; it was `ʼ` everywhere
36
+ (`obʼyekt`, `moʻʼjiza`). Elsewhere it is still `ʼ` (`маъно` → `maʼno`). The
37
+ reverse direction is unchanged (`obyekt` → `обект`), so this is a documented
38
+ lossy mapping.
39
+ - Latin → Cyrillic: `ts` is `т` + `с` before the particles `-gina`, `-kina`,
40
+ `-yu`, `-u`, `-ku`, `-yoq` and `-mikan` after a suffix: `aytsangizgina` →
41
+ `айтсангизгина`, `ketsayu` → `кетсаю`, `tutsinmikan` → `тутсинмикан`; they
42
+ came out with `ц`.
43
+ - Latin → Cyrillic: `oksitotsin`, `rifampitsin` and the `-rubitsin` drugs keep
44
+ `ц` again (`окситоцин`, `рифампицин`, `доксорубицин`), as in 0.1.0; 0.1.1
45
+ gave `окситотсин`. Verbs with the same ending (`bitsin`, `ketsin`) keep
46
+ `т` + `с`.
47
+ - `normalize`: `´` (U+00B4) reads as an apostrophe, like the other seven
48
+ marks. Unicode normalization (NFKC) used to turn it into a space and a
49
+ combining accent, so `normalize("o´zbek")` was `"o zbek"`; it is now
50
+ `"ozbek"`, the same key as `oʻzbek` and `o'zbek`. No other input's key
51
+ changes.
52
+
53
+ ### Output changes
54
+
55
+ Golden diff, 0.1.1 → 0.2.0, over 1121 inputs
56
+ (`python tools/golden.py --diff golden/golden-0.1.1.json golden/golden-0.2.0.json`):
57
+
58
+ | Field | Changed |
59
+ | --- | ---: |
60
+ | `normalize` | 5 |
61
+ | `detect_script` | 0 |
62
+ | `to_cyrillic` | 13 |
63
+ | `to_latin_1995` | 9 |
64
+ | `to_latin_2026` | 9 |
65
+
66
+ - `to_latin_1995` and `to_latin_2026`: the nine inputs with a dropped `ъ`, e.g.
67
+ `объект` `obʼyekt` → `obyekt`, `мўъжиза` `moʻʼjiza` → `moʻjiza` (2026:
68
+ `möʼjiza` → `möjiza`).
69
+ - `to_cyrillic`: seven particle forms (`aytsangizgina` `айцангизгина` →
70
+ `айтсангизгина`) and six drug names (`oksitotsin` `окситотсин` →
71
+ `окситоцин`).
72
+ - `normalize`: the five inputs with `´` inside a word: `o´zbek` `o zbek` →
73
+ `ozbek`, `g´alaba`, `ma´no`, `jur´at` and `O´ZBEKISTON` likewise. A search
74
+ index built with 0.1.x needs these keys rebuilt.
75
+
76
+ ### Packaging
77
+
78
+ - License metadata in the PEP 639 form (`license = "MIT"`,
79
+ `license-files`); the license classifier is gone, as PEP 639 asks.
80
+ - `py.typed` is shipped. The sdist includes `CHANGELOG.md`, `tests/`, `golden/`
81
+ and `tools/`, so the test suite can run from it.
82
+ - The version is read from `akademtranslit.__version__`; the build needs
83
+ `setuptools>=77`.
84
+ - Version classifiers name only the Python versions the test suite has run on.
85
+ - Dropped the `Natural Language :: Uzbek` classifier: it is not in the PyPI
86
+ classifier list, and PyPI rejects uploads that carry it.
87
+ `tools/check_classifiers.py` now checks the list in CI and before every
88
+ release.
89
+ - Still Python 3.9 or newer, and still no dependencies.
90
+
91
+ ## 0.1.1 — 2026-10-16
92
+
93
+ ### Fixed
94
+
95
+ - Latin → Cyrillic: `y` before `oʻ` is `й` + `ў` (`yoʻq` → `йўқ`), not `ё` and a stray mark (`ёʻқ`).
96
+ - Latin → Cyrillic: `ts` is `т` + `с` before an Uzbek suffix (`ketsa` → `кетса`, `hayotsiz` → `ҳаётсиз`) and `т` + `ш` before `h` (`adabiyotshunos` → `адабиётшунос`); loanwords keep `ц`, with the misses listed in the README. A short list of loanwords whose tail reads like a suffix keeps `ц` as well (`aktsiz`, `tablitsa`, `levofloksatsin`, `leytsin`).
97
+ - Words in all caps that contain `ʻ` or `ʼ` stay in all caps (`OʻZBEKISTON` → `ЎЗБЕКИСТОН`, `ÖZBEKISTON`); they came out as `Ўзбекистон`.
98
+
99
+ ## 0.1.0 — 2026-08-15
100
+
101
+ First release.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AkademTech
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.
@@ -0,0 +1,5 @@
1
+ include CHANGELOG.md
2
+ graft tests
3
+ graft golden
4
+ graft tools
5
+ global-exclude __pycache__ *.py[cod]
@@ -0,0 +1,411 @@
1
+ Metadata-Version: 2.4
2
+ Name: akademtranslit
3
+ Version: 0.2.0
4
+ Summary: Uzbek transliteration across three orthographies: Cyrillic, the 1995 Latin alphabet, and the 2026 reform.
5
+ Author-email: AkademTech <contact@akademtech.org>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://akademtech.org
8
+ Project-URL: Source, https://github.com/AkademTech/akademtranslit
9
+ Project-URL: Changelog, https://github.com/AkademTech/akademtranslit/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/AkademTech/akademtranslit/issues
11
+ Keywords: uzbek,transliteration,cyrillic,latin,romanization,normalization,alphabet-reform
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Text Processing :: Linguistic
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Provides-Extra: test
30
+ Requires-Dist: pytest>=7; extra == "test"
31
+ Dynamic: license-file
32
+
33
+ # AkademTranslit
34
+
35
+ Uzbek transliteration across three orthographies — Cyrillic, the 1995 Latin
36
+ alphabet, and the 2026 Latin alphabet — plus a normalization key that makes one
37
+ word searchable across all three, and alphabetical sorting in each.
38
+
39
+ Pure Python, standard library only, no dependencies.
40
+
41
+ ```python
42
+ >>> from akademtranslit import to_latin, to_cyrillic, convert, normalize
43
+ >>> to_latin("Тошкент давлат университети")
44
+ 'Toşkent davlat universiteti'
45
+ >>> to_latin("Тошкент давлат университети", variant="latin_1995")
46
+ 'Toshkent davlat universiteti'
47
+ >>> to_cyrillic("Oʻzbekiston Respublikasi")
48
+ 'Ўзбекистон Республикаси'
49
+ >>> normalize("Şoşimova") == normalize("Shoshimova") == normalize("Шошимова")
50
+ True
51
+
52
+ ```
53
+
54
+ ## Why
55
+
56
+ Uzbek is written three ways at once, and will be for years.
57
+
58
+ Cyrillic carries the archives, the older scholarly record and a large share of
59
+ the press. The Latin alphabet adopted in 1993 and revised in 1995 carries
60
+ official documents and most of the web; it writes four sounds with digraphs and
61
+ modifier marks — `sh`, `ch`, `gʻ`, `oʻ`. The 2026 alphabet, set by Law No.
62
+ O'RQ-1184 (signed 21 October 2026, in force from 22 April 2027), replaces those
63
+ four with single letters — `ş`, `ç`, `ğ`, `ö` — giving 28 letters plus the
64
+ tutuq belgisi.
65
+
66
+ For a reader this is an inconvenience. For a search index it is a correctness
67
+ problem: the same author, journal or institution arrives spelled three
68
+ different ways in three different records, and string equality finds none of
69
+ the matches. `Шошимова`, `Shoshimova` and `Şoşimova` are one person.
70
+
71
+ This library does separate jobs, and keeping them separate is the point:
72
+
73
+ - **Transliteration** (`to_latin`, `to_cyrillic`, `convert`) — display-grade,
74
+ preserves case and punctuation. Use it to show text to people and to migrate
75
+ a corpus from one alphabet to another.
76
+ - **Normalization** (`normalize`) — deliberately lossy, produces a match key
77
+ nobody should ever display. Use it for search, deduplication and equality.
78
+ - **Sorting** (`sort_key`) — the alphabetical order of each orthography, which
79
+ code-point order gets wrong in all three.
80
+
81
+ ## Install
82
+
83
+ ```bash
84
+ pip install akademtranslit
85
+ ```
86
+
87
+ Requires Python 3.9 or newer. No dependencies.
88
+
89
+ ## Usage
90
+
91
+ ### Convert between the three orthographies
92
+
93
+ ```python
94
+ >>> from akademtranslit import convert
95
+ >>> convert("Тошкент", source="cyrillic", target="latin_2026")
96
+ 'Toşkent'
97
+ >>> convert("Toshkent", source="latin_1995", target="latin_2026")
98
+ 'Toşkent'
99
+ >>> convert("Toşkent", source="latin_2026", target="cyrillic")
100
+ 'Тошкент'
101
+
102
+ ```
103
+
104
+ `source` defaults to `"auto"`, which runs script detection first, and `target`
105
+ defaults to `"latin_2026"`.
106
+
107
+ ### Migrate an existing corpus to the 2026 alphabet
108
+
109
+ The 1995 ↔ 2026 conversion is lossless in both directions, so a migration can
110
+ be verified and, if needed, reversed.
111
+
112
+ ```python
113
+ >>> old = "Mirzo Ulugʻbek nomidagi Oʻzbekiston Milliy universiteti"
114
+ >>> new = convert(old, source="latin_1995", target="latin_2026")
115
+ >>> new
116
+ 'Mirzo Uluğbek nomidagi Özbekiston Milliy universiteti'
117
+ >>> convert(new, source="latin_2026", target="latin_1995") == old
118
+ True
119
+
120
+ ```
121
+
122
+ ### Build a cross-script search key
123
+
124
+ ```python
125
+ >>> from akademtranslit import normalize
126
+ >>> index = {}
127
+ >>> for record in ["Şoşimova N.", "Shoshimova N.", "Шошимова Н."]:
128
+ ... index.setdefault(normalize(record), []).append(record)
129
+ >>> index
130
+ {'shoshimova n': ['Şoşimova N.', 'Shoshimova N.', 'Шошимова Н.']}
131
+
132
+ ```
133
+
134
+ `normalize` is idempotent and folds orthography, case, punctuation and every
135
+ apostrophe style in use: all eight accepted marks (see below) give the same
136
+ key, so `o'zbek`, `o’zbek`, `o´zbek` and `oʻzbek` all match.
137
+
138
+ ### Sort in alphabetical order
139
+
140
+ `sort_key(text, orthography)` sorts by the alphabet of the orthography you
141
+ name. It has to be named — there is no detection — because the same string
142
+ sorts differently in different orthographies.
143
+
144
+ ```python
145
+ >>> from akademtranslit import sort_key
146
+ >>> sorted(["tabiat", "çaqmoq", "sabr", "şahar"], key=lambda w: sort_key(w, "latin_2026"))
147
+ ['sabr', 'şahar', 'çaqmoq', 'tabiat']
148
+ >>> sorted(["choy", "shamol", "zarar", "oʻrik", "gʻoz"], key=lambda w: sort_key(w, "latin_1995"))
149
+ ['zarar', 'oʻrik', 'gʻoz', 'shamol', 'choy']
150
+ >>> sorted(["ҳаво", "ғоз", "ялпиз", "қовун", "ўрик"], key=lambda w: sort_key(w, "cyrillic"))
151
+ ['ялпиз', 'ўрик', 'қовун', 'ғоз', 'ҳаво']
152
+
153
+ ```
154
+
155
+ In `latin_1995`, `oʻ`, `gʻ`, `sh` and `ch` are single letters after `z`, while
156
+ a separated `sʼh` is two: `Isʼhoq` sorts before `Ishonch`. Whitespace sorts
157
+ before digits, digits before letters, and letters outside the alphabet after
158
+ it; punctuation and the tutuq belgisi are not counted. When two keys tie on
159
+ letters, the text itself decides, so the order is total.
160
+
161
+ ### Put the apostrophes in one form
162
+
163
+ ```python
164
+ >>> from akademtranslit import normalize_apostrophes
165
+ >>> normalize_apostrophes("O'zbekiston, ma'no, bog'")
166
+ 'Oʻzbekiston, maʼno, bogʻ'
167
+ >>> normalize_apostrophes("Oʻzbekiston, maʼno", style="ascii")
168
+ "O'zbekiston, ma'no"
169
+
170
+ ```
171
+
172
+ Only marks inside Latin words change; quotes around a word, digits,
173
+ punctuation and words with a Cyrillic letter are left as they are. It does not
174
+ convert anything, so it is safe on text whose orthography is unknown.
175
+
176
+ ### Detect what you were handed
177
+
178
+ ```python
179
+ >>> from akademtranslit import detect_script, script_class, cyrillic_language
180
+ >>> detect_script("Самарқанд"), detect_script("Oʻzbekiston"), detect_script("Özbekiston")
181
+ ('cyrillic', 'latin_1995', 'latin_2026')
182
+ >>> script_class("Тошкент"), script_class("Toshkent"), script_class("Tоshkent")
183
+ ('cyrillic', 'latin', 'mixed')
184
+ >>> cyrillic_language("Ўзбекистон"), cyrillic_language("Мы здесь"), cyrillic_language("бир")
185
+ ('uz', 'ru', 'unknown')
186
+
187
+ ```
188
+
189
+ `detect_script` names an orthography. `script_class` answers a narrower
190
+ question with a closed set — `latin`, `cyrillic` or `mixed` — and is the one to
191
+ use for catching homoglyphs: the `о` in the third example is Cyrillic.
192
+ `cyrillic_language` tells Uzbek Cyrillic from Russian by the letters only one
193
+ of them has (`ў қ ғ ҳ` against `щ ы`); it is a routing heuristic, not a
194
+ language identifier, and short text is often `unknown`.
195
+
196
+ ## Supported orthographies
197
+
198
+ The three alphabets, in their alphabetical order, are exposed as
199
+ `LATIN_2026_ALPHABET`, `LATIN_1995_ALPHABET` and `CYRILLIC_ALPHABET`:
200
+
201
+ | Orthography | Letters, in order |
202
+ | --- | --- |
203
+ | `latin_2026` (28) | A B D E F G Ğ H I J K L M N O Ö P Q R S Ş Ç T U V X Y Z |
204
+ | `latin_1995` (28) | A B D E F G H I J K L M N O P Q R S T U V X Y Z Oʻ Gʻ Sh Ch |
205
+ | `cyrillic` (35) | А Б В Г Д Е Ё Ж З И Й К Л М Н О П Р С Т У Ф Х Ц Ч Ш Ъ Ь Э Ю Я Ў Қ Ғ Ҳ |
206
+
207
+ The tutuq belgisi `ʼ` belongs to both Latin alphabets but is not a letter: it
208
+ is a separate sign, `TUTUQ`, and is in neither list.
209
+
210
+ | Sound | `cyrillic` | `latin_1995` | `latin_2026` |
211
+ | --- | --- | --- | --- |
212
+ | /ʃ/ | ш | sh | **ş** |
213
+ | /tʃ/ | ч | ch | **ç** |
214
+ | /ʁ/ | ғ | gʻ | **ğ** |
215
+ | /oʻ/ | ў | oʻ | **ö** |
216
+ | /q/ | қ | q | q |
217
+ | /h/ | ҳ | h | h |
218
+ | /x/ | х | x | x |
219
+ | /dʒ/ | ж | j | j |
220
+ | glottal stop | ъ | ʼ | ʼ |
221
+
222
+ Only the four bolded rows differ between the two Latin alphabets. Everything
223
+ else is shared, which is why `latin_1995` serves as the pivot for every
224
+ conversion.
225
+
226
+ ### Context-dependent rules
227
+
228
+ Five readings depend on position rather than on the letter alone:
229
+
230
+ | Rule | Example |
231
+ | --- | --- |
232
+ | `е` is `ye` word-initially and after a vowel, `e` otherwise | Ер → Yer, поезд → poyezd, тезда → tezda |
233
+ | `ц` is `ts` after a vowel, `s` otherwise | милиция → militsiya, функция → funksiya, цирк → sirk |
234
+ | `ts` is `т` + `с` before an Uzbek suffix, `т` + `ш` before `h`, `ц` otherwise — a heuristic, see below | ketsa → кетса, hayotsiz → ҳаётсиз, adabiyotshunos → адабиётшунос, operatsiya → операция |
235
+ | `с` before `ҳ` takes a separator in `latin_1995` | Исҳоқ → Isʼhoq (not `Ishoq`) |
236
+ | `ъ` is dropped after a consonant before `е ё ю я` and after `ў`, `ʼ` otherwise | объект → obyekt, мўъжиза → moʻjiza, маъно → maʼno |
237
+
238
+ The separator needs a note. In `latin_1995`, `sh` is a digraph, so a genuine
239
+ s+h sequence has to be separated with the tutuq belgisi or it reads as `ш`. The
240
+ 2026 alphabet does not need the separator, because `ş` carries that sound —
241
+ `Isʼhoq` becomes simply `Ishoq`, while `ish` (work) becomes `iş`. The pair
242
+ survives the round trip in both directions.
243
+
244
+ The `ts` rule needs a note of its own. In loanwords `ts` is the letter `ц`;
245
+ where a stem ending in `t` meets a suffix beginning with `s` it is two letters:
246
+ `ketsa` (if he goes) is кетса, not кеца. There is no dictionary, only a short
247
+ list of loanwords, so the reading comes from the shape of the word. The checks
248
+ run in order and the first that applies wins:
249
+
250
+ - the `s` starts `sh` → `т` + `ш`: adabiyotshunos → адабиётшунос;
251
+ - the word starts with `ts` → `ц`: tsement → цемент;
252
+ - `i` and then a vowel or `y` follow → `ц`: operatsiya → операция, ratsion →
253
+ рацион;
254
+ - a loanword whose tail would pass for suffixes → `ц`. The list: names in
255
+ `-mitsin`, `-rubitsin`, `-floksatsin` and `-leytsin`, plus `glitsin`,
256
+ `oksitotsin`, `rifampitsin`, `amikatsin` and `aktsiz`; and words beginning
257
+ with one of the `-ица` stems `tablits`, `teplits`, `stolits`, `ulits`,
258
+ `bolnits`, `granits`, `gostinits` (`TS_LOANWORD_STEMS` in `tables.py`).
259
+ Inflected forms count too: levofloksatsinni → левофлоксацинни, tablitsada →
260
+ таблицада. The list exists to split `ts`; it is not a dictionary and is not
261
+ meant to grow. Uzbek words with the same endings keep `т` + `с` — bitsa,
262
+ bitsin, oʻqitsa, koʻrsatsin, and nouns in `-kt` with `-siz` such as
263
+ kontaktsiz;
264
+ - the rest of the word is Uzbek suffixes → `т` + `с`. The `s` must open one of
265
+ `-sa`, `-sak`, `-sam`, `-sang`, `-sangiz`, `-salar`, `-sin`, `-sinlar`,
266
+ `-siz`, `-sizlik` or `-sevar`, and anything after it must be further suffixes
267
+ or particles (`-lik`, `-lar`, `-ga`, `-da`, `-ni`, `-man`, `-mi`, `-gina`,
268
+ `-yu`, `-ku`, `-yoq`, `-mikan`, …): ketsinmi → кетсинми, hayotsizlik →
269
+ ҳаётсизлик, aytsangizgina → айтсангизгина. Starting with the right letters is
270
+ not enough, so meditsina (`sin` + `a`) and natsizm (`siz` + `m`) keep `ц`;
271
+ - otherwise `ц`: leykotsit → лейкоцит, protsent → процент.
272
+
273
+ The known misses:
274
+
275
+ - A loanword off that list whose tail looks like a suffix gets `т` + `с`:
276
+ karbetotsin → карбетотсин (карбетоцин). oksitotsin, rifampitsin and the
277
+ `-rubitsin` names had this problem in 0.1.1 and are on the list since 0.2.0.
278
+ - A stem on the list also takes a native ending: granitsiz (granite + `-siz`)
279
+ → границиз, not гранитсиз.
280
+ - An Uzbek word whose `t` + `s` boundary is not in the list keeps `ц`:
281
+ compounds such as oʻtsoʻndirgich → ўцўндиргич (ўтсўндиргич) and
282
+ soatsoz → соацоз (соатсоз), and suffixes such as `-simon` (otsimon → оцимон).
283
+ - pitsa and nitsa are wrong either way: питса and нитса now, пица and ница
284
+ before 0.1.1, пицца and Ницца in fact.
285
+
286
+ ### The two modifier marks
287
+
288
+ `ʻ` (U+02BB, turned comma) is part of the letters `oʻ` and `gʻ`. `ʼ` (U+02BC,
289
+ modifier apostrophe) is the tutuq belgisi. Most people type both as a plain
290
+ `'`, so the library recovers the distinction from context: after `o` or `g` the
291
+ mark is a turned comma, elsewhere it is the tutuq belgisi.
292
+
293
+ Input accepts eight characters for either mark: `ʻ` U+02BB, `ʼ` U+02BC, `’`
294
+ U+2019, `‘` U+2018, `´` U+00B4, `ʹ` U+02B9, `'` U+0027 and `` ` `` U+0060.
295
+ Conversion always emits the canonical two, which makes it idempotent and its
296
+ output safe to store; `normalize_apostrophes` does the same without converting,
297
+ or writes `'` for both with `style="ascii"`. `normalize` drops all eight, so the
298
+ mark a word was typed with never changes its key (since 0.2.0 this includes
299
+ `´`, which Unicode normalization used to turn into a space).
300
+
301
+ ## Guarantees, and their limits
302
+
303
+ **Lossless.** `latin_1995` ↔ `latin_2026` round-trips exactly for text in
304
+ canonical form: marks written as `ʻ` and `ʼ`, and each word in lower case,
305
+ with an initial capital, or in all caps (`OʻZBEKISTON` included). Other input
306
+ comes back canonicalised — `o'zbek` as `oʻzbek`, `McDonald` as `Mcdonald`.
307
+ Cyrillic → Latin → Cyrillic round-trips exactly over the native Uzbek letter
308
+ inventory, with two gaps: `т` followed by `с` comes back as `ц` unless the `s`
309
+ starts a suffix the `ts` rule knows (`ўтсўндиргич`, `соатсоз`; see
310
+ Context-dependent rules), and `ъ` after `ў` does not come back (`мўъжиза` →
311
+ `moʻjiza` → `мўжиза`). Both are enforced by the test suite.
312
+
313
+ **Known lossy mappings.** Four Cyrillic letters occur only in Russian
314
+ loanwords, and folding them is deliberate; `ъ` is dropped where the 1995
315
+ spelling drops it:
316
+
317
+ | Letter | Behaviour | Reason |
318
+ | --- | --- | --- |
319
+ | щ | merges with ш → `sh` | no distinct sound in Uzbek |
320
+ | ы | merges with и → `i` | no distinct sound in Uzbek |
321
+ | ь | dropped | not represented in either Latin alphabet |
322
+ | ц | positional `ts`/`s`, not reversible | a heuristic; loanword spelling is genuinely inconsistent |
323
+ | ъ | dropped after a consonant before `е ё ю я` and after `ў` (`объект` → `obyekt`, `мўъжиза` → `moʻjiza`), `ʼ` elsewhere; not reversible | the 1995 spelling writes no mark there, so the Latin side has nothing to restore it from |
324
+
325
+ **Script detection is a heuristic.** A `latin_2026` string containing none of
326
+ the four reformed letters is indistinguishable from a `latin_1995` one. That is
327
+ harmless for nearly every word — the two orthographies write them identically —
328
+ but not for a separated s+h: `Ishoq` is Исҳоқ in `latin_2026` and Ишоқ in
329
+ `latin_1995`. Pass `source` explicitly when the orthography is known from
330
+ context.
331
+
332
+ **Out of scope.** `normalize` folds orthography, not romanization choice. It
333
+ does not treat `Xomidov` and `Khomidov`, or `Jasur` and `Zhasur`, as the same
334
+ string — those are different transcription conventions, and collapsing them
335
+ would merge names belonging to different people. Handle them upstream as scored
336
+ candidates. Translation matching and fuzzy or edit-distance matching are
337
+ likewise out of scope.
338
+
339
+ **A note on `ö`.** Law No. O'RQ-1184 writes the letter as `ö`, and that is
340
+ what this library emits. Earlier drafts of the reform used `ō` (o with macron)
341
+ or `ǒ`; both are accepted on input as aliases of `ö`, so text written under a
342
+ draft reads correctly, but they are never output.
343
+
344
+ ## Golden vectors
345
+
346
+ `golden/` holds the library's output, sealed:
347
+
348
+ - `inputs.json` — the inputs: every letter of the three alphabets, the context
349
+ rules, every apostrophe form, mixed content, ecosystem traps. Append-only: an
350
+ `id` never changes and is never reused.
351
+ - `golden-<version>.json` — the output of every public function on every input,
352
+ for that release.
353
+ - `SHA256SUMS` — the hashes of those files.
354
+
355
+ Check them with:
356
+
357
+ ```bash
358
+ cd golden
359
+ shasum -a 256 -c SHA256SUMS # or: sha256sum -c SHA256SUMS
360
+ ```
361
+
362
+ The files are the shared reference for code that switches to this library and
363
+ for the ports to other languages (JavaScript, PHP): a port is correct when it
364
+ reproduces `golden-<version>.json` byte for byte. `python tools/golden.py
365
+ --check` regenerates the file from the source and compares;
366
+ `python tools/golden.py --diff OLD NEW` lists every output that changed between
367
+ two releases.
368
+
369
+ ## Versioning
370
+
371
+ [Semantic versioning](https://semver.org). The output of `normalize()` never
372
+ changes in a patch release (0.y.Z): search indexes are built on it. Every
373
+ change to any output is listed in [CHANGELOG.md](CHANGELOG.md) with its golden
374
+ diff.
375
+
376
+ ## Development
377
+
378
+ ```bash
379
+ python3 -m venv .venv
380
+ .venv/bin/pip install -e ".[test]"
381
+ .venv/bin/python -m pytest
382
+ ```
383
+
384
+ Or with [uv](https://docs.astral.sh/uv/): `uv run --with pytest -- python -m
385
+ pytest`.
386
+
387
+ The suite covers the alphabet letter by letter in both directions, the context
388
+ rules, the round-trip tiers above, casing (including ALL-CAPS, where `ШОШИМОВА`
389
+ must give `ŞOŞIMOVA` and not `Şoşimova`, and `OʻZBEKISTON` must give
390
+ `ЎЗБЕКИСТОН`), the cross-script normalization contract, sorting, and the
391
+ golden vectors. The examples in this README and in the docstrings run as tests.
392
+ `tests/test_regressions.py` and `tests/test_changes_0_2_0.py` pin every
393
+ behaviour change since 0.1.0; see [CHANGELOG.md](CHANGELOG.md).
394
+
395
+ ## Security
396
+
397
+ See [SECURITY.md](SECURITY.md). The library does not use the network and
398
+ stores nothing.
399
+
400
+ ## Contributing
401
+
402
+ See [CONTRIBUTING.md](CONTRIBUTING.md). This project follows a
403
+ [code of conduct](CODE_OF_CONDUCT.md).
404
+
405
+ ## License
406
+
407
+ MIT — see [LICENSE](LICENSE).
408
+
409
+ ## Who
410
+
411
+ Built by [AkademTech](https://akademtech.org).