lokal-id 0.2.2__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 (50) hide show
  1. lokal_id-0.2.2/.gitignore +13 -0
  2. lokal_id-0.2.2/LICENSE +21 -0
  3. lokal_id-0.2.2/PKG-INFO +189 -0
  4. lokal_id-0.2.2/README.md +157 -0
  5. lokal_id-0.2.2/pyproject.toml +78 -0
  6. lokal_id-0.2.2/scripts/gen_vectors.mjs +296 -0
  7. lokal_id-0.2.2/scripts/sync_data.py +54 -0
  8. lokal_id-0.2.2/src/lokal/__init__.py +86 -0
  9. lokal_id-0.2.2/src/lokal/_js.py +143 -0
  10. lokal_id-0.2.2/src/lokal/_result.py +37 -0
  11. lokal_id-0.2.2/src/lokal/data/LICENSE-kodepos.txt +21 -0
  12. lokal_id-0.2.2/src/lokal/data/LICENSE-wilayah.txt +21 -0
  13. lokal_id-0.2.2/src/lokal/data/desa.json.gz +0 -0
  14. lokal_id-0.2.2/src/lokal/data/kodepos.json.gz +0 -0
  15. lokal_id-0.2.2/src/lokal/data/wilayah.json.gz +0 -0
  16. lokal_id-0.2.2/src/lokal/holidays.py +203 -0
  17. lokal_id-0.2.2/src/lokal/nik.py +129 -0
  18. lokal_id-0.2.2/src/lokal/npwp.py +69 -0
  19. lokal_id-0.2.2/src/lokal/phone.py +120 -0
  20. lokal_id-0.2.2/src/lokal/provinces.py +46 -0
  21. lokal_id-0.2.2/src/lokal/py.typed +0 -0
  22. lokal_id-0.2.2/src/lokal/rupiah.py +249 -0
  23. lokal_id-0.2.2/src/lokal/samaran/__init__.py +21 -0
  24. lokal_id-0.2.2/src/lokal/samaran/core.py +209 -0
  25. lokal_id-0.2.2/src/lokal/samaran/detectors.py +964 -0
  26. lokal_id-0.2.2/src/lokal/samaran/fields.py +186 -0
  27. lokal_id-0.2.2/src/lokal/samaran/logging.py +64 -0
  28. lokal_id-0.2.2/src/lokal/samaran/types.py +43 -0
  29. lokal_id-0.2.2/src/lokal/terbilang.py +97 -0
  30. lokal_id-0.2.2/src/lokal/wilayah.py +225 -0
  31. lokal_id-0.2.2/tests/test_holidays.py +83 -0
  32. lokal_id-0.2.2/tests/test_identity.py +80 -0
  33. lokal_id-0.2.2/tests/test_parity.py +202 -0
  34. lokal_id-0.2.2/tests/test_python_api.py +75 -0
  35. lokal_id-0.2.2/tests/test_rupiah.py +115 -0
  36. lokal_id-0.2.2/tests/test_samaran.py +207 -0
  37. lokal_id-0.2.2/tests/test_uu_pdp.py +97 -0
  38. lokal_id-0.2.2/tests/test_wilayah.py +100 -0
  39. lokal_id-0.2.2/tests/vectors/holidays.json +9283 -0
  40. lokal_id-0.2.2/tests/vectors/nik.json +14459 -0
  41. lokal_id-0.2.2/tests/vectors/npwp.json +1850 -0
  42. lokal_id-0.2.2/tests/vectors/phone.json +6153 -0
  43. lokal_id-0.2.2/tests/vectors/rupiah_format.json +2256 -0
  44. lokal_id-0.2.2/tests/vectors/rupiah_parse.json +2126 -0
  45. lokal_id-0.2.2/tests/vectors/samaran_detect.json +59674 -0
  46. lokal_id-0.2.2/tests/vectors/samaran_fields.json +154 -0
  47. lokal_id-0.2.2/tests/vectors/samaran_mask.json +6385 -0
  48. lokal_id-0.2.2/tests/vectors/samaran_partial.json +1627 -0
  49. lokal_id-0.2.2/tests/vectors/terbilang.json +5140 -0
  50. lokal_id-0.2.2/tests/vectors/wilayah.json +3597 -0
