uzkit 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,21 @@
1
+ name: tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ pytest:
10
+ runs-on: ubuntu-24.04
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.9", "3.11", "3.13"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - run: pip install -e ".[dev]"
21
+ - run: pytest
uzkit-0.1.0/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ .pytest_cache/
6
+ build/
7
+ dist/
8
+ .DS_Store
@@ -0,0 +1,3 @@
1
+ {
2
+ "python-envs.defaultEnvManager": "ms-python.python:system"
3
+ }
uzkit-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ali
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.
uzkit-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.5
2
+ Name: uzkit
3
+ Version: 0.1.0
4
+ Summary: O'zbek tili va ma'lumotlari uchun Python kutubxonasi: transliteratsiya, raqamlarni so'zga aylantirish, so'm summalari, telefon va avtoraqam validatorlari, viloyatlar.
5
+ Project-URL: Homepage, https://github.com/Mr-Ali-web/Uzkit
6
+ Project-URL: Issues, https://github.com/Mr-Ali-web/Uzkit/issues
7
+ Author: Ali
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: license-plate,numbers,phone,transliteration,uzbek,uzbekistan,validators
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ [![tests](https://github.com/Mr-Ali-web/uzkit/actions/workflows/tests.yml/badge.svg)](https://github.com/Mr-Ali-web/uzkit/actions/workflows/tests.yml)
29
+
30
+ # uzkit
31
+
32
+ O'zbek tili va ma'lumotlari uchun yordamchi Python kutubxonasi.
33
+ Small Python library for Uzbek language and data helpers.
34
+
35
+ ## O'rnatish / Installation
36
+
37
+ ```bash
38
+ pip install -e ".[dev]"
39
+ ```
40
+
41
+ ## Misollar / Examples
42
+
43
+ ```python
44
+ from uzkit import to_cyrillic, to_latin, number_to_words, money_to_words, format_money, normalize_phone, is_valid_phone, is_valid_plate, ordinal
45
+
46
+ to_cyrillic("Salom, dunyo!") # 'Салом, дунё!'
47
+ to_latin("Салом, дунё!") # 'Salom, dunyo!'
48
+ number_to_words(1250) # 'bir ming ikki yuz ellik'
49
+ money_to_words(1250) # 'bir ming ikki yuz ellik soʻm'
50
+ format_money(1250000) # '1 250 000 soʻm'
51
+ normalize_phone("+998 90 123 45 67") # '+998901234567'
52
+ is_valid_phone("+998 90 123 45 67") # True
53
+ is_valid_plate("01A123BC") # True
54
+ ordinal(3) # 'uchinchi'
55
+ ```
56
+
57
+ ## Testlar / Tests
58
+
59
+ ```bash
60
+ pytest
61
+ ```
62
+
63
+ ## Cheklovlar / Limitations
64
+
65
+ - Telefon kodlari Wikipedia va operator sahifalariga asoslangan, regulyatorning rasmiy hujjati bilan tekshirilmagan.
66
+ - Faqat mobil raqamlar qo'llab-quvvatlanadi.
67
+ - Viloyatlar ro'yxati Wikipedia/ISO 3166-2:UZ manbalariga asoslangan; rasmiy davlat manbasi bilan to'liq solishtirilmagan.
68
+
69
+ Operator and region codes are not verified against an official source. Only mobile phone numbers are supported.
70
+ Region list is based on Wikipedia/ISO 3166-2:UZ and has not been fully reconciled with official government sources.# Uzkit
uzkit-0.1.0/README.md ADDED
@@ -0,0 +1,43 @@
1
+ [![tests](https://github.com/Mr-Ali-web/uzkit/actions/workflows/tests.yml/badge.svg)](https://github.com/Mr-Ali-web/uzkit/actions/workflows/tests.yml)
2
+
3
+ # uzkit
4
+
5
+ O'zbek tili va ma'lumotlari uchun yordamchi Python kutubxonasi.
6
+ Small Python library for Uzbek language and data helpers.
7
+
8
+ ## O'rnatish / Installation
9
+
10
+ ```bash
11
+ pip install -e ".[dev]"
12
+ ```
13
+
14
+ ## Misollar / Examples
15
+
16
+ ```python
17
+ from uzkit import to_cyrillic, to_latin, number_to_words, money_to_words, format_money, normalize_phone, is_valid_phone, is_valid_plate, ordinal
18
+
19
+ to_cyrillic("Salom, dunyo!") # 'Салом, дунё!'
20
+ to_latin("Салом, дунё!") # 'Salom, dunyo!'
21
+ number_to_words(1250) # 'bir ming ikki yuz ellik'
22
+ money_to_words(1250) # 'bir ming ikki yuz ellik soʻm'
23
+ format_money(1250000) # '1 250 000 soʻm'
24
+ normalize_phone("+998 90 123 45 67") # '+998901234567'
25
+ is_valid_phone("+998 90 123 45 67") # True
26
+ is_valid_plate("01A123BC") # True
27
+ ordinal(3) # 'uchinchi'
28
+ ```
29
+
30
+ ## Testlar / Tests
31
+
32
+ ```bash
33
+ pytest
34
+ ```
35
+
36
+ ## Cheklovlar / Limitations
37
+
38
+ - Telefon kodlari Wikipedia va operator sahifalariga asoslangan, regulyatorning rasmiy hujjati bilan tekshirilmagan.
39
+ - Faqat mobil raqamlar qo'llab-quvvatlanadi.
40
+ - Viloyatlar ro'yxati Wikipedia/ISO 3166-2:UZ manbalariga asoslangan; rasmiy davlat manbasi bilan to'liq solishtirilmagan.
41
+
42
+ Operator and region codes are not verified against an official source. Only mobile phone numbers are supported.
43
+ Region list is based on Wikipedia/ISO 3166-2:UZ and has not been fully reconciled with official government sources.# Uzkit
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "uzkit"
7
+ version = "0.1.0"
8
+ description = "O'zbek tili va ma'lumotlari uchun Python kutubxonasi: transliteratsiya, raqamlarni so'zga aylantirish, so'm summalari, telefon va avtoraqam validatorlari, viloyatlar."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Ali" }]
13
+ keywords = ["uzbekistan", "uzbek", "transliteration", "numbers", "validators", "phone", "license-plate"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Software Development :: Libraries",
26
+ "Typing :: Typed",
27
+ ]
28
+ dependencies = []
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/Mr-Ali-web/Uzkit"
32
+ Issues = "https://github.com/Mr-Ali-web/Uzkit/issues"
33
+
34
+ [project.optional-dependencies]
35
+ dev = ["pytest>=7.0"]
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/uzkit"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ pythonpath = ["src"]
43
+ addopts = "-ra"
@@ -0,0 +1,25 @@
1
+ """uzkit - O'zbekiston uchun kichik yordamchi vositalar to'plami."""
2
+
3
+ from uzkit.money import format_money, money_to_words
4
+ from uzkit.numbers import number_to_words, ordinal
5
+ from uzkit.regions import Region, get_region, list_regions
6
+ from uzkit.translit import to_cyrillic, to_latin
7
+ from uzkit.validators import is_valid_phone, is_valid_plate, normalize_phone
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ __all__ = [
12
+ "__version__",
13
+ "Region",
14
+ "format_money",
15
+ "get_region",
16
+ "is_valid_phone",
17
+ "is_valid_plate",
18
+ "list_regions",
19
+ "money_to_words",
20
+ "normalize_phone",
21
+ "number_to_words",
22
+ "ordinal",
23
+ "to_cyrillic",
24
+ "to_latin",
25
+ ]
@@ -0,0 +1,16 @@
1
+ [
2
+ {"name": "Andijon viloyati", "name_cyrillic": "Андижон вилояти", "capital": "Andijon", "capital_cyrillic": "Андижон", "plate_code": "60"},
3
+ {"name": "Buxoro viloyati", "name_cyrillic": "Бухоро вилояти", "capital": "Buxoro", "capital_cyrillic": "Бухоро", "plate_code": "80"},
4
+ {"name": "Farg'ona viloyati", "name_cyrillic": "Фарғона вилояти", "capital": "Farg'ona", "capital_cyrillic": "Фарғона", "plate_code": "40"},
5
+ {"name": "Jizzax viloyati", "name_cyrillic": "Жиззах вилояти", "capital": "Jizzax", "capital_cyrillic": "Жиззах", "plate_code": "25"},
6
+ {"name": "Namangan viloyati", "name_cyrillic": "Наманган вилояти", "capital": "Namangan", "capital_cyrillic": "Наманган", "plate_code": "50"},
7
+ {"name": "Navoiy viloyati", "name_cyrillic": "Навоий вилояти", "capital": "Navoiy", "capital_cyrillic": "Навоий", "plate_code": "85"},
8
+ {"name": "Qashqadaryo viloyati", "name_cyrillic": "Қашқадарё вилояти", "capital": "Qarshi", "capital_cyrillic": "Қарши", "plate_code": "70"},
9
+ {"name": "Qoraqalpog'iston Respublikasi", "name_cyrillic": "Қорақалпоғистон Республикаси", "capital": "Nukus", "capital_cyrillic": "Нукус", "plate_code": "95"},
10
+ {"name": "Samarqand viloyati", "name_cyrillic": "Самарқанд вилояти", "capital": "Samarqand", "capital_cyrillic": "Самарқанд", "plate_code": "30"},
11
+ {"name": "Sirdaryo viloyati", "name_cyrillic": "Сирдарё вилояти", "capital": "Guliston", "capital_cyrillic": "Гулистон", "plate_code": "20"},
12
+ {"name": "Surxondaryo viloyati", "name_cyrillic": "Сурхондарё вилояти", "capital": "Termiz", "capital_cyrillic": "Термиз", "plate_code": "75"},
13
+ {"name": "Toshkent shahri", "name_cyrillic": "Тошкент шаҳри", "capital": "Toshkent", "capital_cyrillic": "Тошкент", "plate_code": "01"},
14
+ {"name": "Toshkent viloyati", "name_cyrillic": "Тошкент вилояти", "capital": "Nurafshon", "capital_cyrillic": "Нурафшон", "plate_code": "10"},
15
+ {"name": "Xorazm viloyati", "name_cyrillic": "Хоразм вилояти", "capital": "Urganch", "capital_cyrillic": "Урганч", "plate_code": "90"}
16
+ ]
@@ -0,0 +1 @@
1
+ """Formatlash yordamchilari (hozircha bo'sh)."""
@@ -0,0 +1,56 @@
1
+ """So'm summalarini so'zga aylantirish va formatlash."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import ROUND_HALF_UP, Decimal
6
+ from typing import Union
7
+
8
+ from uzkit.numbers import number_to_words
9
+ from uzkit.translit import OKINA
10
+
11
+ SOM = f"so{OKINA}m"
12
+ TIYIN = "tiyin"
13
+
14
+ Amount = Union[int, float, Decimal, str]
15
+
16
+
17
+ def _to_decimal(amount: Amount) -> Decimal:
18
+ if isinstance(amount, bool) or not isinstance(amount, (int, float, Decimal, str)):
19
+ raise TypeError(f"Unsupported amount type: {type(amount).__name__}")
20
+ return Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
21
+
22
+
23
+ def money_to_words(amount: Amount, *, with_tiyin: bool = True) -> str:
24
+ """Summani so'm va tiyinda so'z bilan yozadi.
25
+
26
+ >>> money_to_words(1250)
27
+ 'bir ming ikki yuz ellik soʻm'
28
+ >>> money_to_words("1250.50")
29
+ 'bir ming ikki yuz ellik soʻm ellik tiyin'
30
+ """
31
+ value = _to_decimal(amount)
32
+ sign = "minus " if value < 0 else ""
33
+ value = abs(value)
34
+ som = int(value)
35
+ tiyin = int((value - som) * 100)
36
+ parts = [number_to_words(som), SOM]
37
+ if with_tiyin and tiyin:
38
+ parts += [number_to_words(tiyin), TIYIN]
39
+ return sign + " ".join(parts)
40
+
41
+
42
+ def format_money(amount: Amount) -> str:
43
+ """Summani o'qishga qulay ko'rinishda yozadi.
44
+
45
+ >>> format_money(1250000)
46
+ '1 250 000 soʻm'
47
+ """
48
+ value = _to_decimal(amount)
49
+ sign = "-" if value < 0 else ""
50
+ value = abs(value)
51
+ som = int(value)
52
+ tiyin = int((value - som) * 100)
53
+ text = f"{som:,}".replace(",", " ")
54
+ if tiyin:
55
+ text += f",{tiyin:02d}"
56
+ return f"{sign}{text} {SOM}"
@@ -0,0 +1,126 @@
1
+ """Sonlarni o'zbekcha so'zlar bilan yozish (0 dan 10**12 gacha)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List
6
+
7
+ from uzkit.translit import OKINA
8
+
9
+ __all__ = ["MAX_NUMBER", "number_to_words", "ordinal"]
10
+
11
+ #: Qo'llab-quvvatlanadigan eng katta son (modul bo'yicha).
12
+ MAX_NUMBER = 10**12
13
+
14
+ _ONES = (
15
+ "",
16
+ "bir",
17
+ "ikki",
18
+ "uch",
19
+ f"to{OKINA}rt",
20
+ "besh",
21
+ "olti",
22
+ "yetti",
23
+ "sakkiz",
24
+ f"to{OKINA}qqiz",
25
+ )
26
+
27
+ _TENS = (
28
+ "",
29
+ f"o{OKINA}n",
30
+ "yigirma",
31
+ f"o{OKINA}ttiz",
32
+ "qirq",
33
+ "ellik",
34
+ "oltmish",
35
+ "yetmish",
36
+ "sakson",
37
+ f"to{OKINA}qson",
38
+ )
39
+
40
+ _SCALES = (
41
+ (10**12, "trillion"),
42
+ (10**9, "milliard"),
43
+ (10**6, "million"),
44
+ (10**3, "ming"),
45
+ )
46
+
47
+ _VOWELS = frozenset("aeiou")
48
+
49
+
50
+ def _validate(n: object) -> None:
51
+ if isinstance(n, bool) or not isinstance(n, int):
52
+ raise TypeError(f"n must be int, not {type(n).__name__}")
53
+ if abs(n) > MAX_NUMBER:
54
+ raise ValueError(f"n must be within [-{MAX_NUMBER}, {MAX_NUMBER}], got {n}")
55
+
56
+
57
+ def _below_thousand(n: int) -> List[str]:
58
+ """1..999 oralig'idagi sonni so'zlar ro'yxati sifatida qaytaradi."""
59
+ words: List[str] = []
60
+ hundreds, rest = divmod(n, 100)
61
+ if hundreds:
62
+ words += [_ONES[hundreds], "yuz"]
63
+ tens, ones = divmod(rest, 10)
64
+ if tens:
65
+ words.append(_TENS[tens])
66
+ if ones:
67
+ words.append(_ONES[ones])
68
+ return words
69
+
70
+
71
+ def number_to_words(n: int) -> str:
72
+ """Butun sonni o'zbekcha so'zlar bilan yozadi.
73
+
74
+ >>> number_to_words(1250)
75
+ 'bir ming ikki yuz ellik'
76
+ >>> number_to_words(0)
77
+ 'nol'
78
+ >>> number_to_words(-5)
79
+ 'minus besh'
80
+
81
+ Oraliq: ``-10**12 <= n <= 10**12``. Manfiy sonlar ``minus`` bilan
82
+ boshlanadi. ``yuz`` va ``ming`` oldidan ``bir`` doim yoziladi
83
+ (``100`` -> ``bir yuz``).
84
+
85
+ ``oʻ`` va ``gʻ`` dagi belgi :data:`uzkit.translit.OKINA` (U+02BB).
86
+
87
+ ``int`` bo'lmagan (``bool`` ham) kiritish uchun ``TypeError``, oraliqdan
88
+ tashqari son uchun ``ValueError`` ko'taradi.
89
+ """
90
+ _validate(n)
91
+ if n == 0:
92
+ return "nol"
93
+
94
+ words: List[str] = ["minus"] if n < 0 else []
95
+ remainder = abs(n)
96
+ for value, name in _SCALES:
97
+ count, remainder = divmod(remainder, value)
98
+ if count:
99
+ words += _below_thousand(count) + [name]
100
+ words += _below_thousand(remainder)
101
+ return " ".join(words)
102
+
103
+
104
+ def ordinal(n: int) -> str:
105
+ """Tartib sonni yozadi: oxirgi so'zga ``-inchi`` yoki ``-nchi`` qo'shiladi.
106
+
107
+ >>> ordinal(3)
108
+ 'uchinchi'
109
+ >>> ordinal(2)
110
+ 'ikkinchi'
111
+ >>> ordinal(1250)
112
+ 'bir ming ikki yuz ellikinchi'
113
+
114
+ Oxirgi so'z unli bilan tugasa ``-nchi``, undosh bilan tugasa ``-inchi``
115
+ qo'shiladi. Oraliq: ``0 <= n <= 10**12``.
116
+
117
+ ``int`` bo'lmagan kiritish uchun ``TypeError``, manfiy yoki oraliqdan
118
+ tashqari son uchun ``ValueError`` ko'taradi.
119
+ """
120
+ _validate(n)
121
+ if n < 0:
122
+ raise ValueError(f"n must be non-negative, got {n}")
123
+
124
+ words = number_to_words(n)
125
+ suffix = "nchi" if words[-1] in _VOWELS else "inchi"
126
+ return words + suffix
File without changes
@@ -0,0 +1,42 @@
1
+ """O'zbekiston viloyatlari va tarkibiy birliklari."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from functools import lru_cache
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ __all__ = ["Region", "get_region", "list_regions"]
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Region:
16
+ name: str
17
+ name_cyrillic: str
18
+ capital: str
19
+ capital_cyrillic: str
20
+ plate_code: str
21
+
22
+
23
+ @lru_cache(maxsize=1)
24
+ def _regions() -> tuple[Region, ...]:
25
+ path = Path(__file__).resolve().parent / "data" / "regions.json"
26
+ with path.open(encoding="utf-8") as file:
27
+ raw = json.load(file)
28
+ return tuple(Region(**item) for item in raw)
29
+
30
+
31
+ def list_regions() -> list[Region]:
32
+ return list(_regions())
33
+
34
+
35
+ def get_region(name: str) -> Optional[Region]:
36
+ if not isinstance(name, str) or not name.strip():
37
+ return None
38
+ needle = name.casefold()
39
+ for region in _regions():
40
+ if region.name.casefold() == needle or region.name_cyrillic.casefold() == needle:
41
+ return region
42
+ return None
@@ -0,0 +1,105 @@
1
+ """O'zbek tili uchun Lotin <-> Kirill transliteratsiyasi."""
2
+
3
+ from __future__ import annotations
4
+
5
+ OKINA = "\u02bb" # ʻ o‘ va g‘ dagi belgi
6
+ TUTUQ = "\u02bc" # ʼ tutuq belgisi (ma’no)
7
+
8
+ _APOSTROPHES = frozenset("'\u2019\u02bb\u2018`\u00b4\u02bc")
9
+ _LAT_VOWELS = frozenset("aeiou")
10
+ _CYR_VOWELS = frozenset("аеёиоуўэюяы")
11
+
12
+ _LAT2CYR = {
13
+ "a": "а", "b": "б", "d": "д", "f": "ф", "g": "г", "h": "ҳ",
14
+ "i": "и", "j": "ж", "k": "к", "l": "л", "m": "м", "n": "н",
15
+ "o": "о", "p": "п", "q": "қ", "r": "р", "s": "с", "t": "т",
16
+ "u": "у", "v": "в", "x": "х", "y": "й", "z": "з",
17
+ }
18
+ _Y_COMBOS = {"o": "ё", "u": "ю", "a": "я", "e": "е"}
19
+
20
+ _CYR2LAT = {
21
+ "а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "ё": "yo",
22
+ "ж": "j", "з": "z", "и": "i", "й": "y", "к": "k", "л": "l",
23
+ "м": "m", "н": "n", "о": "o", "п": "p", "р": "r", "с": "s",
24
+ "т": "t", "у": "u", "ф": "f", "х": "x", "ц": "ts", "ч": "ch",
25
+ "ш": "sh", "щ": "shch", "ъ": TUTUQ, "ы": "i", "ь": "", "э": "e",
26
+ "ю": "yu", "я": "ya", "ў": "o" + OKINA, "қ": "q",
27
+ "ғ": "g" + OKINA, "ҳ": "h",
28
+ }
29
+
30
+
31
+ def to_cyrillic(text: str) -> str:
32
+ """Lotin yozuvidagi matnni Kirillga o'giradi.
33
+
34
+ >>> to_cyrillic("O'zbekiston")
35
+ 'Ўзбекистон'
36
+ """
37
+ out: list[str] = []
38
+ n = len(text)
39
+ i = 0
40
+ while i < n:
41
+ ch = text[i]
42
+ low = ch.lower()
43
+ nxt = text[i + 1] if i + 1 < n else ""
44
+ nlow = nxt.lower()
45
+ prev = text[i - 1] if i > 0 else ""
46
+ step = 1
47
+
48
+ if low in ("o", "g") and nxt in _APOSTROPHES:
49
+ res = "ў" if low == "o" else "ғ"
50
+ step = 2
51
+ elif low in ("s", "c") and nlow == "h":
52
+ res = "ш" if low == "s" else "ч"
53
+ step = 2
54
+ elif low == "y" and nlow in _Y_COMBOS:
55
+ res = _Y_COMBOS[nlow]
56
+ step = 2
57
+ elif low == "e":
58
+ at_start = not prev.isalpha() or prev.lower() in _LAT_VOWELS
59
+ res = "э" if at_start else "е"
60
+ elif ch in _APOSTROPHES and prev.isalpha() and nxt.isalpha():
61
+ res = "ъ"
62
+ elif low in _LAT2CYR:
63
+ res = _LAT2CYR[low]
64
+ else:
65
+ out.append(ch)
66
+ i += 1
67
+ continue
68
+
69
+ out.append(res.upper() if ch.isupper() else res)
70
+ i += step
71
+ return "".join(out)
72
+
73
+
74
+ def _has_upper_neighbor(text: str, i: int) -> bool:
75
+ before = i > 0 and text[i - 1].isupper()
76
+ after = i + 1 < len(text) and text[i + 1].isupper()
77
+ return before or after
78
+
79
+
80
+ def to_latin(text: str) -> str:
81
+ """Kirill yozuvidagi matnni Lotinga o'giradi (2023 alifbosi).
82
+
83
+ >>> to_latin("Ўзбекистон")
84
+ 'Oʻzbekiston'
85
+ """
86
+ out: list[str] = []
87
+ for i, ch in enumerate(text):
88
+ low = ch.lower()
89
+ if low == "е":
90
+ prev = text[i - 1].lower() if i > 0 else ""
91
+ ye = not prev.isalpha() or prev in _CYR_VOWELS or prev in "ъь"
92
+ res = "ye" if ye else "e"
93
+ elif low in _CYR2LAT:
94
+ res = _CYR2LAT[low]
95
+ else:
96
+ out.append(ch)
97
+ continue
98
+
99
+ if ch.isupper() and res:
100
+ if len(res) > 1 and not _has_upper_neighbor(text, i):
101
+ res = res.capitalize()
102
+ else:
103
+ res = res.upper()
104
+ out.append(res)
105
+ return "".join(out)
@@ -0,0 +1 @@
1
+ """Umumiy yordamchi funksiyalar (hozircha bo'sh)."""
@@ -0,0 +1,112 @@
1
+ """O'zbekiston telefon raqamlari va avtomobil davlat raqamlarini tekshirish."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any, Optional
7
+
8
+ __all__ = [
9
+ "MOBILE_OPERATOR_CODES",
10
+ "is_valid_phone",
11
+ "is_valid_plate",
12
+ "normalize_phone",
13
+ ]
14
+
15
+ COUNTRY_CODE = "998"
16
+
17
+ #: Mobil operator kodlari (+998 XX ...).
18
+ MOBILE_OPERATOR_CODES = frozenset(
19
+ {
20
+ "20", # Sardor Telecom / OQ
21
+ "33", # Humans
22
+ "50", # Ucell
23
+ "55", # Ucell; VoIP (Uztelecom), mobil emas
24
+ "77", # Uzmobile
25
+ "88", # Mobiuz / Humans
26
+ "90", # Beeline
27
+ "91", # Beeline
28
+ "93", # Ucell
29
+ "94", # Ucell
30
+ "95", # Uzmobile
31
+ "97", # Mobiuz
32
+ "98", # Mobiuz
33
+ "99", # Uzmobile
34
+ }
35
+ )
36
+
37
+ # Raqamlar orasida ruxsat etilgan ajratuvchilar: bo'shliq, tire, nuqta, qavs.
38
+ _PHONE_SEPARATORS = re.compile(r"[\s\-.()]+")
39
+ _PHONE_BODY = re.compile(r"\+?[0-9]+", re.ASCII)
40
+
41
+ # 01 A 123 AA (jismoniy shaxs) va 01 123 AAA (yuridik shaxs)
42
+ _PLATE_PERSONAL = re.compile(r"(\d{2}) ?([A-Z]) ?(\d{3}) ?([A-Z]{2})", re.ASCII)
43
+ _PLATE_LEGAL = re.compile(r"(\d{2}) ?(\d{3}) ?([A-Z]{3})", re.ASCII)
44
+
45
+
46
+ def normalize_phone(phone: Any) -> Optional[str]:
47
+ """Telefon raqamini ``+998XXXXXXXXX`` ko'rinishiga keltiradi.
48
+
49
+ Qabul qilinadigan kiritishlar (bo'shliq, tire, nuqta va qavslar e'tiborga
50
+ olinmaydi)::
51
+
52
+ +998 90 123-45-67
53
+ 998901234567
54
+ 90 123 45 67 (milliy 9 xonali format)
55
+
56
+ Raqam noto'g'ri bo'lsa (yoki ``str`` bo'lmasa) ``None`` qaytariladi.
57
+ Faqat mobil operator kodlari (:data:`MOBILE_OPERATOR_CODES`) qabul qilinadi.
58
+ """
59
+ if not isinstance(phone, str):
60
+ return None
61
+
62
+ cleaned = _PHONE_SEPARATORS.sub("", phone)
63
+ if not _PHONE_BODY.fullmatch(cleaned):
64
+ return None
65
+
66
+ digits = cleaned[1:] if cleaned.startswith("+") else cleaned
67
+
68
+ if len(digits) == 12 and digits.startswith(COUNTRY_CODE):
69
+ national = digits[len(COUNTRY_CODE):]
70
+ elif len(digits) == 9 and not cleaned.startswith("+"):
71
+ national = digits
72
+ else:
73
+ return None
74
+
75
+ if national[:2] not in MOBILE_OPERATOR_CODES:
76
+ return None
77
+
78
+ return f"+{COUNTRY_CODE}{national}"
79
+
80
+
81
+ def is_valid_phone(phone: Any) -> bool:
82
+ """Telefon raqami O'zbekiston mobil raqami ekanini tekshiradi.
83
+
84
+ >>> is_valid_phone("+998 90 123 45 67")
85
+ True
86
+ >>> is_valid_phone("+998 10 123 45 67")
87
+ False
88
+ """
89
+ return normalize_phone(phone) is not None
90
+
91
+
92
+ def is_valid_plate(plate: Any) -> bool:
93
+ """Avtomobil davlat raqamini tekshiradi.
94
+
95
+ Qo'llab-quvvatlanadigan formatlar::
96
+
97
+ 01 A 123 AA (jismoniy shaxslar)
98
+ 01 123 AAA (yuridik shaxslar)
99
+
100
+ Bo'shliqlar ixtiyoriy (``01A123AA``), harflar katta-kichikligi farqlanmaydi.
101
+ Viloyat kodi 00 dan farqli ikki xonali kod bo'lishi shart (01-99).
102
+ """
103
+ if not isinstance(plate, str):
104
+ return False
105
+
106
+ value = plate.strip().upper()
107
+ match = _PLATE_PERSONAL.fullmatch(value) or _PLATE_LEGAL.fullmatch(value)
108
+ if match is None:
109
+ return False
110
+
111
+ code = match.group(1)
112
+ return code != "00" and code.isdigit() and len(code) == 2
@@ -0,0 +1,45 @@
1
+ from decimal import Decimal
2
+
3
+ import pytest
4
+
5
+ from uzkit import format_money, money_to_words
6
+ from uzkit.translit import OKINA
7
+
8
+ SOM = f"so{OKINA}m"
9
+
10
+
11
+ @pytest.mark.parametrize("amount,expected", [
12
+ (0, f"nol {SOM}"),
13
+ (1250, f"bir ming ikki yuz ellik {SOM}"),
14
+ ("1250.50", f"bir ming ikki yuz ellik {SOM} ellik tiyin"),
15
+ (Decimal("0.05"), f"nol {SOM} besh tiyin"),
16
+ (-100, f"minus bir yuz {SOM}"),
17
+ (1_000_000, f"bir million {SOM}"),
18
+ ])
19
+ def test_money_to_words(amount, expected):
20
+ assert money_to_words(amount) == expected
21
+
22
+
23
+ def test_without_tiyin():
24
+ assert money_to_words(10.75, with_tiyin=False) == f"o{OKINA}n {SOM}"
25
+
26
+
27
+ def test_float_precision():
28
+ assert money_to_words(0.1 + 0.2) == f"nol {SOM} o{OKINA}ttiz tiyin"
29
+
30
+
31
+ @pytest.mark.parametrize("bad", [None, True, [1], {}])
32
+ def test_bad_type(bad):
33
+ with pytest.raises(TypeError):
34
+ money_to_words(bad)
35
+
36
+
37
+ @pytest.mark.parametrize("amount,expected", [
38
+ (1250000, f"1 250 000 {SOM}"),
39
+ (999, f"999 {SOM}"),
40
+ ("1500.5", f"1 500,50 {SOM}"),
41
+ (-2000, f"-2 000 {SOM}"),
42
+ ])
43
+ def test_format_money(amount, expected):
44
+ assert format_money(amount) == expected
45
+
@@ -0,0 +1,61 @@
1
+ import pytest
2
+
3
+ from uzkit import number_to_words, ordinal
4
+ from uzkit.translit import OKINA
5
+
6
+ O = OKINA
7
+
8
+ CARDINALS = [
9
+ (0, "nol"),
10
+ (1, "bir"),
11
+ (7, "yetti"),
12
+ (10, f"o{O}n"),
13
+ (14, f"o{O}n to{O}rt"),
14
+ (20, "yigirma"),
15
+ (40, "qirq"),
16
+ (80, "sakson"),
17
+ (90, f"to{O}qson"),
18
+ (100, "bir yuz"),
19
+ (101, "bir yuz bir"),
20
+ (1000, "bir ming"),
21
+ (1250, "bir ming ikki yuz ellik"),
22
+ (2024, f"ikki ming yigirma to{O}rt"),
23
+ (1_000_000, "bir million"),
24
+ (1_000_001, "bir million bir"),
25
+ (1_000_000_000, "bir milliard"),
26
+ (
27
+ 12_345_678,
28
+ f"o{O}n ikki million uch yuz qirq besh ming olti yuz yetmish sakkiz",
29
+ ),
30
+ ]
31
+
32
+
33
+ @pytest.mark.parametrize("n,words", CARDINALS)
34
+ def test_number_to_words(n, words):
35
+ assert number_to_words(n) == words
36
+
37
+
38
+ @pytest.mark.parametrize("n", [1, 5, 99, 1000, 123456])
39
+ def test_negative(n):
40
+ assert number_to_words(-n) == "minus " + number_to_words(n)
41
+
42
+
43
+ ORDINALS = [
44
+ (1, "birinchi"),
45
+ (2, "ikkinchi"),
46
+ (3, "uchinchi"),
47
+ (4, f"to{O}rtinchi"),
48
+ (5, "beshinchi"),
49
+ (6, "oltinchi"),
50
+ (7, "yettinchi"),
51
+ (8, "sakkizinchi"),
52
+ (9, f"to{O}qqizinchi"),
53
+ (10, f"o{O}ninchi"),
54
+ (20, "yigirmanchi"),
55
+ (40, "qirqinchi"),
56
+ ]
57
+
58
+
59
+ @pytest.mark.parametrize("n,words", ORDINALS)
60
+ def test_ordinal(n, words):
61
+ assert ordinal(n) == words
@@ -0,0 +1,91 @@
1
+ import pytest
2
+
3
+ from uzkit.regions import Region, get_region, list_regions
4
+
5
+
6
+ def test_list_regions_count():
7
+ assert len(list_regions()) == 14
8
+
9
+
10
+ def test_list_regions_returns_region_objects():
11
+ assert all(isinstance(region, Region) for region in list_regions())
12
+
13
+
14
+ def test_get_region_by_latin_name():
15
+ region = get_region("Toshkent viloyati")
16
+ assert region is not None
17
+ assert region.capital == "Nurafshon"
18
+
19
+
20
+ def test_get_region_by_cyrillic_name():
21
+ region = get_region("Тошкент вилояти")
22
+ assert region is not None
23
+ assert region.name == "Toshkent viloyati"
24
+
25
+
26
+ def test_get_region_case_insensitive():
27
+ assert get_region("toshkent viloyati") == get_region("TOSHKENT VILOYATI")
28
+
29
+
30
+ def test_get_region_unknown_returns_none():
31
+ assert get_region("Mars") is None
32
+
33
+
34
+ def test_get_region_empty_returns_none():
35
+ assert get_region("") is None
36
+ assert get_region(" ") is None
37
+
38
+
39
+ def test_get_region_non_string_returns_none():
40
+ assert get_region(None) is None
41
+ assert get_region(123) is None
42
+
43
+
44
+ def test_all_capitals_present():
45
+ for region in list_regions():
46
+ assert region.capital
47
+ assert region.capital_cyrillic
48
+
49
+
50
+ def test_plate_codes_match_validator_ranges():
51
+ codes = {region.plate_code for region in list_regions()}
52
+ assert codes == {"01", "10", "20", "25", "30", "40", "50", "60", "70", "75", "80", "85", "90", "95"}
53
+
54
+
55
+ def test_unique_region_names():
56
+ names = [region.name for region in list_regions()]
57
+ assert len(names) == len(set(names))
58
+
59
+
60
+ def test_unique_cyrillic_names():
61
+ names = [region.name_cyrillic for region in list_regions()]
62
+ assert len(names) == len(set(names))
63
+
64
+
65
+ def test_qoraqalpogiston():
66
+ region = get_region("Qoraqalpog'iston Respublikasi")
67
+ assert region is not None
68
+ assert region.capital == "Nukus"
69
+
70
+
71
+ def test_toshkent_shahri():
72
+ region = get_region("Toshkent shahri")
73
+ assert region is not None
74
+ assert region.plate_code == "01"
75
+
76
+
77
+ def test_get_region_by_capital_not_required():
78
+ assert get_region("Nukus") is None
79
+
80
+
81
+ def test_immutable_region():
82
+ region = get_region("Xorazm viloyati")
83
+ with pytest.raises(Exception):
84
+ region.name = "changed"
85
+
86
+
87
+ def test_region_code_mapping():
88
+ assert get_region("Qashqadaryo viloyati").plate_code == "70"
89
+ assert get_region("Surxondaryo viloyati").plate_code == "75"
90
+ assert get_region("Buxoro viloyati").plate_code == "80"
91
+ assert get_region("Navoiy viloyati").plate_code == "85"
@@ -0,0 +1,43 @@
1
+ import pytest
2
+
3
+ from uzkit.translit import OKINA, TUTUQ, to_cyrillic, to_latin
4
+
5
+ PAIRS = [
6
+ ("Toshkent", "Тошкент"),
7
+ (f"O{OKINA}zbekiston", "Ўзбекистон"),
8
+ (f"G{OKINA}ijduvon", "Ғиждувон"),
9
+ (f"Qo{OKINA}qon", "Қўқон"),
10
+ ("yaxshi", "яхши"),
11
+ ("shahar", "шаҳар"),
12
+ ("chiroyli", "чиройли"),
13
+ ("yoz", "ёз"),
14
+ ("Yevropa", "Европа"),
15
+ ("Elektr", "Электр"),
16
+ ("aeroport", "аэропорт"),
17
+ ("SHAHAR", "ШАҲАР"),
18
+ ("YOSH", "ЁШ"),
19
+ ("Shahar", "Шаҳар"),
20
+ (f"ma{TUTUQ}no", "маъно"),
21
+ ("", ""),
22
+ ("123, 45!", "123, 45!"),
23
+ ]
24
+
25
+
26
+ @pytest.mark.parametrize("latin,cyrillic", PAIRS)
27
+ def test_to_cyrillic(latin, cyrillic):
28
+ assert to_cyrillic(latin) == cyrillic
29
+
30
+
31
+ @pytest.mark.parametrize("latin,cyrillic", PAIRS)
32
+ def test_to_latin(latin, cyrillic):
33
+ assert to_latin(cyrillic) == latin
34
+
35
+
36
+ @pytest.mark.parametrize("apos", ["'", "’", "ʻ", "‘", "`"])
37
+ def test_apostrophe_variants(apos):
38
+ assert to_cyrillic(f"o{apos}zbek") == "ўзбек"
39
+ assert to_cyrillic(f"ma{apos}no") == "маъно"
40
+
41
+
42
+ def test_quotes_at_word_edges_untouched():
43
+ assert to_cyrillic("'salom'") == "'салом'"
@@ -0,0 +1,154 @@
1
+ import pytest
2
+
3
+ from uzkit.validators import (
4
+ MOBILE_OPERATOR_CODES,
5
+ is_valid_phone,
6
+ is_valid_plate,
7
+ normalize_phone,
8
+ )
9
+
10
+
11
+ # ---------------------------------------------------------------- telefon
12
+ @pytest.mark.parametrize(
13
+ "value",
14
+ [
15
+ "+998901234567",
16
+ "+998 90 123 45 67",
17
+ "+998 (90) 123-45-67",
18
+ "998901234567",
19
+ "901234567",
20
+ "90 123 45 67",
21
+ "+998.91.123.45.67",
22
+ " +998931234567 ",
23
+ ],
24
+ )
25
+ def test_valid_phone_formats(value):
26
+ assert is_valid_phone(value) is True
27
+
28
+
29
+ @pytest.mark.parametrize("code", sorted(MOBILE_OPERATOR_CODES))
30
+ def test_every_operator_code_is_accepted(code):
31
+ assert is_valid_phone(f"+998{code}1234567")
32
+
33
+
34
+ @pytest.mark.parametrize("code", ["90", "91", "93", "94", "95", "97", "99", "33", "88"])
35
+ def test_required_operator_codes_present(code):
36
+ assert code in MOBILE_OPERATOR_CODES
37
+
38
+
39
+ @pytest.mark.parametrize(
40
+ "value",
41
+ [
42
+ "",
43
+ " ",
44
+ "+99890123456", # juda qisqa
45
+ "+9989012345678", # juda uzun
46
+ "+998 10 123 45 67", # noma'lum operator kodi
47
+ "+998 00 123 45 67",
48
+ "+7 901 234 56 78", # boshqa davlat
49
+ "+901234567", # 9 xonali, lekin '+' bilan
50
+ "90123456", # 8 xonali
51
+ "+998 90 123 45 6a",
52
+ "++998901234567",
53
+ "+99890+1234567",
54
+ "abcdefghijkl",
55
+ "+٩٩٨٩٠١٢٣٤٥٦٧", # arab-hind raqamlari
56
+ ],
57
+ )
58
+ def test_invalid_phone(value):
59
+ assert is_valid_phone(value) is False
60
+
61
+
62
+ @pytest.mark.parametrize("value", [None, 998901234567, 90.1, [], b"+998901234567"])
63
+ def test_non_string_phone(value):
64
+ assert is_valid_phone(value) is False
65
+ assert normalize_phone(value) is None
66
+
67
+
68
+ @pytest.mark.parametrize(
69
+ ("raw", "expected"),
70
+ [
71
+ ("+998 90 123-45-67", "+998901234567"),
72
+ ("998901234567", "+998901234567"),
73
+ ("90 123 45 67", "+998901234567"),
74
+ ("(93) 765-43-21", "+998937654321"),
75
+ ("+998-33-111-22-33", "+998331112233"),
76
+ ],
77
+ )
78
+ def test_normalize_phone(raw, expected):
79
+ assert normalize_phone(raw) == expected
80
+
81
+
82
+ def test_normalize_phone_invalid_returns_none():
83
+ assert normalize_phone("+998 10 123 45 67") is None
84
+ assert normalize_phone("hello") is None
85
+
86
+
87
+ def test_normalize_is_idempotent():
88
+ once = normalize_phone("90 123 45 67")
89
+ assert normalize_phone(once) == once
90
+
91
+
92
+ # ----------------------------------------------------------- davlat raqami
93
+ @pytest.mark.parametrize(
94
+ "value",
95
+ [
96
+ "01 A 123 AA",
97
+ "01 123 AAA",
98
+ "01A123AA",
99
+ "01123AAA",
100
+ "01 a 123 aa",
101
+ " 30 B 456 CD ",
102
+ "95 Z 999 ZZ",
103
+ "80 777 XYZ",
104
+ "10 A 001 BC",
105
+ ],
106
+ )
107
+ def test_valid_plate(value):
108
+ assert is_valid_plate(value) is True
109
+
110
+
111
+ @pytest.mark.parametrize("code", [f"{i:02d}" for i in range(1, 100)])
112
+ def test_every_region_code_is_accepted(code):
113
+ assert is_valid_plate(f"{code} A 123 AA")
114
+ assert is_valid_plate(f"{code} 123 AAA")
115
+
116
+ @pytest.mark.parametrize("code", ["02", "31", "99"])
117
+ def test_selected_plate_codes_accepted(code):
118
+ assert is_valid_plate(f"{code} A 123 AA")
119
+
120
+ def test_plate_code_00_rejected():
121
+ assert is_valid_plate("00 A 123 AA") is False
122
+
123
+
124
+ @pytest.mark.parametrize(
125
+ "value",
126
+ [
127
+ "",
128
+ " ",
129
+ "00 A 123 AA", # noma'lum viloyat kodi
130
+ "1 A 123 AA", # viloyat kodi bitta raqam
131
+ "001 A 123 AA",
132
+ "01 AA 123 AA",
133
+ "01 A 12 AA",
134
+ "01 A 1234 AA",
135
+ "01 A 123 A",
136
+ "01 A 123 AAA", # ikki format aralashgan
137
+ "01 12 AAA",
138
+ "01 1234 AAA",
139
+ "01 123 AA",
140
+ "01 123 AAAA",
141
+ "01 1 123 AA",
142
+ "A 01 123 AA",
143
+ "01-A-123-AA",
144
+ "01 A 123 AA", # ikkita bo'shliq
145
+ "01 А 123 АА", # kirill harflari
146
+ ],
147
+ )
148
+ def test_invalid_plate(value):
149
+ assert is_valid_plate(value) is False
150
+
151
+
152
+ @pytest.mark.parametrize("value", [None, 123, [], b"01 A 123 AA"])
153
+ def test_non_string_plate(value):
154
+ assert is_valid_plate(value) is False