pakdatakit 2.2.0__py3-none-any.whl
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.
- pakdatakit/__init__.py +159 -0
- pakdatakit/_data.py +19 -0
- pakdatakit/_internal/__init__.py +1 -0
- pakdatakit/_internal/emv.py +74 -0
- pakdatakit/_internal/js.py +200 -0
- pakdatakit/_internal/normalize.py +36 -0
- pakdatakit/_internal/phone_utils.py +54 -0
- pakdatakit/address.py +240 -0
- pakdatakit/cnic.py +64 -0
- pakdatakit/data/area-codes.json +294 -0
- pakdatakit/data/banks.json +248 -0
- pakdatakit/data/cities.json +906 -0
- pakdatakit/data/districts.json +710 -0
- pakdatakit/data/holidays.json +154 -0
- pakdatakit/data/networks.json +55 -0
- pakdatakit/data/postal-codes.json +641 -0
- pakdatakit/data_version.py +8 -0
- pakdatakit/format.py +64 -0
- pakdatakit/geo.py +98 -0
- pakdatakit/hijri.py +169 -0
- pakdatakit/holidays.py +171 -0
- pakdatakit/iban.py +116 -0
- pakdatakit/input.py +141 -0
- pakdatakit/networks.py +31 -0
- pakdatakit/numbers.py +175 -0
- pakdatakit/phone.py +48 -0
- pakdatakit/postal.py +95 -0
- pakdatakit/py.typed +0 -0
- pakdatakit/raast.py +498 -0
- pakdatakit/tax.py +61 -0
- pakdatakit/text.py +66 -0
- pakdatakit/whatsapp.py +24 -0
- pakdatakit/words.py +130 -0
- pakdatakit-2.2.0.dist-info/METADATA +117 -0
- pakdatakit-2.2.0.dist-info/RECORD +37 -0
- pakdatakit-2.2.0.dist-info/WHEEL +4 -0
- pakdatakit-2.2.0.dist-info/licenses/LICENSE +21 -0
pakdatakit/__init__.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""PakDataKit: utilities for Pakistani data.
|
|
2
|
+
|
|
3
|
+
CNIC, phone, networks, WhatsApp, IBAN, postal codes, geography, address
|
|
4
|
+
parsing, Roman Urdu, Hijri dates, holidays, currency formatting, amounts in
|
|
5
|
+
words, tax numbers, landlines and Raast QR codes.
|
|
6
|
+
|
|
7
|
+
A Python port of the ``pakdatakit`` npm package: it ships the same datasets and
|
|
8
|
+
is tested against shared test vectors, so both give identical results.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .address import ParsedAddress, format_address, parse_address
|
|
12
|
+
from .cnic import CNICInfo, get_cnic_info, validate_cnic
|
|
13
|
+
from .data_version import DATA_VERSION, data_version
|
|
14
|
+
from .format import format_lakh, format_pkr
|
|
15
|
+
from .geo import (
|
|
16
|
+
get_district_province,
|
|
17
|
+
get_districts_by_province,
|
|
18
|
+
get_province,
|
|
19
|
+
search_cities,
|
|
20
|
+
search_districts,
|
|
21
|
+
)
|
|
22
|
+
from .hijri import (
|
|
23
|
+
HijriDate,
|
|
24
|
+
format_hijri,
|
|
25
|
+
from_hijri,
|
|
26
|
+
get_hijri_month_name,
|
|
27
|
+
is_ramadan,
|
|
28
|
+
to_hijri,
|
|
29
|
+
)
|
|
30
|
+
from .holidays import Holiday, get_holidays_on, get_pakistan_holidays, is_pakistan_holiday
|
|
31
|
+
from .iban import BankInfo, get_bank_from_iban, get_bank_info, get_banks, validate_iban
|
|
32
|
+
from .input import (
|
|
33
|
+
check_cnic_input,
|
|
34
|
+
check_iban_input,
|
|
35
|
+
check_phone_input,
|
|
36
|
+
format_cnic_input,
|
|
37
|
+
format_iban_input,
|
|
38
|
+
format_phone_input,
|
|
39
|
+
)
|
|
40
|
+
from .networks import get_network
|
|
41
|
+
from .numbers import (
|
|
42
|
+
NumberInfo,
|
|
43
|
+
analyze_number,
|
|
44
|
+
get_area_code,
|
|
45
|
+
get_number_type,
|
|
46
|
+
validate_landline,
|
|
47
|
+
)
|
|
48
|
+
from .phone import PhoneAnalysis, analyze_phone, format_phone, validate_phone
|
|
49
|
+
from .postal import (
|
|
50
|
+
get_city_by_postal_code,
|
|
51
|
+
get_postal_areas,
|
|
52
|
+
get_postal_code,
|
|
53
|
+
get_postal_codes,
|
|
54
|
+
search_postal_codes,
|
|
55
|
+
)
|
|
56
|
+
from .raast import (
|
|
57
|
+
EmvField,
|
|
58
|
+
MerchantAccountInfo,
|
|
59
|
+
ParsedRaastQr,
|
|
60
|
+
RaastQrValidation,
|
|
61
|
+
create_merchant_qr,
|
|
62
|
+
create_raast_p2p_qr,
|
|
63
|
+
parse_raast_qr,
|
|
64
|
+
validate_raast_qr,
|
|
65
|
+
)
|
|
66
|
+
from .tax import NTNInfo, analyze_ntn, validate_ntn, validate_strn
|
|
67
|
+
from .text import normalize_roman_urdu, normalize_text, to_english_digits, to_urdu_digits
|
|
68
|
+
from .whatsapp import create_whatsapp_link
|
|
69
|
+
from .words import amount_in_words, number_to_words
|
|
70
|
+
|
|
71
|
+
__version__ = "2.2.0"
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"__version__",
|
|
75
|
+
"DATA_VERSION",
|
|
76
|
+
"data_version",
|
|
77
|
+
# cnic
|
|
78
|
+
"CNICInfo",
|
|
79
|
+
"validate_cnic",
|
|
80
|
+
"get_cnic_info",
|
|
81
|
+
# phone
|
|
82
|
+
"PhoneAnalysis",
|
|
83
|
+
"validate_phone",
|
|
84
|
+
"format_phone",
|
|
85
|
+
"analyze_phone",
|
|
86
|
+
"get_network",
|
|
87
|
+
"create_whatsapp_link",
|
|
88
|
+
# iban
|
|
89
|
+
"validate_iban",
|
|
90
|
+
"get_bank_from_iban",
|
|
91
|
+
"BankInfo",
|
|
92
|
+
"get_bank_info",
|
|
93
|
+
"get_banks",
|
|
94
|
+
# postal
|
|
95
|
+
"get_city_by_postal_code",
|
|
96
|
+
"get_postal_code",
|
|
97
|
+
"get_postal_codes",
|
|
98
|
+
"get_postal_areas",
|
|
99
|
+
"search_postal_codes",
|
|
100
|
+
# geo
|
|
101
|
+
"get_province",
|
|
102
|
+
"search_cities",
|
|
103
|
+
"get_district_province",
|
|
104
|
+
"search_districts",
|
|
105
|
+
"get_districts_by_province",
|
|
106
|
+
# address
|
|
107
|
+
"ParsedAddress",
|
|
108
|
+
"parse_address",
|
|
109
|
+
"format_address",
|
|
110
|
+
# text
|
|
111
|
+
"normalize_text",
|
|
112
|
+
"normalize_roman_urdu",
|
|
113
|
+
"to_urdu_digits",
|
|
114
|
+
"to_english_digits",
|
|
115
|
+
# hijri
|
|
116
|
+
"HijriDate",
|
|
117
|
+
"to_hijri",
|
|
118
|
+
"from_hijri",
|
|
119
|
+
"format_hijri",
|
|
120
|
+
"get_hijri_month_name",
|
|
121
|
+
"is_ramadan",
|
|
122
|
+
# format / words
|
|
123
|
+
"format_pkr",
|
|
124
|
+
"format_lakh",
|
|
125
|
+
"number_to_words",
|
|
126
|
+
"amount_in_words",
|
|
127
|
+
# tax
|
|
128
|
+
"NTNInfo",
|
|
129
|
+
"analyze_ntn",
|
|
130
|
+
"validate_ntn",
|
|
131
|
+
"validate_strn",
|
|
132
|
+
# numbers
|
|
133
|
+
"NumberInfo",
|
|
134
|
+
"analyze_number",
|
|
135
|
+
"get_number_type",
|
|
136
|
+
"validate_landline",
|
|
137
|
+
"get_area_code",
|
|
138
|
+
# raast
|
|
139
|
+
"EmvField",
|
|
140
|
+
"MerchantAccountInfo",
|
|
141
|
+
"ParsedRaastQr",
|
|
142
|
+
"RaastQrValidation",
|
|
143
|
+
"create_raast_p2p_qr",
|
|
144
|
+
"create_merchant_qr",
|
|
145
|
+
"parse_raast_qr",
|
|
146
|
+
"validate_raast_qr",
|
|
147
|
+
# holidays
|
|
148
|
+
"Holiday",
|
|
149
|
+
"get_pakistan_holidays",
|
|
150
|
+
"get_holidays_on",
|
|
151
|
+
"is_pakistan_holiday",
|
|
152
|
+
# input
|
|
153
|
+
"format_cnic_input",
|
|
154
|
+
"check_cnic_input",
|
|
155
|
+
"format_phone_input",
|
|
156
|
+
"check_phone_input",
|
|
157
|
+
"format_iban_input",
|
|
158
|
+
"check_iban_input",
|
|
159
|
+
]
|
pakdatakit/_data.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Loads the bundled JSON datasets (shared with the npm package)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
try: # Python 3.9+
|
|
10
|
+
from importlib.resources import files as _files
|
|
11
|
+
except ImportError: # pragma: no cover
|
|
12
|
+
_files = None # type: ignore[assignment]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@lru_cache(maxsize=None)
|
|
16
|
+
def load(name: str) -> Any:
|
|
17
|
+
"""Returns the parsed contents of ``pakdatakit/data/<name>``."""
|
|
18
|
+
text = _files("pakdatakit").joinpath("data").joinpath(name).read_text(encoding="utf-8")
|
|
19
|
+
return json.loads(text)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Internal helpers. Not part of the public API."""
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""EMVCo Merchant-Presented QR encoding (mirrors src/internal/emv.ts)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from .js import utf8_bytes
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class EmvField:
|
|
13
|
+
"""A parsed EMV field. ``children`` is set for template fields."""
|
|
14
|
+
|
|
15
|
+
id: str
|
|
16
|
+
value: str
|
|
17
|
+
children: Optional[List["EmvField"]] = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EmvSyntaxError(ValueError):
|
|
21
|
+
"""Malformed EMV data."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_template_id(field_id: str) -> bool:
|
|
25
|
+
n = int(field_id)
|
|
26
|
+
return 26 <= n <= 51 or n == 62 or n == 64 or 80 <= n <= 99
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def crc16(text: str) -> str:
|
|
30
|
+
"""CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF), as 4 uppercase hex digits."""
|
|
31
|
+
crc = 0xFFFF
|
|
32
|
+
for byte in utf8_bytes(text):
|
|
33
|
+
crc ^= byte << 8
|
|
34
|
+
for _ in range(8):
|
|
35
|
+
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
|
|
36
|
+
return format(crc, "04X")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def encode_field(field_id: str, value: str) -> str:
|
|
40
|
+
"""Encodes one field. Length counts characters, as the EMV spec requires."""
|
|
41
|
+
length = len(value)
|
|
42
|
+
if length > 99:
|
|
43
|
+
raise ValueError(f"EMV field {field_id} is longer than 99 characters")
|
|
44
|
+
return f"{field_id}{length:02d}{value}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_digits(text: str) -> bool:
|
|
48
|
+
return all("0" <= c <= "9" for c in text)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_fields(data: str, nested: bool = True) -> List[EmvField]:
|
|
52
|
+
"""Parses EMV fields. Raises ``EmvSyntaxError`` if the data is malformed."""
|
|
53
|
+
chars = data
|
|
54
|
+
fields: List[EmvField] = []
|
|
55
|
+
i = 0
|
|
56
|
+
while i < len(chars):
|
|
57
|
+
header = chars[i : i + 4]
|
|
58
|
+
if len(header) != 4 or not _is_digits(header):
|
|
59
|
+
raise EmvSyntaxError(f"Malformed EMV field at position {i}")
|
|
60
|
+
field_id = header[:2]
|
|
61
|
+
length = int(header[2:])
|
|
62
|
+
if i + 4 + length > len(chars):
|
|
63
|
+
raise EmvSyntaxError(f"EMV field {field_id} is truncated")
|
|
64
|
+
value = chars[i + 4 : i + 4 + length]
|
|
65
|
+
children = parse_fields(value, False) if nested and is_template_id(field_id) else None
|
|
66
|
+
fields.append(EmvField(field_id, value, children))
|
|
67
|
+
i += 4 + length
|
|
68
|
+
return fields
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def with_crc(payload: str) -> str:
|
|
72
|
+
"""Appends the CRC field (63) to an encoded payload."""
|
|
73
|
+
data = f"{payload}6304"
|
|
74
|
+
return f"{data}{crc16(data)}"
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Helpers that reproduce JavaScript semantics, so results match the npm package.
|
|
2
|
+
|
|
3
|
+
Python and JavaScript differ in small ways that matter for exact parity:
|
|
4
|
+
whitespace classes, ``Math.round``, number-to-string conversion,
|
|
5
|
+
``toFixed`` rounding, ``Number(string)`` parsing and ``localeCompare``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
import unicodedata
|
|
13
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
14
|
+
from typing import Any, List, Tuple
|
|
15
|
+
|
|
16
|
+
#: Characters matched by JavaScript's ``\\s`` and removed by ``String.prototype.trim``.
|
|
17
|
+
WS_CHARS = (
|
|
18
|
+
"\t\n\x0b\x0c\r "
|
|
19
|
+
" "
|
|
20
|
+
"
"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
#: Regex character-class body equivalent to JavaScript's ``\\s``.
|
|
24
|
+
WS = "\\t\\n\\x0b\\x0c\\r \\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff"
|
|
25
|
+
|
|
26
|
+
MAX_SAFE_INTEGER = 2**53 - 1
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def trim(value: str) -> str:
|
|
30
|
+
"""``String.prototype.trim``."""
|
|
31
|
+
return value.strip(WS_CHARS)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def trim_end(value: str) -> str:
|
|
35
|
+
"""``String.prototype.trimEnd``."""
|
|
36
|
+
return value.rstrip(WS_CHARS)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_number(value: Any) -> bool:
|
|
40
|
+
"""``typeof value === 'number'`` (bools are not numbers in JavaScript)."""
|
|
41
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_finite(value: Any) -> bool:
|
|
45
|
+
"""``Number.isFinite``."""
|
|
46
|
+
if not is_number(value):
|
|
47
|
+
return False
|
|
48
|
+
try:
|
|
49
|
+
return math.isfinite(float(value))
|
|
50
|
+
except OverflowError:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_integer(value: Any) -> bool:
|
|
55
|
+
"""``Number.isInteger``."""
|
|
56
|
+
if isinstance(value, bool):
|
|
57
|
+
return False
|
|
58
|
+
if isinstance(value, int):
|
|
59
|
+
return True
|
|
60
|
+
return isinstance(value, float) and math.isfinite(value) and value.is_integer()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_safe_integer(value: Any) -> bool:
|
|
64
|
+
"""``Number.isSafeInteger``."""
|
|
65
|
+
return is_integer(value) and abs(value) <= MAX_SAFE_INTEGER
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def js_round(value: float) -> int:
|
|
69
|
+
"""``Math.round``: halves round towards positive infinity."""
|
|
70
|
+
floor = math.floor(value)
|
|
71
|
+
return floor + 1 if value - floor >= 0.5 else floor
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def number_to_string(value: float) -> str:
|
|
75
|
+
"""``String(number)`` / template-literal conversion of a number."""
|
|
76
|
+
if isinstance(value, bool):
|
|
77
|
+
return "true" if value else "false"
|
|
78
|
+
if isinstance(value, int) and abs(value) < 10**21:
|
|
79
|
+
return str(value)
|
|
80
|
+
value = float(value)
|
|
81
|
+
if math.isnan(value):
|
|
82
|
+
return "NaN"
|
|
83
|
+
if math.isinf(value):
|
|
84
|
+
return "Infinity" if value > 0 else "-Infinity"
|
|
85
|
+
if value == 0:
|
|
86
|
+
return "0"
|
|
87
|
+
sign = "-" if value < 0 else ""
|
|
88
|
+
_, digits_tuple, exponent = Decimal(repr(abs(value))).normalize().as_tuple()
|
|
89
|
+
digits = "".join(str(d) for d in digits_tuple)
|
|
90
|
+
k = len(digits)
|
|
91
|
+
n = int(exponent) + k
|
|
92
|
+
if k <= n <= 21:
|
|
93
|
+
text = digits + "0" * (n - k)
|
|
94
|
+
elif 0 < n <= 21:
|
|
95
|
+
text = digits[:n] + "." + digits[n:]
|
|
96
|
+
elif -6 < n <= 0:
|
|
97
|
+
text = "0." + "0" * (-n) + digits
|
|
98
|
+
else:
|
|
99
|
+
e = n - 1
|
|
100
|
+
exp = ("+" if e >= 0 else "-") + str(abs(e))
|
|
101
|
+
text = digits + "e" + exp if k == 1 else digits[0] + "." + digits[1:] + "e" + exp
|
|
102
|
+
return sign + text
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def to_fixed(value: float, places: int) -> str:
|
|
106
|
+
"""``Number.prototype.toFixed`` (rounds the exact binary value half up)."""
|
|
107
|
+
value = float(value)
|
|
108
|
+
if not math.isfinite(value) or abs(value) >= 1e21:
|
|
109
|
+
return number_to_string(value)
|
|
110
|
+
if value == 0:
|
|
111
|
+
return "0." + "0" * places if places else "0"
|
|
112
|
+
quantum = Decimal(1).scaleb(-places)
|
|
113
|
+
return format(Decimal(value).quantize(quantum, rounding=ROUND_HALF_UP), "f")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_DECIMAL_LITERAL = re.compile(
|
|
117
|
+
r"[+-]?(?:Infinity|(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)\Z"
|
|
118
|
+
)
|
|
119
|
+
_RADIX_LITERAL = re.compile(r"0(?:[xX][0-9a-fA-F]+|[oO][0-7]+|[bB][01]+)\Z")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def to_number(text: str) -> float:
|
|
123
|
+
"""``Number(string)``: returns ``nan`` where JavaScript returns ``NaN``."""
|
|
124
|
+
text = trim(text)
|
|
125
|
+
if text == "":
|
|
126
|
+
return 0.0
|
|
127
|
+
if _DECIMAL_LITERAL.match(text):
|
|
128
|
+
if text.lstrip("+-") == "Infinity":
|
|
129
|
+
return float("-inf") if text.startswith("-") else float("inf")
|
|
130
|
+
return float(text)
|
|
131
|
+
if _RADIX_LITERAL.match(text):
|
|
132
|
+
return float(int(text, 0))
|
|
133
|
+
return float("nan")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Root-collation order of ASCII punctuation and symbols (CLDR / ICU), all of
|
|
137
|
+
# which sort before digits and letters.
|
|
138
|
+
_PUNCTUATION = "_-,;:!?.'\"()[]{}@*/\\&#%`^+<=>|~$"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def collation_key(text: str) -> Tuple[Any, ...]:
|
|
142
|
+
"""Sort key approximating ``a.localeCompare(b)`` (ICU root collation).
|
|
143
|
+
|
|
144
|
+
Compares base letters case- and accent-insensitively first, then accents,
|
|
145
|
+
then case (lowercase first), like ICU's default strength.
|
|
146
|
+
"""
|
|
147
|
+
primary: List[Tuple[int, Any]] = []
|
|
148
|
+
secondary: List[int] = []
|
|
149
|
+
tertiary: List[int] = []
|
|
150
|
+
for char in unicodedata.normalize("NFD", text):
|
|
151
|
+
if unicodedata.combining(char):
|
|
152
|
+
secondary.append(ord(char))
|
|
153
|
+
continue
|
|
154
|
+
if char in WS_CHARS:
|
|
155
|
+
weight: Tuple[int, Any] = (0, ord(char))
|
|
156
|
+
elif char in _PUNCTUATION:
|
|
157
|
+
weight = (1, _PUNCTUATION.index(char))
|
|
158
|
+
elif char.isdigit():
|
|
159
|
+
weight = (2, unicodedata.digit(char, 0))
|
|
160
|
+
elif char.isalpha():
|
|
161
|
+
weight = (3, char.lower())
|
|
162
|
+
else:
|
|
163
|
+
weight = (4, ord(char))
|
|
164
|
+
primary.append(weight)
|
|
165
|
+
secondary.append(0)
|
|
166
|
+
tertiary.append(1 if char.isupper() else 0)
|
|
167
|
+
return (tuple(primary), tuple(secondary), tuple(tertiary), text)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def locale_sorted(values: List[str]) -> List[str]:
|
|
171
|
+
"""``values.sort((a, b) => a.localeCompare(b))``."""
|
|
172
|
+
return sorted(values, key=collation_key)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def object_entries(mapping: Any) -> List[Tuple[str, Any]]:
|
|
176
|
+
"""``Object.entries`` order: integer-like keys ascending, then insertion order."""
|
|
177
|
+
keys = list(mapping.keys())
|
|
178
|
+
|
|
179
|
+
def is_index(key: str) -> bool:
|
|
180
|
+
return (
|
|
181
|
+
isinstance(key, str)
|
|
182
|
+
and key.isascii()
|
|
183
|
+
and key.isdigit()
|
|
184
|
+
and (key == "0" or not key.startswith("0"))
|
|
185
|
+
and int(key) < 2**32 - 1
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
indices = sorted((k for k in keys if is_index(k)), key=int)
|
|
189
|
+
others = [k for k in keys if not is_index(k)]
|
|
190
|
+
return [(k, mapping[k]) for k in indices + others]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def utf16_length(text: str) -> int:
|
|
194
|
+
"""``string.length`` (UTF-16 code units)."""
|
|
195
|
+
return sum(2 if ord(c) > 0xFFFF else 1 for c in text)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def utf8_bytes(text: str) -> bytes:
|
|
199
|
+
"""``new TextEncoder().encode(text)``: lone surrogates become U+FFFD."""
|
|
200
|
+
return re.sub("[\ud800-\udfff]", "�", text).encode("utf-8")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Input normalization shared by the modules (mirrors src/internal/normalize.ts)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .js import WS, trim
|
|
8
|
+
|
|
9
|
+
_URDU_DIGITS = "۰۱۲۳۴۵۶۷۸۹"
|
|
10
|
+
_ARABIC_INDIC_DIGITS = "٠١٢٣٤٥٦٧٨٩"
|
|
11
|
+
_DIGIT_TABLE = {ord(c): str(i) for i, c in enumerate(_URDU_DIGITS)}
|
|
12
|
+
_DIGIT_TABLE.update({ord(c): str(i) for i, c in enumerate(_ARABIC_INDIC_DIGITS)})
|
|
13
|
+
|
|
14
|
+
# Whitespace, hyphens and dashes, dots, slashes and parentheses.
|
|
15
|
+
_SEPARATORS = re.compile("[" + WS + r"\-‐‑‒–—./()]")
|
|
16
|
+
_WHITESPACE_RUN = re.compile("[" + WS + "]+")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def to_ascii_digits(value: str) -> str:
|
|
20
|
+
"""Converts Urdu (Extended Arabic-Indic) and Arabic-Indic digits to ASCII digits."""
|
|
21
|
+
return value.translate(_DIGIT_TABLE)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def strip_separators(value: str) -> str:
|
|
25
|
+
"""Removes whitespace, hyphens and dashes, dots, slashes and parentheses."""
|
|
26
|
+
return _SEPARATORS.sub("", value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize_whitespace(value: str) -> str:
|
|
30
|
+
"""Collapses internal whitespace and trims leading/trailing whitespace."""
|
|
31
|
+
return _WHITESPACE_RUN.sub(" ", trim(value))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def normalize_key(value: str) -> str:
|
|
35
|
+
"""Lowercase with collapsed whitespace."""
|
|
36
|
+
return normalize_whitespace(value).lower()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Mobile number normalization (mirrors src/internal/phone-utils.ts)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .._data import load
|
|
9
|
+
from .normalize import strip_separators, to_ascii_digits
|
|
10
|
+
|
|
11
|
+
_MOBILE = re.compile(r"3[0-9]{9}\Z")
|
|
12
|
+
_allocated: Optional[frozenset] = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _allocated_prefixes() -> frozenset:
|
|
16
|
+
global _allocated
|
|
17
|
+
if _allocated is None:
|
|
18
|
+
_allocated = frozenset(entry["prefix"] for entry in load("networks.json"))
|
|
19
|
+
return _allocated
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_allocated_prefix(prefix: str) -> bool:
|
|
23
|
+
"""Whether a ``03XX`` mobile prefix is allocated to an operator."""
|
|
24
|
+
return prefix in _allocated_prefixes()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_phone_digits(phone: str) -> Optional[str]:
|
|
28
|
+
"""Normalizes a phone string to digits with country code (no leading plus).
|
|
29
|
+
|
|
30
|
+
Returns ``None`` when the input is not a Pakistani mobile number on an
|
|
31
|
+
allocated ``03XX`` prefix.
|
|
32
|
+
"""
|
|
33
|
+
digits = strip_separators(to_ascii_digits(phone))
|
|
34
|
+
if digits.startswith("+"):
|
|
35
|
+
digits = digits[1:]
|
|
36
|
+
elif digits.startswith("00"):
|
|
37
|
+
digits = digits[2:]
|
|
38
|
+
if digits.startswith("92") and len(digits) > 10:
|
|
39
|
+
digits = digits[2:]
|
|
40
|
+
if digits.startswith("0"):
|
|
41
|
+
digits = digits[1:]
|
|
42
|
+
if not _MOBILE.match(digits):
|
|
43
|
+
return None
|
|
44
|
+
if f"0{digits[:3]}" not in _allocated_prefixes():
|
|
45
|
+
return None
|
|
46
|
+
return f"92{digits}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_phone_prefix(phone: str) -> Optional[str]:
|
|
50
|
+
"""Returns the local mobile prefix (``0XXX``), or ``None`` if invalid."""
|
|
51
|
+
digits = normalize_phone_digits(phone)
|
|
52
|
+
if digits is None:
|
|
53
|
+
return None
|
|
54
|
+
return f"0{digits[2:5]}"
|