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 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]}"