Skip to content
This repository was archived by the owner on Jan 8, 2025. It is now read-only.

Commit 0d3be1b

Browse files
authored
Fix #49 - implement Hungary Personal ID (#196)
1 parent 07c2cf4 commit 0d3be1b

File tree

3 files changed

+139
-0
lines changed

3 files changed

+139
-0
lines changed

idnumbers/nationalid/HUN.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import re
2+
from datetime import date
3+
from types import SimpleNamespace
4+
from typing import Optional, TypedDict, Tuple
5+
from .util import CHECK_DIGIT, weighted_modulus_digit, validate_regexp
6+
from .constant import Citizenship, Gender
7+
8+
9+
def normalize(id_number):
10+
"""strip out useless characters/whitespaces"""
11+
return re.sub(r'[ -]', '', id_number)
12+
13+
14+
class ParseResult(TypedDict):
15+
"""parse result for the national id"""
16+
yyyymmdd: date
17+
"""birthday"""
18+
gender: Gender
19+
"""gender, possible value: male, female"""
20+
citizenship: Citizenship
21+
"""possible value: citizen meaning born from hungary, foreign: meaning naturalized citizen or resident"""
22+
sn: str
23+
"""serial number"""
24+
checksum: CHECK_DIGIT
25+
"""check digits"""
26+
27+
28+
class PersonalID:
29+
"""
30+
Hungary Personal ID number format
31+
https://en.wikipedia.org/wiki/National_identification_number#Hungary
32+
"""
33+
METADATA = SimpleNamespace(**{
34+
'iso3166_alpha2': 'HU',
35+
'min_length': 11,
36+
'max_length': 11,
37+
'parsable': True,
38+
'checksum': True,
39+
'regexp': re.compile(r'^(?P<gender>\d)[ -]?'
40+
r'(?P<yy>\d{2})(?P<mm>\d{2})(?P<dd>\d{2})[ -]?'
41+
r'(?P<sn>\d{3})'
42+
r'(?P<checksum>\d)$')
43+
})
44+
45+
MAGIC_MULTIPLIER = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
46+
"""multiplier for the checksum"""
47+
48+
@staticmethod
49+
def validate(id_number: str) -> bool:
50+
"""
51+
Validate the personal id number
52+
"""
53+
if not validate_regexp(id_number, PersonalID.METADATA.regexp):
54+
return False
55+
return PersonalID.parse(id_number) is not None
56+
57+
@staticmethod
58+
def parse(id_number: str) -> Optional[ParseResult]:
59+
"""parse the result"""
60+
match_obj = PersonalID.METADATA.regexp.match(id_number)
61+
if not match_obj:
62+
return None
63+
checksum = PersonalID.checksum(id_number)
64+
if not checksum:
65+
return None
66+
gender_citizenship_year_base = PersonalID.get_gender_citizenship_year_base(int(match_obj.group('gender')))
67+
if not gender_citizenship_year_base:
68+
return None
69+
gender, citizenship, year_base = gender_citizenship_year_base
70+
yy = int(match_obj.group('yy'))
71+
mm = int(match_obj.group('mm'))
72+
dd = int(match_obj.group('dd'))
73+
sn = match_obj.group('sn')
74+
try:
75+
return {
76+
'yyyymmdd': date(yy + year_base, mm, dd),
77+
'gender': gender,
78+
'citizenship': citizenship,
79+
'sn': sn,
80+
'checksum': int(match_obj.group('checksum'))
81+
}
82+
except ValueError:
83+
return None
84+
85+
@staticmethod
86+
def checksum(id_number) -> bool:
87+
"""
88+
It's hard to find it. The algorithm is the 11-modulus on a weighted sum.
89+
algorithm: https://github.com/loonkwil/hungarian-validator-bundle/blob/master/Validator/PersonalIdValidator.php
90+
"""
91+
if not validate_regexp(id_number, PersonalID.METADATA.regexp):
92+
return False
93+
# it uses modulus 11 algorithm with magic numbers
94+
numbers = [int(char) for char in normalize(id_number)]
95+
modulus = weighted_modulus_digit(numbers[:-1], PersonalID.MAGIC_MULTIPLIER, 11, True)
96+
# According to an official doc in hungary language, gov will use another random number to
97+
# skip the modulus 10.
98+
return modulus == numbers[-1] if modulus < 10 else False
99+
100+
@staticmethod
101+
def get_gender_citizenship_year_base(gender_citizenship: int) -> Optional[Tuple[Gender, Citizenship, int]]:
102+
gender = Gender.MALE if gender_citizenship % 2 == 1 else Gender.FEMALE
103+
citizenship = Citizenship.CITIZEN if gender_citizenship < 5 else Citizenship.FOREIGN
104+
if 0 < gender_citizenship <= 2:
105+
year_base = 1900
106+
elif 2 < gender_citizenship <= 4:
107+
year_base = 2000
108+
elif 4 < gender_citizenship <= 6:
109+
year_base = 1900
110+
elif 6 < gender_citizenship <= 8:
111+
year_base = 1800
112+
else:
113+
return None
114+
return gender, citizenship, year_base
115+
116+
117+
NationalID = PersonalID
118+
"""alias of PersonalID"""

idnumbers/nationalid/constant.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ class Gender(Enum):
1212
class Citizenship(Enum):
1313
CITIZEN = 'citizen'
1414
RESIDENT = 'resident'
15+
FOREIGN = 'foreign'
16+
"""foreign may be naturalized citizen or a resident"""

tests/nationalid/test_HUN.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from unittest import TestCase, main
2+
3+
from idnumbers.nationalid import HUN
4+
5+
6+
class TestHUNValidation(TestCase):
7+
def test_normal_case(self):
8+
self.assertTrue(HUN.PersonalID.validate('3 110714 1231'))
9+
10+
def test_error_case(self):
11+
self.assertFalse(HUN.PersonalID.validate('3 110714 1230'))
12+
13+
def test_parse(self):
14+
result = HUN.PersonalID.parse('3 110714 1231')
15+
self.assertEqual(2011, result['yyyymmdd'].year)
16+
self.assertEqual(7, result['yyyymmdd'].month)
17+
self.assertEqual(14, result['yyyymmdd'].day)
18+
self.assertEqual('123', result['sn'])
19+
self.assertEqual(1, result['checksum'])

0 commit comments

Comments
 (0)