stdnum-centroamerica 0.1.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.
- stdnum_centroamerica/__init__.py +67 -0
- stdnum_centroamerica/_base.py +166 -0
- stdnum_centroamerica/_data/__init__.py +1 -0
- stdnum_centroamerica/_data/honduras.py +43 -0
- stdnum_centroamerica/_data/nicaragua.py +41 -0
- stdnum_centroamerica/_dates.py +16 -0
- stdnum_centroamerica/_random.py +28 -0
- stdnum_centroamerica/_registry.py +184 -0
- stdnum_centroamerica/_types.py +111 -0
- stdnum_centroamerica/contrib/__init__.py +9 -0
- stdnum_centroamerica/contrib/_common.py +41 -0
- stdnum_centroamerica/contrib/django.py +129 -0
- stdnum_centroamerica/contrib/pydantic.py +91 -0
- stdnum_centroamerica/contrib/wtforms.py +76 -0
- stdnum_centroamerica/cr/__init__.py +64 -0
- stdnum_centroamerica/exceptions.py +20 -0
- stdnum_centroamerica/gt/__init__.py +103 -0
- stdnum_centroamerica/hn/__init__.py +102 -0
- stdnum_centroamerica/mrz/__init__.py +408 -0
- stdnum_centroamerica/ni/__init__.py +204 -0
- stdnum_centroamerica/pa/__init__.py +176 -0
- stdnum_centroamerica/py.typed +0 -0
- stdnum_centroamerica/sv/__init__.py +7 -0
- stdnum_centroamerica/sv/dui.py +37 -0
- stdnum_centroamerica/sv/nit.py +64 -0
- stdnum_centroamerica/sv/nrc.py +28 -0
- stdnum_centroamerica-0.1.0.dist-info/METADATA +290 -0
- stdnum_centroamerica-0.1.0.dist-info/RECORD +30 -0
- stdnum_centroamerica-0.1.0.dist-info/WHEEL +4 -0
- stdnum_centroamerica-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Validate, format and compact Central American identity and tax numbers.
|
|
2
|
+
|
|
3
|
+
Basic usage::
|
|
4
|
+
|
|
5
|
+
>>> import stdnum_centroamerica as sca
|
|
6
|
+
>>> sca.is_valid("sv.dui", "12345678-4")
|
|
7
|
+
True
|
|
8
|
+
>>> sca.validate("sv.dui", "12345678-5").error
|
|
9
|
+
<ErrorCode.CHECKSUM: 'checksum'>
|
|
10
|
+
>>> sca.make("ni.cedula").format("0010101900001n")
|
|
11
|
+
'001-010190-0001N'
|
|
12
|
+
|
|
13
|
+
Each document has a ``country.document`` key; :func:`keys` lists them. The package has no
|
|
14
|
+
dependencies, performs no I/O and never stores or logs the numbers.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from stdnum_centroamerica._base import MAX_INPUT_LENGTH, BaseDocument
|
|
18
|
+
from stdnum_centroamerica._registry import (
|
|
19
|
+
describe,
|
|
20
|
+
generate,
|
|
21
|
+
is_valid,
|
|
22
|
+
keys,
|
|
23
|
+
make,
|
|
24
|
+
parse,
|
|
25
|
+
register,
|
|
26
|
+
validate,
|
|
27
|
+
)
|
|
28
|
+
from stdnum_centroamerica._types import (
|
|
29
|
+
Document,
|
|
30
|
+
DocumentInfo,
|
|
31
|
+
ErrorCode,
|
|
32
|
+
Level,
|
|
33
|
+
SupportsGenerate,
|
|
34
|
+
SupportsParse,
|
|
35
|
+
ValidationResult,
|
|
36
|
+
)
|
|
37
|
+
from stdnum_centroamerica.exceptions import (
|
|
38
|
+
StdnumError,
|
|
39
|
+
UnknownDocumentError,
|
|
40
|
+
UnsupportedOperationError,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__version__ = "0.1.0"
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"MAX_INPUT_LENGTH",
|
|
47
|
+
"BaseDocument",
|
|
48
|
+
"Document",
|
|
49
|
+
"DocumentInfo",
|
|
50
|
+
"ErrorCode",
|
|
51
|
+
"Level",
|
|
52
|
+
"StdnumError",
|
|
53
|
+
"SupportsGenerate",
|
|
54
|
+
"SupportsParse",
|
|
55
|
+
"UnknownDocumentError",
|
|
56
|
+
"UnsupportedOperationError",
|
|
57
|
+
"ValidationResult",
|
|
58
|
+
"__version__",
|
|
59
|
+
"describe",
|
|
60
|
+
"generate",
|
|
61
|
+
"is_valid",
|
|
62
|
+
"keys",
|
|
63
|
+
"make",
|
|
64
|
+
"parse",
|
|
65
|
+
"register",
|
|
66
|
+
"validate",
|
|
67
|
+
]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Base class for documents and shared helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import string
|
|
7
|
+
from collections.abc import Iterable, Mapping
|
|
8
|
+
from typing import Any, ClassVar, NoReturn
|
|
9
|
+
|
|
10
|
+
from stdnum_centroamerica._types import ErrorCode, ValidationResult
|
|
11
|
+
|
|
12
|
+
__all__ = ["DIGITS", "MAX_INPUT_LENGTH", "BaseDocument", "ensure_str", "weighted_sum"]
|
|
13
|
+
|
|
14
|
+
MAX_INPUT_LENGTH = 256
|
|
15
|
+
"""Maximum accepted input length. A TD1 MRZ (90 characters plus line breaks) fits easily.
|
|
16
|
+
Longer inputs are rejected as ``FORMAT`` without running any regular expression."""
|
|
17
|
+
|
|
18
|
+
DIGITS = re.compile(r"[0-9]+", re.ASCII)
|
|
19
|
+
"""One or more ASCII digits. ``str.isdigit()`` is not enough: it accepts non-ASCII digits."""
|
|
20
|
+
|
|
21
|
+
# Strips ASCII whitespace and hyphens and upper-cases ASCII letters only: str.upper() turns
|
|
22
|
+
# "ß" into "SS" and the Turkish dotless i into "I", which could make invalid input valid.
|
|
23
|
+
_COMPACT_TABLE = str.maketrans(string.ascii_lowercase, string.ascii_uppercase, " \t\n\r\f\v-")
|
|
24
|
+
|
|
25
|
+
_RESULTS: Mapping[ErrorCode | None, ValidationResult] = {
|
|
26
|
+
None: ValidationResult(),
|
|
27
|
+
**{code: ValidationResult(code) for code in ErrorCode},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ensure_str(value: object) -> str:
|
|
32
|
+
"""Return ``value`` if it is a ``str``; otherwise raise ``TypeError`` without echoing it."""
|
|
33
|
+
if not isinstance(value, str):
|
|
34
|
+
_raise_type_error(value)
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _raise_type_error(value: object) -> NoReturn:
|
|
39
|
+
message = f"expected str, got {type(value).__name__}"
|
|
40
|
+
raise TypeError(message)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _rebuild(cls: type[BaseDocument], options: dict[str, Any]) -> BaseDocument:
|
|
44
|
+
return cls(**options)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def weighted_sum(digits: str, weights: Iterable[int]) -> int:
|
|
48
|
+
"""Sum of each digit times its weight, left to right (one weight per digit)."""
|
|
49
|
+
return sum(int(digits[i]) * weight for i, weight in enumerate(weights))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BaseDocument:
|
|
53
|
+
"""Base class of the bundled documents.
|
|
54
|
+
|
|
55
|
+
Subclasses define ``_PATTERN`` (the regex the prepared form must fully match) and, when
|
|
56
|
+
needed, ``_check_content()`` with additional rules. Documents that cannot be expressed
|
|
57
|
+
with a single pattern override ``_check()``. ``_prepare()`` changes the form that is
|
|
58
|
+
validated (the compact form by default; Panama keeps the hyphens).
|
|
59
|
+
|
|
60
|
+
Instances are immutable, so they can be shared across threads.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
__slots__ = ()
|
|
64
|
+
|
|
65
|
+
_PATTERN: ClassVar[re.Pattern[str] | None] = None
|
|
66
|
+
|
|
67
|
+
def __setattr__(self, name: str, value: object) -> NoReturn:
|
|
68
|
+
message = f"{type(self).__name__} is immutable"
|
|
69
|
+
raise AttributeError(message)
|
|
70
|
+
|
|
71
|
+
def __delattr__(self, name: str) -> NoReturn:
|
|
72
|
+
message = f"{type(self).__name__} is immutable"
|
|
73
|
+
raise AttributeError(message)
|
|
74
|
+
|
|
75
|
+
def _init_attr(self, name: str, value: object) -> None:
|
|
76
|
+
"""Set an attribute from ``__init__``, bypassing the immutability guard."""
|
|
77
|
+
object.__setattr__(self, name, value)
|
|
78
|
+
|
|
79
|
+
def _options(self) -> Mapping[str, Any]:
|
|
80
|
+
"""Constructor options, used by ``repr()``, equality and pickling."""
|
|
81
|
+
return {}
|
|
82
|
+
|
|
83
|
+
def __repr__(self) -> str:
|
|
84
|
+
options = ", ".join(f"{name}={value!r}" for name, value in self._options().items())
|
|
85
|
+
return f"{type(self).__name__}({options})"
|
|
86
|
+
|
|
87
|
+
def __eq__(self, other: object) -> bool:
|
|
88
|
+
if not isinstance(other, BaseDocument) or type(other) is not type(self):
|
|
89
|
+
return NotImplemented
|
|
90
|
+
return dict(self._options()) == dict(other._options())
|
|
91
|
+
|
|
92
|
+
def __hash__(self) -> int:
|
|
93
|
+
return hash((type(self), tuple(sorted(self._options().items()))))
|
|
94
|
+
|
|
95
|
+
def __reduce__(self) -> tuple[Any, ...]:
|
|
96
|
+
# pickle and copy.deepcopy rebuild through the constructor, not through __setattr__.
|
|
97
|
+
return (_rebuild, (type(self), dict(self._options())))
|
|
98
|
+
|
|
99
|
+
# -- public API --------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def is_valid(self, number: str) -> bool:
|
|
102
|
+
"""Return ``True`` if the number is valid."""
|
|
103
|
+
return self._error(number) is None
|
|
104
|
+
|
|
105
|
+
def validate(self, number: str) -> ValidationResult:
|
|
106
|
+
"""Return the result, with the failure reason (``ErrorCode``) if the number is invalid."""
|
|
107
|
+
return _RESULTS[self._error(number)]
|
|
108
|
+
|
|
109
|
+
def compact(self, number: str) -> str:
|
|
110
|
+
"""Return the number without whitespace or hyphens, upper-cased."""
|
|
111
|
+
return ensure_str(number).translate(_COMPACT_TABLE)
|
|
112
|
+
|
|
113
|
+
def format(self, number: str) -> str:
|
|
114
|
+
"""Return the display form. Without a grouping of its own, it is the compact form."""
|
|
115
|
+
return self.compact(number)
|
|
116
|
+
|
|
117
|
+
# -- validation --------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def _error(self, number: str) -> ErrorCode | None:
|
|
120
|
+
number = ensure_str(number)
|
|
121
|
+
if len(number) > MAX_INPUT_LENGTH or not number.isascii():
|
|
122
|
+
return ErrorCode.FORMAT
|
|
123
|
+
return self._check(self._prepare(number))
|
|
124
|
+
|
|
125
|
+
def _validated(self, number: str) -> str | None:
|
|
126
|
+
"""Return the validated form of the number, or ``None``: the basis of ``parse()``."""
|
|
127
|
+
if self._error(number) is not None:
|
|
128
|
+
return None
|
|
129
|
+
return self._prepare(number)
|
|
130
|
+
|
|
131
|
+
def _prepare(self, number: str) -> str:
|
|
132
|
+
return self.compact(number)
|
|
133
|
+
|
|
134
|
+
def _check(self, prepared: str) -> ErrorCode | None:
|
|
135
|
+
pattern = self._PATTERN
|
|
136
|
+
if pattern is None:
|
|
137
|
+
raise NotImplementedError
|
|
138
|
+
if pattern.fullmatch(prepared) is None:
|
|
139
|
+
return ErrorCode.FORMAT
|
|
140
|
+
return self._check_content(prepared)
|
|
141
|
+
|
|
142
|
+
def _check_content(self, prepared: str) -> ErrorCode | None:
|
|
143
|
+
"""Additional rules for a number that already matches ``_PATTERN``."""
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
# -- formatting --------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def _group(self, number: str, sizes: Iterable[int], separator: str = "-") -> str:
|
|
149
|
+
"""Group the compact number; if the length does not match, return it compact."""
|
|
150
|
+
compact = self.compact(number)
|
|
151
|
+
sizes = tuple(sizes)
|
|
152
|
+
if len(compact) != sum(sizes):
|
|
153
|
+
return compact
|
|
154
|
+
parts = []
|
|
155
|
+
offset = 0
|
|
156
|
+
for size in sizes:
|
|
157
|
+
parts.append(compact[offset : offset + size])
|
|
158
|
+
offset += size
|
|
159
|
+
return separator.join(parts)
|
|
160
|
+
|
|
161
|
+
def _group_last(self, number: str) -> str:
|
|
162
|
+
"""Separate the last character (the check digit) from the rest with a hyphen."""
|
|
163
|
+
compact = self.compact(number)
|
|
164
|
+
if len(compact) < 2:
|
|
165
|
+
return compact
|
|
166
|
+
return f"{compact[:-1]}-{compact[-1]}"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Reference data tables. They are immutable (``frozenset``, ``tuple``, ``MappingProxyType``)."""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Administrative divisions of Honduras: 18 departments, 298 municipalities.
|
|
2
|
+
|
|
3
|
+
The 4-digit code is department (2) + municipality (2, sequential 01..n).
|
|
4
|
+
|
|
5
|
+
Counts checked on 2026-10-19 against the INE (2013 census, municipal volumes): the
|
|
6
|
+
"División política y códigos municipales" lists of each department and the numbering of the
|
|
7
|
+
298 volumes (e.g. Villeda Morales 09-06, San Francisco de Opalaca 10-17).
|
|
8
|
+
Legal basis of the code: RNP Law (Decree 62-2004), arts. 99-100.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from types import MappingProxyType
|
|
15
|
+
|
|
16
|
+
MUNICIPALITIES_BY_DEPARTMENT: Mapping[int, int] = MappingProxyType(
|
|
17
|
+
{
|
|
18
|
+
1: 8, # Atlántida
|
|
19
|
+
2: 10, # Colón
|
|
20
|
+
3: 21, # Comayagua
|
|
21
|
+
4: 23, # Copán
|
|
22
|
+
5: 12, # Cortés
|
|
23
|
+
6: 16, # Choluteca
|
|
24
|
+
7: 19, # El Paraíso
|
|
25
|
+
8: 28, # Francisco Morazán
|
|
26
|
+
9: 6, # Gracias a Dios
|
|
27
|
+
10: 17, # Intibucá
|
|
28
|
+
11: 4, # Islas de la Bahía
|
|
29
|
+
12: 19, # La Paz
|
|
30
|
+
13: 28, # Lempira
|
|
31
|
+
14: 16, # Ocotepeque
|
|
32
|
+
15: 23, # Olancho
|
|
33
|
+
16: 28, # Santa Bárbara
|
|
34
|
+
17: 9, # Valle
|
|
35
|
+
18: 11, # Yoro
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
"""Department => number of municipalities."""
|
|
39
|
+
|
|
40
|
+
SPECIAL_CODES: frozenset[str] = frozenset({"0880", "0890"})
|
|
41
|
+
"""Official special codes (RNP Law Regulation, Agreement 73): 0880 dual nationality
|
|
42
|
+
(art. 153) and 0890 naturalized citizens (art. 150). The RNP may create others "in duly
|
|
43
|
+
justified cases" (Law, art. 100)."""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Municipality codes of the Nicaraguan cédula (first 3 digits).
|
|
2
|
+
|
|
3
|
+
Source: third-party list (gist "Validacion Cedula Nicaraguense", reproduced by
|
|
4
|
+
bnorvinjosue/cedula-nicaragua-validator); the CSE does not publish the table. Checked on
|
|
5
|
+
2026-10-19. Against the official INIDE table (153 municipalities), 149 of the 152 codes fall
|
|
6
|
+
in the right department. Exceptions and gaps:
|
|
7
|
+
|
|
8
|
+
- Mulukukú (North Caribbean Coast, created in 2005) is missing;
|
|
9
|
+
- 492 appears as Wiwilí in the Nueva Segovia block; it is Wiwilí de Jinotega;
|
|
10
|
+
- 454 Waslala is in the Matagalpa block but belongs to the North Caribbean Coast;
|
|
11
|
+
- the 40-code blocks have unused gaps (e.g. 282 in León).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
MUNICIPALITY_CODES: tuple[int, ...] = (
|
|
17
|
+
1, 2, 3, 4, 5, 6, 7, 8, 9, 41, 42, 43,
|
|
18
|
+
44, 45, 46, 47, 48, 81, 82, 83, 84, 85, 86, 87,
|
|
19
|
+
88, 89, 90, 91, 92, 93, 121, 122, 123, 124, 125, 126,
|
|
20
|
+
127, 128, 129, 130, 161, 162, 163, 164, 165, 166, 201, 202,
|
|
21
|
+
203, 204, 241, 242, 243, 244, 245, 246, 247, 281, 283, 284,
|
|
22
|
+
285, 286, 287, 288, 289, 290, 291, 321, 322, 323, 324, 325,
|
|
23
|
+
326, 327, 328, 329, 361, 362, 363, 364, 365, 366, 401, 402,
|
|
24
|
+
403, 404, 405, 406, 407, 408, 409, 441, 442, 443, 444, 445,
|
|
25
|
+
446, 447, 448, 449, 450, 451, 452, 453, 454, 481, 482, 483,
|
|
26
|
+
484, 485, 486, 487, 488, 489, 490, 491, 492, 493, 521, 522,
|
|
27
|
+
523, 524, 525, 526, 561, 562, 563, 564, 565, 566, 567, 568,
|
|
28
|
+
569, 570, 601, 602, 603, 604, 605, 606, 607, 608, 610, 611,
|
|
29
|
+
612, 615, 616, 619, 624, 626, 627, 628,
|
|
30
|
+
) # fmt: skip
|
|
31
|
+
"""Known codes, in order."""
|
|
32
|
+
|
|
33
|
+
KNOWN_CODES: frozenset[int] = frozenset(MUNICIPALITY_CODES)
|
|
34
|
+
|
|
35
|
+
FOREIGN_CODE = 888
|
|
36
|
+
"""Code for people born abroad. Single source (README of
|
|
37
|
+
bnorvinjosue/cedula-nicaragua-validator, April 2026, citing no regulation); it does not
|
|
38
|
+
appear in Law 152 or in the CSE registrar's manual."""
|
|
39
|
+
|
|
40
|
+
MAX_CODE = 640
|
|
41
|
+
"""Departments use blocks of 40 codes starting at 001; the last one ends at 640."""
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Date helpers: calendar validation and a "today" that tests can replace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import calendar
|
|
6
|
+
import datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_valid_date(year: int, month: int, day: int) -> bool:
|
|
10
|
+
"""Return ``True`` if the date exists. With ``year=2000`` it accepts February 29."""
|
|
11
|
+
return 1 <= month <= 12 and 1 <= day <= calendar.monthrange(year, month)[1]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def today() -> datetime.date:
|
|
15
|
+
"""Return today's local date. Tests replace it with ``monkeypatch``."""
|
|
16
|
+
return datetime.date.today()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Randomness for ``generate()``.
|
|
2
|
+
|
|
3
|
+
Uses :mod:`secrets` even though the numbers are synthetic and meant for tests: it costs the
|
|
4
|
+
same as :mod:`random` and keeps security scanners quiet.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import secrets
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import TypeVar
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def rand_int(low: int, high: int) -> int:
|
|
17
|
+
"""Return a random integer between ``low`` and ``high``, both inclusive."""
|
|
18
|
+
return low + secrets.randbelow(high - low + 1)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def choice(items: Sequence[T]) -> T:
|
|
22
|
+
"""Return a random element of a non-empty sequence."""
|
|
23
|
+
return secrets.choice(items)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def random_digits(length: int, *, non_zero_first: bool = False) -> str:
|
|
27
|
+
"""Return ``length`` random digits; ``non_zero_first`` avoids a leading zero."""
|
|
28
|
+
return "".join(str(rand_int(1 if i == 0 and non_zero_first else 0, 9)) for i in range(length))
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Document catalog and high-level functions (``is_valid``, ``validate``, ...)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import functools
|
|
7
|
+
import re
|
|
8
|
+
import threading
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from types import MappingProxyType
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from stdnum_centroamerica import cr, gt, hn, mrz, ni, pa, sv
|
|
14
|
+
from stdnum_centroamerica._types import (
|
|
15
|
+
Document,
|
|
16
|
+
DocumentInfo,
|
|
17
|
+
Level,
|
|
18
|
+
SupportsGenerate,
|
|
19
|
+
SupportsParse,
|
|
20
|
+
ValidationResult,
|
|
21
|
+
)
|
|
22
|
+
from stdnum_centroamerica.exceptions import UnknownDocumentError, UnsupportedOperationError
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"describe",
|
|
26
|
+
"generate",
|
|
27
|
+
"is_valid",
|
|
28
|
+
"keys",
|
|
29
|
+
"make",
|
|
30
|
+
"parse",
|
|
31
|
+
"register",
|
|
32
|
+
"validate",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_C, _F = Level.COMPLETE, Level.FORMAT
|
|
36
|
+
|
|
37
|
+
_CATALOG: Mapping[str, DocumentInfo] = MappingProxyType(
|
|
38
|
+
{
|
|
39
|
+
info.key: info
|
|
40
|
+
for info in (
|
|
41
|
+
DocumentInfo("gt.nit", gt.Nit, "GT", "NIT", _C),
|
|
42
|
+
DocumentInfo("gt.dpi", gt.Dpi, "GT", "DPI (CUI)", _C),
|
|
43
|
+
DocumentInfo("sv.dui", sv.Dui, "SV", "DUI", _C),
|
|
44
|
+
DocumentInfo("sv.nit", sv.Nit, "SV", "NIT", _C),
|
|
45
|
+
DocumentInfo("sv.nrc", sv.Nrc, "SV", "NRC", _F),
|
|
46
|
+
DocumentInfo("hn.identidad", hn.Identidad, "HN", "Número de identidad", _F),
|
|
47
|
+
DocumentInfo("hn.rtn", hn.Rtn, "HN", "RTN", _F),
|
|
48
|
+
DocumentInfo("ni.cedula", ni.Cedula, "NI", "Cédula", _C),
|
|
49
|
+
DocumentInfo("ni.ruc", ni.Ruc, "NI", "RUC", _F),
|
|
50
|
+
DocumentInfo("cr.cedula_fisica", cr.CedulaFisica, "CR", "Cédula física", _F),
|
|
51
|
+
DocumentInfo("cr.cedula_juridica", cr.CedulaJuridica, "CR", "Cédula jurídica", _F),
|
|
52
|
+
DocumentInfo("cr.dimex", cr.Dimex, "CR", "DIMEX", _F),
|
|
53
|
+
DocumentInfo("cr.nite", cr.Nite, "CR", "NITE", _F),
|
|
54
|
+
DocumentInfo("pa.cedula", pa.Cedula, "PA", "Cédula", _F),
|
|
55
|
+
DocumentInfo("pa.ruc", pa.Ruc, "PA", "RUC", _F),
|
|
56
|
+
DocumentInfo("mrz.td3", mrz.Td3, "ICAO", "Passport MRZ (TD3)", _C),
|
|
57
|
+
DocumentInfo("mrz.td1", mrz.Td1, "ICAO", "ID card MRZ (TD1)", _C),
|
|
58
|
+
DocumentInfo("mrz.td2", mrz.Td2, "ICAO", "ID document MRZ (TD2)", _C),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
_KEY = re.compile(r"[a-z0-9]+\.[a-z0-9_]+", re.ASCII)
|
|
64
|
+
_custom: dict[str, type[Document]] = {}
|
|
65
|
+
_lock = threading.Lock()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _resolve(key: str) -> type[Document]:
|
|
69
|
+
value: object = key # runtime check: callers may not be type-checked
|
|
70
|
+
if not isinstance(value, str):
|
|
71
|
+
message = f"key must be a str, got {type(value).__name__}"
|
|
72
|
+
raise TypeError(message)
|
|
73
|
+
normalized = value.lower()
|
|
74
|
+
cls = _custom.get(normalized)
|
|
75
|
+
if cls is not None:
|
|
76
|
+
return cls
|
|
77
|
+
info = _CATALOG.get(normalized)
|
|
78
|
+
if info is not None:
|
|
79
|
+
return info.document_class
|
|
80
|
+
|
|
81
|
+
message = f"Unsupported document: {normalized!r}"
|
|
82
|
+
suggestions = difflib.get_close_matches(normalized, keys(), n=3)
|
|
83
|
+
if suggestions:
|
|
84
|
+
message += f". Did you mean {', '.join(map(repr, suggestions))}?"
|
|
85
|
+
raise UnknownDocumentError(message)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@functools.lru_cache(maxsize=256)
|
|
89
|
+
def _cached(cls: type[Document], options: tuple[tuple[str, Any], ...]) -> Document:
|
|
90
|
+
return cls(**dict(options))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def make(key: str, /, **options: Any) -> Document:
|
|
94
|
+
"""Return an instance of document ``key`` (e.g. ``"ni.cedula"``) built with ``options``.
|
|
95
|
+
|
|
96
|
+
Keys are case-insensitive. Instances are immutable and reused.
|
|
97
|
+
|
|
98
|
+
:raises UnknownDocumentError: if the key does not exist.
|
|
99
|
+
:raises TypeError: if the document does not accept an option.
|
|
100
|
+
"""
|
|
101
|
+
cls = _resolve(key)
|
|
102
|
+
items = tuple(sorted(options.items()))
|
|
103
|
+
try:
|
|
104
|
+
return _cached(cls, items)
|
|
105
|
+
except TypeError:
|
|
106
|
+
# Options that cannot be used as a cache key (or invalid ones: the constructor
|
|
107
|
+
# raises the same TypeError again).
|
|
108
|
+
return cls(**options)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def validate(key: str, number: str, /, **options: Any) -> ValidationResult:
|
|
112
|
+
"""Validate ``number`` and return the failure reason (``result.error``), if any."""
|
|
113
|
+
return make(key, **options).validate(number)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_valid(key: str, number: str, /, **options: Any) -> bool:
|
|
117
|
+
"""Return ``True`` if ``number`` is a valid ``key`` document."""
|
|
118
|
+
return make(key, **options).is_valid(number)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def generate(key: str, /, **options: Any) -> str:
|
|
122
|
+
"""Return a synthetic valid number, for tests and sample data.
|
|
123
|
+
|
|
124
|
+
It is random and may match a real number by chance: never use it as personal data.
|
|
125
|
+
|
|
126
|
+
:raises UnsupportedOperationError: if the document does not generate numbers.
|
|
127
|
+
"""
|
|
128
|
+
document = make(key, **options)
|
|
129
|
+
if not isinstance(document, SupportsGenerate):
|
|
130
|
+
message = f"Document {key.lower()!r} does not generate numbers"
|
|
131
|
+
raise UnsupportedOperationError(message)
|
|
132
|
+
return document.generate()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse(key: str, number: str, /, **options: Any) -> Any:
|
|
136
|
+
"""Return the components of a valid number, or ``None`` if it is invalid.
|
|
137
|
+
|
|
138
|
+
Available for ``ni.cedula``, ``hn.identidad``, ``gt.dpi``, ``pa.cedula`` and the MRZs.
|
|
139
|
+
The components are personal data and are not checked against any registry.
|
|
140
|
+
|
|
141
|
+
:raises UnsupportedOperationError: if the document has no components to parse.
|
|
142
|
+
"""
|
|
143
|
+
document = make(key, **options)
|
|
144
|
+
if not isinstance(document, SupportsParse):
|
|
145
|
+
message = f"Document {key.lower()!r} has no components to parse"
|
|
146
|
+
raise UnsupportedOperationError(message)
|
|
147
|
+
return document.parse(number)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def keys() -> tuple[str, ...]:
|
|
151
|
+
"""Return the available keys: first those added with :func:`register`, then bundled ones."""
|
|
152
|
+
custom = tuple(_custom)
|
|
153
|
+
return custom + tuple(key for key in _CATALOG if key not in _custom)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def describe() -> tuple[DocumentInfo, ...]:
|
|
157
|
+
"""Return the bundled documents with country, name and level. Registered ones are omitted."""
|
|
158
|
+
return tuple(_CATALOG.values())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def register(key: str, document_class: type[Document]) -> None:
|
|
162
|
+
"""Add or replace a document; it takes precedence over the bundled ones.
|
|
163
|
+
|
|
164
|
+
Call it at application startup, not per request.
|
|
165
|
+
|
|
166
|
+
:raises ValueError: if the key does not look like ``country.document``.
|
|
167
|
+
:raises TypeError: if the class does not implement the :class:`Document` protocol.
|
|
168
|
+
"""
|
|
169
|
+
raw_key: object = key
|
|
170
|
+
if not isinstance(raw_key, str) or _KEY.fullmatch(raw_key.lower()) is None:
|
|
171
|
+
message = "key must look like 'country.document' (a-z, 0-9 and _)"
|
|
172
|
+
raise ValueError(message)
|
|
173
|
+
candidate: object = document_class
|
|
174
|
+
if not isinstance(candidate, type) or not issubclass(candidate, Document):
|
|
175
|
+
message = f"{document_class!r} does not implement the Document protocol"
|
|
176
|
+
raise TypeError(message)
|
|
177
|
+
with _lock:
|
|
178
|
+
_custom[key.lower()] = document_class
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _unregister(key: str) -> None:
|
|
182
|
+
"""Remove a registered document. For tests only."""
|
|
183
|
+
with _lock:
|
|
184
|
+
_custom.pop(key.lower(), None)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Shared public types: error codes, levels, results and document protocols."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Protocol, TypeVar, runtime_checkable
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Document",
|
|
11
|
+
"DocumentInfo",
|
|
12
|
+
"ErrorCode",
|
|
13
|
+
"Level",
|
|
14
|
+
"SupportsGenerate",
|
|
15
|
+
"SupportsParse",
|
|
16
|
+
"ValidationResult",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
T_co = TypeVar("T_co", covariant=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ErrorCode(StrEnum):
|
|
23
|
+
"""Why a number is invalid.
|
|
24
|
+
|
|
25
|
+
Not named ``ValidationError`` to avoid clashing with ``pydantic.ValidationError`` and
|
|
26
|
+
``django.core.exceptions.ValidationError``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
FORMAT = "format"
|
|
30
|
+
"""Wrong length, characters or structure."""
|
|
31
|
+
|
|
32
|
+
COMPONENT = "component"
|
|
33
|
+
"""A component (date, department, municipality, prefix) is out of range."""
|
|
34
|
+
|
|
35
|
+
CHECKSUM = "checksum"
|
|
36
|
+
"""The check digit or check letter does not match."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Level(StrEnum):
|
|
40
|
+
"""How thoroughly a document is validated."""
|
|
41
|
+
|
|
42
|
+
COMPLETE = "complete"
|
|
43
|
+
"""Format plus check digit or check letter."""
|
|
44
|
+
|
|
45
|
+
FORMAT = "format"
|
|
46
|
+
"""Length, characters and basic ranges, without a check digit."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class ValidationResult:
|
|
51
|
+
"""Result of :meth:`Document.validate`: ``error`` is ``None`` when the number is valid."""
|
|
52
|
+
|
|
53
|
+
error: ErrorCode | None = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def is_valid(self) -> bool:
|
|
57
|
+
return self.error is None
|
|
58
|
+
|
|
59
|
+
def __bool__(self) -> bool:
|
|
60
|
+
return self.error is None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@runtime_checkable
|
|
64
|
+
class Document(Protocol):
|
|
65
|
+
"""Protocol every document implements: validate, format and compact."""
|
|
66
|
+
|
|
67
|
+
def is_valid(self, number: str) -> bool: ...
|
|
68
|
+
|
|
69
|
+
def validate(self, number: str) -> ValidationResult: ...
|
|
70
|
+
|
|
71
|
+
def format(self, number: str) -> str: ...
|
|
72
|
+
|
|
73
|
+
def compact(self, number: str) -> str: ...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@runtime_checkable
|
|
77
|
+
class SupportsGenerate(Protocol):
|
|
78
|
+
"""A document that can produce synthetic numbers that pass its own validation."""
|
|
79
|
+
|
|
80
|
+
def generate(self) -> str:
|
|
81
|
+
"""Return a random valid number. It may match a real one by chance: tests only."""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@runtime_checkable
|
|
86
|
+
class SupportsParse(Protocol[T_co]):
|
|
87
|
+
"""A document whose number encodes data (birth date, municipality, kind)."""
|
|
88
|
+
|
|
89
|
+
def parse(self, number: str) -> T_co | None:
|
|
90
|
+
"""Return the components of a valid number, or ``None``. They are personal data."""
|
|
91
|
+
...
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class DocumentInfo:
|
|
96
|
+
"""Description of a bundled document."""
|
|
97
|
+
|
|
98
|
+
key: str
|
|
99
|
+
"""``country.document`` key, e.g. ``gt.nit``."""
|
|
100
|
+
|
|
101
|
+
document_class: type[Document]
|
|
102
|
+
"""Class that implements the document."""
|
|
103
|
+
|
|
104
|
+
country: str
|
|
105
|
+
"""ISO 3166-1 alpha-2 code, or ``ICAO`` for international standards."""
|
|
106
|
+
|
|
107
|
+
name: str
|
|
108
|
+
"""Document name, e.g. ``NIT``."""
|
|
109
|
+
|
|
110
|
+
level: Level
|
|
111
|
+
"""How thoroughly it is validated."""
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Optional framework integrations.
|
|
2
|
+
|
|
3
|
+
Each submodule is imported separately and only when used; ``import stdnum_centroamerica``
|
|
4
|
+
never imports a framework.
|
|
5
|
+
|
|
6
|
+
- :mod:`stdnum_centroamerica.contrib.pydantic`: FastAPI, Pydantic v2, SQLModel, Litestar.
|
|
7
|
+
- :mod:`stdnum_centroamerica.contrib.django`: Django and Django REST Framework.
|
|
8
|
+
- :mod:`stdnum_centroamerica.contrib.wtforms`: WTForms and Flask-WTF.
|
|
9
|
+
"""
|