id-extract 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- id_extract-0.2.0/PKG-INFO +36 -0
- id_extract-0.2.0/README.md +25 -0
- id_extract-0.2.0/pyproject.toml +24 -0
- id_extract-0.2.0/setup.cfg +4 -0
- id_extract-0.2.0/src/id_extract/__init__.py +26 -0
- id_extract-0.2.0/src/id_extract/checksums.py +472 -0
- id_extract-0.2.0/src/id_extract/countries/__init__.py +1 -0
- id_extract-0.2.0/src/id_extract/countries/ar.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/at.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/au.py +9 -0
- id_extract-0.2.0/src/id_extract/countries/be.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/br.py +9 -0
- id_extract-0.2.0/src/id_extract/countries/ca.py +15 -0
- id_extract-0.2.0/src/id_extract/countries/ch.py +8 -0
- id_extract-0.2.0/src/id_extract/countries/cn.py +9 -0
- id_extract-0.2.0/src/id_extract/countries/de.py +10 -0
- id_extract-0.2.0/src/id_extract/countries/dk.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/es.py +11 -0
- id_extract-0.2.0/src/id_extract/countries/fi.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/fr.py +10 -0
- id_extract-0.2.0/src/id_extract/countries/gb.py +10 -0
- id_extract-0.2.0/src/id_extract/countries/gr.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/hk.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/id.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/ie.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/il.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/in.py +10 -0
- id_extract-0.2.0/src/id_extract/countries/it.py +9 -0
- id_extract-0.2.0/src/id_extract/countries/jp.py +9 -0
- id_extract-0.2.0/src/id_extract/countries/kr.py +8 -0
- id_extract-0.2.0/src/id_extract/countries/mx.py +13 -0
- id_extract-0.2.0/src/id_extract/countries/my.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/nl.py +8 -0
- id_extract-0.2.0/src/id_extract/countries/no.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/nz.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/pl.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/pt.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/ru.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/se.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/sg.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/th.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/tr.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/tw.py +7 -0
- id_extract-0.2.0/src/id_extract/countries/universal.py +24 -0
- id_extract-0.2.0/src/id_extract/countries/us.py +28 -0
- id_extract-0.2.0/src/id_extract/countries/za.py +8 -0
- id_extract-0.2.0/src/id_extract/extract.py +120 -0
- id_extract-0.2.0/src/id_extract/opt_in.py +185 -0
- id_extract-0.2.0/src/id_extract/plugins.py +197 -0
- id_extract-0.2.0/src/id_extract/types.py +50 -0
- id_extract-0.2.0/src/id_extract.egg-info/PKG-INFO +36 -0
- id_extract-0.2.0/src/id_extract.egg-info/SOURCES.txt +53 -0
- id_extract-0.2.0/src/id_extract.egg-info/dependency_links.txt +1 -0
- id_extract-0.2.0/src/id_extract.egg-info/requires.txt +1 -0
- id_extract-0.2.0/src/id_extract.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: id-extract
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Extract structured identifiers (IDs, cards, IBAN, email) with RE2 and checksums.
|
|
5
|
+
Author-email: Leonid Ganeline <leo.gan.57@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: repository, https://github.com/leo-gan/anonymizer
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: google-re2
|
|
11
|
+
|
|
12
|
+
# id-extract
|
|
13
|
+
|
|
14
|
+
Find structured identifiers in text. This package returns character offsets. It does not replace values or open files.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install id-extract
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from id_extract import extract
|
|
22
|
+
|
|
23
|
+
for hit in extract("ABN 51 824 753 556 and ada@example.com"):
|
|
24
|
+
print(hit["type"], hit["text"], hit["start"], hit["end"])
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The default is every bundled country. Narrow the set with `countries=["AU", "GB"]`. `UK` is accepted as `GB`. State, provincial, and industry patterns stay off until you pass `opt_in="all"` or a list of keys.
|
|
28
|
+
|
|
29
|
+
Full guide, including one page per country with official sources:
|
|
30
|
+
|
|
31
|
+
- [id-extract overview](https://leo-gan.github.io/anonymizer/id-extract/)
|
|
32
|
+
- [Install and API](https://leo-gan.github.io/anonymizer/id-extract/api/)
|
|
33
|
+
- [Country catalog](https://leo-gan.github.io/anonymizer/id-extract/countries/)
|
|
34
|
+
- Example: [Australia (ABN, TFN)](https://leo-gan.github.io/anonymizer/id-extract/countries/au/)
|
|
35
|
+
|
|
36
|
+
A hit means the text looked like that identifier. It is not a legal determination.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# id-extract
|
|
2
|
+
|
|
3
|
+
Find structured identifiers in text. This package returns character offsets. It does not replace values or open files.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install id-extract
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from id_extract import extract
|
|
11
|
+
|
|
12
|
+
for hit in extract("ABN 51 824 753 556 and ada@example.com"):
|
|
13
|
+
print(hit["type"], hit["text"], hit["start"], hit["end"])
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The default is every bundled country. Narrow the set with `countries=["AU", "GB"]`. `UK` is accepted as `GB`. State, provincial, and industry patterns stay off until you pass `opt_in="all"` or a list of keys.
|
|
17
|
+
|
|
18
|
+
Full guide, including one page per country with official sources:
|
|
19
|
+
|
|
20
|
+
- [id-extract overview](https://leo-gan.github.io/anonymizer/id-extract/)
|
|
21
|
+
- [Install and API](https://leo-gan.github.io/anonymizer/id-extract/api/)
|
|
22
|
+
- [Country catalog](https://leo-gan.github.io/anonymizer/id-extract/countries/)
|
|
23
|
+
- Example: [Australia (ABN, TFN)](https://leo-gan.github.io/anonymizer/id-extract/countries/au/)
|
|
24
|
+
|
|
25
|
+
A hit means the text looked like that identifier. It is not a legal determination.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "id-extract"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Extract structured identifiers (IDs, cards, IBAN, email) with RE2 and checksums."
|
|
5
|
+
authors = [{ name = "Leonid Ganeline", email = "leo.gan.57@gmail.com" }]
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"google-re2",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
repository = "https://github.com/leo-gan/anonymizer"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["setuptools>=61.0"]
|
|
18
|
+
build-backend = "setuptools.build_meta"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools]
|
|
21
|
+
package-dir = {"" = "src"}
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Extract structured identifiers with RE2 and checksums.
|
|
2
|
+
|
|
3
|
+
This package finds placements. It does not replace text, build a mapping,
|
|
4
|
+
or open files. Documentation lives in the anonymizer monorepo
|
|
5
|
+
(https://leo-gan.github.io/anonymizer/).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from id_extract.extract import extract
|
|
9
|
+
from id_extract.opt_in import available_opt_in
|
|
10
|
+
from id_extract.plugins import (
|
|
11
|
+
available_countries,
|
|
12
|
+
filter_regex_patterns,
|
|
13
|
+
pattern_country,
|
|
14
|
+
patterns,
|
|
15
|
+
)
|
|
16
|
+
from id_extract.types import Entity
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Entity",
|
|
20
|
+
"available_countries",
|
|
21
|
+
"available_opt_in",
|
|
22
|
+
"extract",
|
|
23
|
+
"filter_regex_patterns",
|
|
24
|
+
"pattern_country",
|
|
25
|
+
"patterns",
|
|
26
|
+
]
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
"""Cheap, unambiguous checksums for structured regex hits.
|
|
2
|
+
|
|
3
|
+
The regex stage is structural on purpose (RE2 cannot do Luhn, IBAN mod-97, etc.).
|
|
4
|
+
After a match, this module checks the extra digit when one exists.
|
|
5
|
+
|
|
6
|
+
- Check passes: keep the real type (``IBAN``).
|
|
7
|
+
- Check fails: keep the text, but relabel as ``IBAN_LIKE`` so a mistyped
|
|
8
|
+
number is still hidden. Never drop the hit.
|
|
9
|
+
- No registered check: accept the type unchanged.
|
|
10
|
+
|
|
11
|
+
Only attach a check when it is cheap and unambiguous. Do not invent rules for
|
|
12
|
+
identifiers that have none (most SSNs, many passports, most VAT numbers).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Callable, Dict
|
|
18
|
+
|
|
19
|
+
# Official IBAN character lengths by country code (ISO 13616).
|
|
20
|
+
# Unknown countries are rejected so random "ABxx..." tokens do not survive.
|
|
21
|
+
_IBAN_LENGTHS: Dict[str, int] = {
|
|
22
|
+
"AD": 24,
|
|
23
|
+
"AE": 23,
|
|
24
|
+
"AL": 28,
|
|
25
|
+
"AT": 20,
|
|
26
|
+
"AZ": 28,
|
|
27
|
+
"BA": 20,
|
|
28
|
+
"BE": 16,
|
|
29
|
+
"BG": 22,
|
|
30
|
+
"BH": 22,
|
|
31
|
+
"BR": 29,
|
|
32
|
+
"BY": 28,
|
|
33
|
+
"CH": 21,
|
|
34
|
+
"CR": 22,
|
|
35
|
+
"CY": 28,
|
|
36
|
+
"CZ": 24,
|
|
37
|
+
"DE": 22,
|
|
38
|
+
"DK": 18,
|
|
39
|
+
"DO": 28,
|
|
40
|
+
"EE": 20,
|
|
41
|
+
"EG": 29,
|
|
42
|
+
"ES": 24,
|
|
43
|
+
"FI": 18,
|
|
44
|
+
"FO": 18,
|
|
45
|
+
"FR": 27,
|
|
46
|
+
"GB": 22,
|
|
47
|
+
"GE": 22,
|
|
48
|
+
"GI": 23,
|
|
49
|
+
"GL": 18,
|
|
50
|
+
"GR": 27,
|
|
51
|
+
"GT": 28,
|
|
52
|
+
"HR": 21,
|
|
53
|
+
"HU": 28,
|
|
54
|
+
"IE": 22,
|
|
55
|
+
"IL": 23,
|
|
56
|
+
"IQ": 23,
|
|
57
|
+
"IS": 26,
|
|
58
|
+
"IT": 27,
|
|
59
|
+
"JO": 30,
|
|
60
|
+
"KW": 30,
|
|
61
|
+
"KZ": 20,
|
|
62
|
+
"LB": 28,
|
|
63
|
+
"LC": 32,
|
|
64
|
+
"LI": 21,
|
|
65
|
+
"LT": 20,
|
|
66
|
+
"LU": 20,
|
|
67
|
+
"LV": 21,
|
|
68
|
+
"LY": 25,
|
|
69
|
+
"MC": 27,
|
|
70
|
+
"MD": 24,
|
|
71
|
+
"ME": 22,
|
|
72
|
+
"MK": 19,
|
|
73
|
+
"MR": 27,
|
|
74
|
+
"MT": 31,
|
|
75
|
+
"MU": 30,
|
|
76
|
+
"NL": 18,
|
|
77
|
+
"NO": 15,
|
|
78
|
+
"PK": 24,
|
|
79
|
+
"PL": 28,
|
|
80
|
+
"PS": 29,
|
|
81
|
+
"PT": 25,
|
|
82
|
+
"QA": 29,
|
|
83
|
+
"RO": 24,
|
|
84
|
+
"RS": 22,
|
|
85
|
+
"SA": 24,
|
|
86
|
+
"SE": 24,
|
|
87
|
+
"SI": 19,
|
|
88
|
+
"SK": 24,
|
|
89
|
+
"SM": 27,
|
|
90
|
+
"TN": 24,
|
|
91
|
+
"TR": 26,
|
|
92
|
+
"UA": 29,
|
|
93
|
+
"VA": 22,
|
|
94
|
+
"VG": 24,
|
|
95
|
+
"XK": 20,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# ISO 3779 VIN transliteration (I, O, Q are not used).
|
|
99
|
+
_VIN_TRANSLIT = {
|
|
100
|
+
"A": 1,
|
|
101
|
+
"B": 2,
|
|
102
|
+
"C": 3,
|
|
103
|
+
"D": 4,
|
|
104
|
+
"E": 5,
|
|
105
|
+
"F": 6,
|
|
106
|
+
"G": 7,
|
|
107
|
+
"H": 8,
|
|
108
|
+
"J": 1,
|
|
109
|
+
"K": 2,
|
|
110
|
+
"L": 3,
|
|
111
|
+
"M": 4,
|
|
112
|
+
"N": 5,
|
|
113
|
+
"P": 7,
|
|
114
|
+
"R": 9,
|
|
115
|
+
"S": 2,
|
|
116
|
+
"T": 3,
|
|
117
|
+
"U": 4,
|
|
118
|
+
"V": 5,
|
|
119
|
+
"W": 6,
|
|
120
|
+
"X": 7,
|
|
121
|
+
"Y": 8,
|
|
122
|
+
"Z": 9,
|
|
123
|
+
}
|
|
124
|
+
_VIN_WEIGHTS = (8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2)
|
|
125
|
+
|
|
126
|
+
# Spanish DNI / NIE remainder -> letter.
|
|
127
|
+
_DNI_LETTERS = "TRWAGMYFPDXBNJZSQVHLCKE"
|
|
128
|
+
_NIE_PREFIX = {"X": "0", "Y": "1", "Z": "2"}
|
|
129
|
+
|
|
130
|
+
# Chinese Resident Identity Card (GB 11643-1999).
|
|
131
|
+
_CN_WEIGHTS = (7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2)
|
|
132
|
+
_CN_CHECK = "10X98765432"
|
|
133
|
+
|
|
134
|
+
# Verhoeff tables (Aadhaar).
|
|
135
|
+
_VERHOEFF_D = (
|
|
136
|
+
(0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
|
|
137
|
+
(1, 2, 3, 4, 0, 6, 7, 8, 9, 5),
|
|
138
|
+
(2, 3, 4, 0, 1, 7, 8, 9, 5, 6),
|
|
139
|
+
(3, 4, 0, 1, 2, 8, 9, 5, 6, 7),
|
|
140
|
+
(4, 0, 1, 2, 3, 9, 5, 6, 7, 8),
|
|
141
|
+
(5, 9, 8, 7, 6, 0, 4, 3, 2, 1),
|
|
142
|
+
(6, 5, 9, 8, 7, 1, 0, 4, 3, 2),
|
|
143
|
+
(7, 6, 5, 9, 8, 2, 1, 0, 4, 3),
|
|
144
|
+
(8, 7, 6, 5, 9, 3, 2, 1, 0, 4),
|
|
145
|
+
(9, 8, 7, 6, 5, 4, 3, 2, 1, 0),
|
|
146
|
+
)
|
|
147
|
+
_VERHOEFF_P = (
|
|
148
|
+
(0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
|
|
149
|
+
(1, 5, 7, 6, 2, 8, 3, 0, 9, 4),
|
|
150
|
+
(5, 8, 0, 3, 7, 9, 6, 1, 4, 2),
|
|
151
|
+
(8, 9, 1, 6, 0, 4, 3, 5, 2, 7),
|
|
152
|
+
(9, 4, 5, 3, 1, 2, 6, 8, 7, 0),
|
|
153
|
+
(4, 2, 8, 6, 5, 7, 3, 9, 0, 1),
|
|
154
|
+
(2, 7, 9, 3, 8, 0, 6, 4, 1, 5),
|
|
155
|
+
(7, 0, 4, 6, 9, 1, 3, 2, 5, 8),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Italian codice fiscale: odd positions (1-based) and even positions.
|
|
159
|
+
_CF_ODD = {
|
|
160
|
+
"0": 1,
|
|
161
|
+
"1": 0,
|
|
162
|
+
"2": 5,
|
|
163
|
+
"3": 7,
|
|
164
|
+
"4": 9,
|
|
165
|
+
"5": 13,
|
|
166
|
+
"6": 15,
|
|
167
|
+
"7": 17,
|
|
168
|
+
"8": 19,
|
|
169
|
+
"9": 21,
|
|
170
|
+
"A": 1,
|
|
171
|
+
"B": 0,
|
|
172
|
+
"C": 5,
|
|
173
|
+
"D": 7,
|
|
174
|
+
"E": 9,
|
|
175
|
+
"F": 13,
|
|
176
|
+
"G": 15,
|
|
177
|
+
"H": 17,
|
|
178
|
+
"I": 19,
|
|
179
|
+
"J": 21,
|
|
180
|
+
"K": 2,
|
|
181
|
+
"L": 4,
|
|
182
|
+
"M": 18,
|
|
183
|
+
"N": 20,
|
|
184
|
+
"O": 11,
|
|
185
|
+
"P": 3,
|
|
186
|
+
"Q": 6,
|
|
187
|
+
"R": 8,
|
|
188
|
+
"S": 12,
|
|
189
|
+
"T": 14,
|
|
190
|
+
"U": 16,
|
|
191
|
+
"V": 10,
|
|
192
|
+
"W": 22,
|
|
193
|
+
"X": 25,
|
|
194
|
+
"Y": 24,
|
|
195
|
+
"Z": 23,
|
|
196
|
+
}
|
|
197
|
+
_CF_EVEN = {
|
|
198
|
+
**{str(i): i for i in range(10)},
|
|
199
|
+
**{chr(ord("A") + i): i for i in range(26)},
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _digits_only(text: str) -> str:
|
|
204
|
+
return "".join(ch for ch in text if ch.isdigit())
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _alnum_upper(text: str) -> str:
|
|
208
|
+
return "".join(ch for ch in text.upper() if ch.isalnum())
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def luhn_ok(digits: str) -> bool:
|
|
212
|
+
"""Return True if ``digits`` (0-9 only) passes the Luhn check."""
|
|
213
|
+
if not digits or not digits.isdigit():
|
|
214
|
+
return False
|
|
215
|
+
total = 0
|
|
216
|
+
# Double every second digit from the right.
|
|
217
|
+
reverse = digits[::-1]
|
|
218
|
+
for i, ch in enumerate(reverse):
|
|
219
|
+
n = ord(ch) - 48
|
|
220
|
+
if i % 2 == 1:
|
|
221
|
+
n *= 2
|
|
222
|
+
if n > 9:
|
|
223
|
+
n -= 9
|
|
224
|
+
total += n
|
|
225
|
+
return total % 10 == 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def verhoeff_ok(digits: str) -> bool:
|
|
229
|
+
"""Return True if ``digits`` (0-9 only) passes the Verhoeff check."""
|
|
230
|
+
if not digits or not digits.isdigit():
|
|
231
|
+
return False
|
|
232
|
+
checksum = 0
|
|
233
|
+
for i, ch in enumerate(reversed(digits)):
|
|
234
|
+
checksum = _VERHOEFF_D[checksum][_VERHOEFF_P[i % 8][ord(ch) - 48]]
|
|
235
|
+
return checksum == 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def validate_credit_card(text: str) -> bool:
|
|
239
|
+
digits = _digits_only(text)
|
|
240
|
+
if not 13 <= len(digits) <= 19:
|
|
241
|
+
return False
|
|
242
|
+
return luhn_ok(digits)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def validate_npi(text: str) -> bool:
|
|
246
|
+
# CMS: Luhn over the prefix 80840 + the 10-digit NPI.
|
|
247
|
+
digits = _digits_only(text)
|
|
248
|
+
if len(digits) != 10:
|
|
249
|
+
return False
|
|
250
|
+
return luhn_ok("80840" + digits)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def validate_sin_ca(text: str) -> bool:
|
|
254
|
+
digits = _digits_only(text)
|
|
255
|
+
if len(digits) != 9:
|
|
256
|
+
return False
|
|
257
|
+
return luhn_ok(digits)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def validate_iban(text: str) -> bool:
|
|
261
|
+
compact = _alnum_upper(text)
|
|
262
|
+
if len(compact) < 5 or not compact[:2].isalpha() or not compact[2:4].isdigit():
|
|
263
|
+
return False
|
|
264
|
+
expected = _IBAN_LENGTHS.get(compact[:2])
|
|
265
|
+
if expected is None or len(compact) != expected:
|
|
266
|
+
return False
|
|
267
|
+
rearranged = compact[4:] + compact[:4]
|
|
268
|
+
numeric = []
|
|
269
|
+
for ch in rearranged:
|
|
270
|
+
if ch.isdigit():
|
|
271
|
+
numeric.append(ch)
|
|
272
|
+
else:
|
|
273
|
+
numeric.append(str(ord(ch) - 55)) # A=10 ... Z=35
|
|
274
|
+
return int("".join(numeric)) % 97 == 1
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def validate_vin(text: str) -> bool:
|
|
278
|
+
vin = _alnum_upper(text)
|
|
279
|
+
if len(vin) != 17:
|
|
280
|
+
return False
|
|
281
|
+
total = 0
|
|
282
|
+
for i, ch in enumerate(vin):
|
|
283
|
+
if ch.isdigit():
|
|
284
|
+
value = ord(ch) - 48
|
|
285
|
+
else:
|
|
286
|
+
value = _VIN_TRANSLIT.get(ch)
|
|
287
|
+
if value is None:
|
|
288
|
+
return False
|
|
289
|
+
total += value * _VIN_WEIGHTS[i]
|
|
290
|
+
remainder = total % 11
|
|
291
|
+
expected = "X" if remainder == 10 else str(remainder)
|
|
292
|
+
return vin[8] == expected
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def validate_dni_es(text: str) -> bool:
|
|
296
|
+
compact = _alnum_upper(text)
|
|
297
|
+
if len(compact) != 9 or not compact[:8].isdigit() or not compact[8].isalpha():
|
|
298
|
+
return False
|
|
299
|
+
return compact[8] == _DNI_LETTERS[int(compact[:8]) % 23]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def validate_nie_es(text: str) -> bool:
|
|
303
|
+
compact = _alnum_upper(text)
|
|
304
|
+
if len(compact) != 9 or compact[0] not in _NIE_PREFIX:
|
|
305
|
+
return False
|
|
306
|
+
return validate_dni_es(_NIE_PREFIX[compact[0]] + compact[1:])
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def validate_resident_id_cn(text: str) -> bool:
|
|
310
|
+
compact = _alnum_upper(text)
|
|
311
|
+
if len(compact) != 18 or not compact[:17].isdigit():
|
|
312
|
+
return False
|
|
313
|
+
total = sum((ord(compact[i]) - 48) * _CN_WEIGHTS[i] for i in range(17))
|
|
314
|
+
return compact[17] == _CN_CHECK[total % 11]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def validate_aadhaar_in(text: str) -> bool:
|
|
318
|
+
digits = _digits_only(text)
|
|
319
|
+
if len(digits) != 12 or digits[0] in "01":
|
|
320
|
+
return False
|
|
321
|
+
return verhoeff_ok(digits)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def validate_cpf_br(text: str) -> bool:
|
|
325
|
+
digits = _digits_only(text)
|
|
326
|
+
if len(digits) != 11 or len(set(digits)) == 1:
|
|
327
|
+
return False
|
|
328
|
+
|
|
329
|
+
def _cpf_digit(body: str, start_weight: int) -> str:
|
|
330
|
+
total = sum((ord(ch) - 48) * (start_weight - i) for i, ch in enumerate(body))
|
|
331
|
+
remainder = total % 11
|
|
332
|
+
return "0" if remainder < 2 else str(11 - remainder)
|
|
333
|
+
|
|
334
|
+
if digits[9] != _cpf_digit(digits[:9], 10):
|
|
335
|
+
return False
|
|
336
|
+
return digits[10] == _cpf_digit(digits[:10], 11)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def validate_codice_fiscale_it(text: str) -> bool:
|
|
340
|
+
compact = _alnum_upper(text)
|
|
341
|
+
if len(compact) != 16:
|
|
342
|
+
return False
|
|
343
|
+
total = 0
|
|
344
|
+
for i, ch in enumerate(compact[:15]):
|
|
345
|
+
table = _CF_ODD if i % 2 == 0 else _CF_EVEN
|
|
346
|
+
value = table.get(ch)
|
|
347
|
+
if value is None:
|
|
348
|
+
return False
|
|
349
|
+
total += value
|
|
350
|
+
return compact[15] == chr(ord("A") + (total % 26))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def validate_pesel_pl(text: str) -> bool:
|
|
354
|
+
digits = _digits_only(text)
|
|
355
|
+
if len(digits) != 11:
|
|
356
|
+
return False
|
|
357
|
+
weights = (1, 3, 7, 9, 1, 3, 7, 9, 1, 3)
|
|
358
|
+
total = sum((ord(digits[i]) - 48) * weights[i] for i in range(10))
|
|
359
|
+
check = (10 - (total % 10)) % 10
|
|
360
|
+
return digits[10] == str(check)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def validate_rtn_us(text: str) -> bool:
|
|
364
|
+
"""ABA routing number: weights 3, 7, 1 and a zero mod-10 total."""
|
|
365
|
+
digits = _digits_only(text)
|
|
366
|
+
if len(digits) != 9:
|
|
367
|
+
return False
|
|
368
|
+
prefix = int(digits[:2])
|
|
369
|
+
if not (prefix <= 12 or 21 <= prefix <= 32 or 61 <= prefix <= 72 or prefix == 80):
|
|
370
|
+
return False
|
|
371
|
+
weights = (3, 7, 1, 3, 7, 1, 3, 7, 1)
|
|
372
|
+
total = sum((ord(digits[i]) - 48) * weights[i] for i in range(9))
|
|
373
|
+
return total % 10 == 0
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def validate_cusip(text: str) -> bool:
|
|
377
|
+
"""CUSIP Modulus 10 double-add-double check digit (CGS / ANSI X9.6)."""
|
|
378
|
+
compact = _alnum_upper(text)
|
|
379
|
+
if len(compact) != 9 or not compact[8].isdigit():
|
|
380
|
+
return False
|
|
381
|
+
total = 0
|
|
382
|
+
for i, ch in enumerate(compact[:8]):
|
|
383
|
+
if ch.isdigit():
|
|
384
|
+
value = ord(ch) - 48
|
|
385
|
+
elif "A" <= ch <= "Z":
|
|
386
|
+
value = ord(ch) - ord("A") + 10
|
|
387
|
+
else:
|
|
388
|
+
return False
|
|
389
|
+
if i % 2 == 1:
|
|
390
|
+
value *= 2
|
|
391
|
+
total += value // 10 + value % 10
|
|
392
|
+
check = (10 - (total % 10)) % 10
|
|
393
|
+
return compact[8] == str(check)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def validate_phn_bc(text: str) -> bool:
|
|
397
|
+
"""BC Personal Health Number: leading 9 and the Teleplan MOD-11 digit."""
|
|
398
|
+
digits = _digits_only(text)
|
|
399
|
+
if len(digits) != 10 or digits[0] != "9":
|
|
400
|
+
return False
|
|
401
|
+
weights = (2, 4, 8, 5, 10, 9, 7, 3)
|
|
402
|
+
total = sum((ord(digits[i + 1]) - 48) * weights[i] for i in range(8))
|
|
403
|
+
remainder = total % 11
|
|
404
|
+
expected = 0 if remainder == 0 else 11 - remainder
|
|
405
|
+
if expected == 10:
|
|
406
|
+
return False
|
|
407
|
+
return digits[9] == str(expected)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def validate_clabe_mx(text: str) -> bool:
|
|
411
|
+
"""CLABE control digit: cyclic weights 3, 7, 1 on the first 17 digits."""
|
|
412
|
+
digits = _digits_only(text)
|
|
413
|
+
if len(digits) != 18:
|
|
414
|
+
return False
|
|
415
|
+
weights = (3, 7, 1)
|
|
416
|
+
total = 0
|
|
417
|
+
for i, ch in enumerate(digits[:17]):
|
|
418
|
+
total += ((ord(ch) - 48) * weights[i % 3]) % 10
|
|
419
|
+
check = (10 - (total % 10)) % 10
|
|
420
|
+
return digits[17] == str(check)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
ValidatorFn = Callable[[str], bool]
|
|
424
|
+
|
|
425
|
+
# Keys must match entity TYPEs emitted by the regex stage (upper-case).
|
|
426
|
+
CHECKSUM_VALIDATORS: Dict[str, ValidatorFn] = {
|
|
427
|
+
"CREDIT_CARD": validate_credit_card,
|
|
428
|
+
"MEDICAL_NPI_US": validate_npi,
|
|
429
|
+
"SIN_CA": validate_sin_ca,
|
|
430
|
+
"IBAN": validate_iban,
|
|
431
|
+
"VIN": validate_vin,
|
|
432
|
+
"DNI_ES": validate_dni_es,
|
|
433
|
+
"NIE_ES": validate_nie_es,
|
|
434
|
+
"RESIDENT_ID_CN": validate_resident_id_cn,
|
|
435
|
+
"AADHAAR_IN": validate_aadhaar_in,
|
|
436
|
+
"CPF_BR": validate_cpf_br,
|
|
437
|
+
"CODICE_FISCALE_IT": validate_codice_fiscale_it,
|
|
438
|
+
"PESEL_PL": validate_pesel_pl,
|
|
439
|
+
"RTN_US": validate_rtn_us,
|
|
440
|
+
"CUSIP_NNA": validate_cusip,
|
|
441
|
+
"PHN_BC_CA": validate_phn_bc,
|
|
442
|
+
"CLABE_MX": validate_clabe_mx,
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
# These shapes are common digit runs. A failed check is dropped instead of
|
|
446
|
+
# kept as TYPE_LIKE, which is what the other validators do.
|
|
447
|
+
STRICT_CHECKSUMS = frozenset(
|
|
448
|
+
{
|
|
449
|
+
"RTN_US",
|
|
450
|
+
"CUSIP_NNA",
|
|
451
|
+
"PHN_BC_CA",
|
|
452
|
+
"CLABE_MX",
|
|
453
|
+
}
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def has_checksum(entity_type: str) -> bool:
|
|
458
|
+
"""Return True if this type has a registered extra-digit check."""
|
|
459
|
+
return entity_type.upper() in CHECKSUM_VALIDATORS
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def strict_checksum(entity_type: str) -> bool:
|
|
463
|
+
"""Return True when a failed check should drop the hit."""
|
|
464
|
+
return entity_type.upper() in STRICT_CHECKSUMS
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def passes_checksum(entity_type: str, text: str) -> bool:
|
|
468
|
+
"""Return True if ``text`` has no check, or if its check succeeds."""
|
|
469
|
+
validator = CHECKSUM_VALIDATORS.get(entity_type.upper())
|
|
470
|
+
if validator is None:
|
|
471
|
+
return True
|
|
472
|
+
return validator(text)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Bundled country plugins. Universal patterns always load."""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""National-ID patterns for CA."""
|
|
2
|
+
|
|
3
|
+
CODE = "CA"
|
|
4
|
+
|
|
5
|
+
PATTERNS = {
|
|
6
|
+
"SIN_CA": "\\b\\d{3}-\\d{3}-\\d{3}\\b",
|
|
7
|
+
"DRIVERS_LICENSE_CA": "\\b[A-Z]\\d{4,5}-\\d{5,6}-\\d{5}\\b|\\b[A-Z0-9]{5,15}\\b",
|
|
8
|
+
# CRA program account: 9-digit BN + program letters + 4-digit reference.
|
|
9
|
+
"PROGRAM_ACCOUNT_CA": "\\b\\d{9}\\s?(?:RT|RP|RC|RM|RZ|RR|RG)\\s?\\d{4}\\b",
|
|
10
|
+
"DIN_CA": "\\bDIN\\s?\\d{8}\\b",
|
|
11
|
+
"NPN_CA": "\\bNPN\\s?\\d{8}\\b",
|
|
12
|
+
"DIN_HM_CA": "\\bDIN-HM\\s?\\d{8}\\b",
|
|
13
|
+
# IRCC prints the 10-digit UCI as NN-NNNN-NNNN.
|
|
14
|
+
"UCI_CA": "\\b\\d{2}-\\d{4}-\\d{4}\\b",
|
|
15
|
+
}
|