@@ -0,0 +1,13 @@
1
+ node_modules/
2
+ dist/
3
+ *.tsbuildinfo
4
+ .DS_Store
5
+ evals/results/
6
+
7
+ # Python
8
+ .venv/
9
+ __pycache__/
10
+ *.egg-info/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
lokal_id-0.2.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lokal contributors
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,189 @@
1
+ Metadata-Version: 2.5
2
+ Name: lokal-id
3
+ Version: 0.2.2
4
+ Summary: Indonesian data primitives for apps and AI agents: NIK, NPWP, phone numbers, Rupiah, terbilang, holidays, Kemendagri regions with postal codes, and Samaran PII masking (UU PDP).
5
+ Project-URL: Homepage, https://github.com/lokal-id/lokal
6
+ Project-URL: Documentation, https://github.com/lokal-id/lokal/tree/main/python#readme
7
+ Project-URL: Issues, https://github.com/lokal-id/lokal/issues
8
+ Project-URL: Changelog, https://github.com/lokal-id/lokal/blob/main/CHANGELOG.md
9
+ Author: Lokal contributors
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ License-File: src/lokal/data/LICENSE-kodepos.txt
13
+ License-File: src/lokal/data/LICENSE-wilayah.txt
14
+ Keywords: hari-libur,indonesia,kode-pos,llm,masking,nik,npwp,pii,privacy,rupiah,terbilang,uu-pdp,wilayah
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Natural Language :: Indonesian
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Topic :: Security
27
+ Classifier: Topic :: Software Development :: Libraries
28
+ Classifier: Topic :: Text Processing
29
+ Classifier: Typing :: Typed
30
+ Requires-Python: >=3.10
31
+ Description-Content-Type: text/markdown
32
+
33
+ # lokal-id
34
+
35
+ [![PyPI](https://img.shields.io/pypi/v/lokal-id?color=3775a9)](https://pypi.org/project/lokal-id/)
36
+ [![Python](https://img.shields.io/pypi/pyversions/lokal-id)](https://pypi.org/project/lokal-id/)
37
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/lokal-id/lokal/blob/HEAD/LICENSE)
38
+
39
+ Indonesian data primitives for Python backends and AI agents: NIK, NPWP, phone numbers, Rupiah, *terbilang*, holidays and working days, Kemendagri regions with postal codes, and **Samaran**, which masks Indonesian personal data before it reaches an LLM or a log, and restores it afterwards.
40
+
41
+ The Python port of [`@lokal-id/core`](https://www.npmjs.com/package/@lokal-id/core): same rules, same data, same results, checked by replaying thousands of TypeScript outputs in the test suite. No dependencies, fully typed, Python 3.10+.
42
+
43
+ Part of [Lokal](https://github.com/lokal-id/lokal) · [Bahasa Indonesia](https://github.com/lokal-id/lokal/blob/HEAD/README.id.md)
44
+
45
+ ## Install
46
+
47
+ ```sh
48
+ pip install lokal-id
49
+ ```
50
+
51
+ ```python
52
+ import lokal # NIK, NPWP, phone, Rupiah, holidays, Samaran
53
+ from lokal import wilayah # regions and postal codes (loaded on first use)
54
+ ```
55
+
56
+ ## Usage
57
+
58
+ ```python
59
+ from decimal import Decimal
60
+ import datetime as dt
61
+
62
+ from lokal import (
63
+ parse_nik, parse_npwp, parse_phone, format_rupiah, format_rupiah_short, parse_rupiah, terbilang,
64
+ add_working_days, is_working_day,
65
+ )
66
+ from lokal.wilayah import list_wilayah, search_wilayah, wilayah_name, find_by_postal_code
67
+
68
+ nik = parse_nik('3201011508950001', lookup_wilayah=wilayah_name)
69
+ nik.valid, nik.regency, nik.district, nik.gender, nik.birth_date
70
+ # (True, 'Kabupaten Bogor', 'Cibinong', 'L', '1995-08-15')
71
+
72
+ phone = parse_phone('0812 3456 7890')
73
+ phone.e164, phone.national, phone.whatsapp, phone.operator
74
+ # ('+6281234567890', '0812-3456-7890', '6281234567890', 'Telkomsel')
75
+
76
+ format_rupiah(1500000) # 'Rp1.500.000'
77
+ format_rupiah(Decimal('1500000.505'), decimals=True) # 'Rp1.500.000,51', exact for database decimals
78
+ format_rupiah_short(2300000000) # 'Rp2,3 M'
79
+ terbilang(1500000, currency=True) # 'satu juta lima ratus ribu rupiah'
80
+ parse_rupiah('2jtan') # ParsedRupiah(amount=2000000, approximate=True)
81
+ parse_rupiah('sejuta setengah') # ParsedRupiah(amount=1500000, approximate=False)
82
+
83
+ add_working_days('2026-03-17', 1) # '2026-03-25', skipping Nyepi and Lebaran
84
+ add_working_days(dt.date(2026, 3, 17), 1) # datetime.date(2026, 3, 25): dates in, dates out
85
+ is_working_day('2026-12-24', cuti_bersama=False) # True for offices that ignore cuti bersama
86
+
87
+ len(list_wilayah('32.76')) # 11 kecamatan in Kota Depok
88
+ search_wilayah('kab. bogor')[0].code # '32.01'
89
+ find_by_postal_code('16431')[0].path # 'Depok, Pancoran Mas, Kota Depok, Jawa Barat'
90
+ ```
91
+
92
+ Every result is a dataclass. `to_dict()` gives a JSON-ready dict without empty fields, and `to_dict(camel=True)` uses the same keys as the TypeScript package (`birthDate`, `postalCode`) for frontends that share types with it.
93
+
94
+ ### Samaran: mask before the LLM, restore after
95
+
96
+ ```python
97
+ from anthropic import Anthropic
98
+ from lokal import Samaran
99
+
100
+ client = Anthropic()
101
+
102
+ def balas_pelanggan(pesan: str) -> str:
103
+ samaran = Samaran() # one vault per conversation or request
104
+ masked = samaran.mask(pesan).text # 'NIK saya [NIK_1], HP [PHONE_1]'
105
+ reply = client.messages.create(
106
+ model='claude-sonnet-5-5',
107
+ max_tokens=1024,
108
+ messages=[{'role': 'user', 'content': masked}],
109
+ )
110
+ return samaran.unmask(reply.content[0].text) # real values back, on your server
111
+ ```
112
+
113
+ - The same value always maps to the same placeholder, across formats: `0812-…` and `+62 812…` are both `[PHONE_1]`.
114
+ - `Samaran(mode='partial')` is for logs and admin screens: `3201********0001`, `0812****7890`, `b***@gmail.com`.
115
+ - `Samaran(allow=['cs@toko.id', re.compile(r'@toko\.id$')])` never masks your own contacts.
116
+ - `export_vault()` / `import_vault()` move the vault between processes, for example through Redis. Treat it as the raw data it contains.
117
+ - `detect_pii(text)` returns the matches without masking; `min_confidence='high'` keeps only validated or keyword-anchored ones.
118
+ - 25 types, covering every category in UU PDP Article 4: identifiers (NIK, KK, NPWP, passport, SIM, NIP, BPJS, phone, email, bank account, card, plate, IP, coordinates), name, address, birth date, gender, religion, marital status, nationality, health, finances, criminal records, and biometrics. Found through labels, CSV headers, JSON/YAML keys, SQL `INSERT` dumps, WhatsApp chat exports, and common phrases in free text.
119
+
120
+ ### Logging
121
+
122
+ ```python
123
+ import logging
124
+ from lokal.samaran.logging import SamaranFilter
125
+
126
+ handler = logging.StreamHandler()
127
+ handler.addFilter(SamaranFilter()) # partial masks by default
128
+ logging.basicConfig(handlers=[handler], level=logging.INFO)
129
+
130
+ logging.info('refund ke rek BCA %s, HP %s', '1234567890', '0812-3456-7890')
131
+ # INFO:root:refund ke rek BCA ******7890, HP 0812****7890
132
+ ```
133
+
134
+ The filter masks the formatted message, tracebacks, and stack info. Attach it to handlers, not loggers, so records from every logger pass through it.
135
+
136
+ ### Validating API input
137
+
138
+ ```python
139
+ from pydantic import BaseModel, field_validator
140
+ from lokal import normalize_phone, parse_nik
141
+
142
+ class Pelanggan(BaseModel):
143
+ nik: str
144
+ hp: str
145
+
146
+ @field_validator('nik')
147
+ @classmethod
148
+ def nik_valid(cls, v: str) -> str:
149
+ info = parse_nik(v)
150
+ if not info.valid:
151
+ raise ValueError(info.errors[0].message) # 'Tanggal lahir di NIK tidak valid.'
152
+ return info.nik
153
+
154
+ @field_validator('hp')
155
+ @classmethod
156
+ def hp_e164(cls, v: str) -> str:
157
+ e164 = normalize_phone(v)
158
+ if e164 is None:
159
+ raise ValueError('Nomor HP tidak valid.')
160
+ return e164 # store +62…
161
+ ```
162
+
163
+ ## Same results as TypeScript
164
+
165
+ The test suite replays vectors generated from `@lokal-id/core`: hundreds of NIK, NPWP, phone, and Rupiah inputs, every day of 2026 and 2027, all 91,000 regions with their postal codes, and 2,700 texts for Samaran, including WhatsApp exports, SQL dumps, emoji, no-break spaces, and CRLF. Python must return exactly what TypeScript returns. On top of that, 40,000 random texts and 11 MB of real files gave identical matches during development.
166
+
167
+ To get there the port keeps JavaScript semantics where they differ from Python's: ASCII `\d`, `\w` and `\b`; JavaScript's whitespace set; `Math.round` instead of round-half-even; regex lengths counted in UTF-16 units, as a JavaScript regex counts them.
168
+
169
+ Where Python offers more, it uses it:
170
+
171
+ | | TypeScript | Python |
172
+ |---|---|---|
173
+ | Big integers | `bigint` for `terbilang` beyond 2^53 | any `int` |
174
+ | Money | `number` | `int`, `float`, or exact `Decimal` |
175
+ | Dates | ISO strings, `Date` (read in UTC) | ISO strings, `date`, `datetime` (read in its own timezone); a `date` in gives a `date` out |
176
+ | Match offsets | UTF-16 units | Python characters: `text[m.start:m.end] == m.value` |
177
+ | Names | `camelCase` | `snake_case`; `to_dict(camel=True)` for TypeScript keys |
178
+
179
+ ## Performance
180
+
181
+ Pure Python, no regex compilation at call time: masking a chat message takes about 0.2 ms, a 4 KB prompt about 5 ms, and a log record through `SamaranFilter` about 0.1 ms. Large inputs run at roughly 1 MB per second, linear in size, including single-line JSON. `import lokal` takes about 0.1 s; region data loads on first use in about 0.25 s and 20 MB.
182
+
183
+ ## Limitations
184
+
185
+ Samaran is pattern-based and favors precision. A name dropped into a sentence with no cue at all, unusually phrased health or financial data, and children's data that is not marked as such can still slip through. NIK and NPWP checks are structural, and holidays exist only for years whose SKB has been published (2026, 2027). See the [Lokal README](https://github.com/lokal-id/lokal#limitations) for the full list.
186
+
187
+ ## License
188
+
189
+ Code: [MIT](https://github.com/lokal-id/lokal/blob/HEAD/LICENSE). Region data from [cahyadsn/wilayah](https://github.com/cahyadsn/wilayah) and postal codes from [cahyadsn/wilayah_kodepos](https://github.com/cahyadsn/wilayah_kodepos), both MIT, included in the package with their licenses.
@@ -0,0 +1,157 @@
1
+ # lokal-id
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/lokal-id?color=3775a9)](https://pypi.org/project/lokal-id/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/lokal-id)](https://pypi.org/project/lokal-id/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/lokal-id/lokal/blob/HEAD/LICENSE)
6
+
7
+ Indonesian data primitives for Python backends and AI agents: NIK, NPWP, phone numbers, Rupiah, *terbilang*, holidays and working days, Kemendagri regions with postal codes, and **Samaran**, which masks Indonesian personal data before it reaches an LLM or a log, and restores it afterwards.
8
+
9
+ The Python port of [`@lokal-id/core`](https://www.npmjs.com/package/@lokal-id/core): same rules, same data, same results, checked by replaying thousands of TypeScript outputs in the test suite. No dependencies, fully typed, Python 3.10+.
10
+
11
+ Part of [Lokal](https://github.com/lokal-id/lokal) · [Bahasa Indonesia](https://github.com/lokal-id/lokal/blob/HEAD/README.id.md)
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ pip install lokal-id
17
+ ```
18
+
19
+ ```python
20
+ import lokal # NIK, NPWP, phone, Rupiah, holidays, Samaran
21
+ from lokal import wilayah # regions and postal codes (loaded on first use)
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```python
27
+ from decimal import Decimal
28
+ import datetime as dt
29
+
30
+ from lokal import (
31
+ parse_nik, parse_npwp, parse_phone, format_rupiah, format_rupiah_short, parse_rupiah, terbilang,
32
+ add_working_days, is_working_day,
33
+ )
34
+ from lokal.wilayah import list_wilayah, search_wilayah, wilayah_name, find_by_postal_code
35
+
36
+ nik = parse_nik('3201011508950001', lookup_wilayah=wilayah_name)
37
+ nik.valid, nik.regency, nik.district, nik.gender, nik.birth_date
38
+ # (True, 'Kabupaten Bogor', 'Cibinong', 'L', '1995-08-15')
39
+
40
+ phone = parse_phone('0812 3456 7890')
41
+ phone.e164, phone.national, phone.whatsapp, phone.operator
42
+ # ('+6281234567890', '0812-3456-7890', '6281234567890', 'Telkomsel')
43
+
44
+ format_rupiah(1500000) # 'Rp1.500.000'
45
+ format_rupiah(Decimal('1500000.505'), decimals=True) # 'Rp1.500.000,51', exact for database decimals
46
+ format_rupiah_short(2300000000) # 'Rp2,3 M'
47
+ terbilang(1500000, currency=True) # 'satu juta lima ratus ribu rupiah'
48
+ parse_rupiah('2jtan') # ParsedRupiah(amount=2000000, approximate=True)
49
+ parse_rupiah('sejuta setengah') # ParsedRupiah(amount=1500000, approximate=False)
50
+
51
+ add_working_days('2026-03-17', 1) # '2026-03-25', skipping Nyepi and Lebaran
52
+ add_working_days(dt.date(2026, 3, 17), 1) # datetime.date(2026, 3, 25): dates in, dates out
53
+ is_working_day('2026-12-24', cuti_bersama=False) # True for offices that ignore cuti bersama
54
+
55
+ len(list_wilayah('32.76')) # 11 kecamatan in Kota Depok
56
+ search_wilayah('kab. bogor')[0].code # '32.01'
57
+ find_by_postal_code('16431')[0].path # 'Depok, Pancoran Mas, Kota Depok, Jawa Barat'
58
+ ```
59
+
60
+ Every result is a dataclass. `to_dict()` gives a JSON-ready dict without empty fields, and `to_dict(camel=True)` uses the same keys as the TypeScript package (`birthDate`, `postalCode`) for frontends that share types with it.
61
+
62
+ ### Samaran: mask before the LLM, restore after
63
+
64
+ ```python
65
+ from anthropic import Anthropic
66
+ from lokal import Samaran
67
+
68
+ client = Anthropic()
69
+
70
+ def balas_pelanggan(pesan: str) -> str:
71
+ samaran = Samaran() # one vault per conversation or request
72
+ masked = samaran.mask(pesan).text # 'NIK saya [NIK_1], HP [PHONE_1]'
73
+ reply = client.messages.create(
74
+ model='claude-sonnet-5-5',
75
+ max_tokens=1024,
76
+ messages=[{'role': 'user', 'content': masked}],
77
+ )
78
+ return samaran.unmask(reply.content[0].text) # real values back, on your server
79
+ ```
80
+
81
+ - The same value always maps to the same placeholder, across formats: `0812-…` and `+62 812…` are both `[PHONE_1]`.
82
+ - `Samaran(mode='partial')` is for logs and admin screens: `3201********0001`, `0812****7890`, `b***@gmail.com`.
83
+ - `Samaran(allow=['cs@toko.id', re.compile(r'@toko\.id$')])` never masks your own contacts.
84
+ - `export_vault()` / `import_vault()` move the vault between processes, for example through Redis. Treat it as the raw data it contains.
85
+ - `detect_pii(text)` returns the matches without masking; `min_confidence='high'` keeps only validated or keyword-anchored ones.
86
+ - 25 types, covering every category in UU PDP Article 4: identifiers (NIK, KK, NPWP, passport, SIM, NIP, BPJS, phone, email, bank account, card, plate, IP, coordinates), name, address, birth date, gender, religion, marital status, nationality, health, finances, criminal records, and biometrics. Found through labels, CSV headers, JSON/YAML keys, SQL `INSERT` dumps, WhatsApp chat exports, and common phrases in free text.
87
+
88
+ ### Logging
89
+
90
+ ```python
91
+ import logging
92
+ from lokal.samaran.logging import SamaranFilter
93
+
94
+ handler = logging.StreamHandler()
95
+ handler.addFilter(SamaranFilter()) # partial masks by default
96
+ logging.basicConfig(handlers=[handler], level=logging.INFO)
97
+
98
+ logging.info('refund ke rek BCA %s, HP %s', '1234567890', '0812-3456-7890')
99
+ # INFO:root:refund ke rek BCA ******7890, HP 0812****7890
100
+ ```
101
+
102
+ The filter masks the formatted message, tracebacks, and stack info. Attach it to handlers, not loggers, so records from every logger pass through it.
103
+
104
+ ### Validating API input
105
+
106
+ ```python
107
+ from pydantic import BaseModel, field_validator
108
+ from lokal import normalize_phone, parse_nik
109
+
110
+ class Pelanggan(BaseModel):
111
+ nik: str
112
+ hp: str
113
+
114
+ @field_validator('nik')
115
+ @classmethod
116
+ def nik_valid(cls, v: str) -> str:
117
+ info = parse_nik(v)
118
+ if not info.valid:
119
+ raise ValueError(info.errors[0].message) # 'Tanggal lahir di NIK tidak valid.'
120
+ return info.nik
121
+
122
+ @field_validator('hp')
123
+ @classmethod
124
+ def hp_e164(cls, v: str) -> str:
125
+ e164 = normalize_phone(v)
126
+ if e164 is None:
127
+ raise ValueError('Nomor HP tidak valid.')
128
+ return e164 # store +62…
129
+ ```
130
+
131
+ ## Same results as TypeScript
132
+
133
+ The test suite replays vectors generated from `@lokal-id/core`: hundreds of NIK, NPWP, phone, and Rupiah inputs, every day of 2026 and 2027, all 91,000 regions with their postal codes, and 2,700 texts for Samaran, including WhatsApp exports, SQL dumps, emoji, no-break spaces, and CRLF. Python must return exactly what TypeScript returns. On top of that, 40,000 random texts and 11 MB of real files gave identical matches during development.
134
+
135
+ To get there the port keeps JavaScript semantics where they differ from Python's: ASCII `\d`, `\w` and `\b`; JavaScript's whitespace set; `Math.round` instead of round-half-even; regex lengths counted in UTF-16 units, as a JavaScript regex counts them.
136
+
137
+ Where Python offers more, it uses it:
138
+
139
+ | | TypeScript | Python |
140
+ |---|---|---|
141
+ | Big integers | `bigint` for `terbilang` beyond 2^53 | any `int` |
142
+ | Money | `number` | `int`, `float`, or exact `Decimal` |
143
+ | Dates | ISO strings, `Date` (read in UTC) | ISO strings, `date`, `datetime` (read in its own timezone); a `date` in gives a `date` out |
144
+ | Match offsets | UTF-16 units | Python characters: `text[m.start:m.end] == m.value` |
145
+ | Names | `camelCase` | `snake_case`; `to_dict(camel=True)` for TypeScript keys |
146
+
147
+ ## Performance
148
+
149
+ Pure Python, no regex compilation at call time: masking a chat message takes about 0.2 ms, a 4 KB prompt about 5 ms, and a log record through `SamaranFilter` about 0.1 ms. Large inputs run at roughly 1 MB per second, linear in size, including single-line JSON. `import lokal` takes about 0.1 s; region data loads on first use in about 0.25 s and 20 MB.
150
+
151
+ ## Limitations
152
+
153
+ Samaran is pattern-based and favors precision. A name dropped into a sentence with no cue at all, unusually phrased health or financial data, and children's data that is not marked as such can still slip through. NIK and NPWP checks are structural, and holidays exist only for years whose SKB has been published (2026, 2027). See the [Lokal README](https://github.com/lokal-id/lokal#limitations) for the full list.
154
+
155
+ ## License
156
+
157
+ Code: [MIT](https://github.com/lokal-id/lokal/blob/HEAD/LICENSE). Region data from [cahyadsn/wilayah](https://github.com/cahyadsn/wilayah) and postal codes from [cahyadsn/wilayah_kodepos](https://github.com/cahyadsn/wilayah_kodepos), both MIT, included in the package with their licenses.
@@ -0,0 +1,78 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "lokal-id"
7
+ version = "0.2.2"
8
+ description = "Indonesian data primitives for apps and AI agents: NIK, NPWP, phone numbers, Rupiah, terbilang, holidays, Kemendagri regions with postal codes, and Samaran PII masking (UU PDP)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE", "src/lokal/data/LICENSE-*.txt"]
13
+ authors = [{ name = "Lokal contributors" }]
14
+ keywords = [
15
+ "indonesia", "nik", "npwp", "rupiah", "terbilang", "wilayah", "kode-pos", "hari-libur", "pii", "uu-pdp",
16
+ "privacy", "llm", "masking",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Intended Audience :: Developers",
21
+ "Natural Language :: Indonesian",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3 :: Only",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Programming Language :: Python :: 3.14",
30
+ "Topic :: Security",
31
+ "Topic :: Software Development :: Libraries",
32
+ "Topic :: Text Processing",
33
+ "Typing :: Typed",
34
+ ]
35
+ dependencies = []
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/lokal-id/lokal"
39
+ Documentation = "https://github.com/lokal-id/lokal/tree/main/python#readme"
40
+ Issues = "https://github.com/lokal-id/lokal/issues"
41
+ Changelog = "https://github.com/lokal-id/lokal/blob/main/CHANGELOG.md"
42
+
43
+ [dependency-groups]
44
+ dev = ["pytest>=8", "mypy>=1.11", "ruff>=0.6"]
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/lokal"]
48
+
49
+ [tool.hatch.build.targets.sdist]
50
+ include = ["src", "tests", "scripts", "README.md", "LICENSE"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+ addopts = "-ra"
55
+ filterwarnings = ["error"]
56
+
57
+ [tool.mypy]
58
+ strict = true
59
+ python_version = "3.10"
60
+ files = ["src"]
61
+
62
+ [tool.ruff]
63
+ line-length = 120
64
+ target-version = "py310"
65
+ src = ["src"]
66
+
67
+ [tool.ruff.lint]
68
+ select = ["E", "F", "W", "I", "B", "UP", "SIM"]
69
+ ignore = ["UP007", "UP045", "SIM108", "SIM905"]
70
+
71
+ [tool.ruff.lint.per-file-ignores]
72
+ # Patterns are the TypeScript sources verbatim, so they stay on one line each.
73
+ "src/lokal/samaran/detectors.py" = ["E501"]
74
+ "src/lokal/samaran/fields.py" = ["E501"]
75
+ "tests/*" = ["E501", "B023"]
76
+
77
+ [tool.ruff.format]
78
+ quote-style = "single"