Repository: identique/idnumbers (language: python)

Issue:
id number for Mexico
https://en.wikipedia.org/wiki/National_identification_number#Mexico


Discussion / hints:
https://en.wikipedia.org/wiki/Unique_Population_Registry_Code

parsable and checksum
list of state: http://sistemas.uaeh.edu.mx/dce/admisiones/docs/guia_CURP.pdf

Fix patch:
diff --git a/README.md b/README.md
index 6914857c..e96cceca 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,7 @@ print(id_data['citizenship'])
 * IND - India
 * IRL - Ireland
 * LKA - Sri Lanka
+* MEX - Mexico
 * MYS - Malaysia
 * NGA - Nigeria
 * NZL - New Zealand
diff --git a/idnumbers/nationalid/MEX.py b/idnumbers/nationalid/MEX.py
new file mode 100644
index 00000000..01a3e1bf
--- /dev/null
+++ b/idnumbers/nationalid/MEX.py
@@ -0,0 +1,97 @@
+import re
+from datetime import date
+from types import SimpleNamespace
+from typing import Literal, Optional, TypedDict
+from .util import validate_regexp
+from .constant import Gender
+
+
+class ParseResult(TypedDict):
+    name_initial_chars: str
+    name_consonants: str
+    yyyymmdd: date
+    gender: Gender
+    location: str
+    sn: str
+    checksum: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
+
+
+class NationalID:
+    """
+    Mexico National ID number format, CURP
+    https://en.wikipedia.org/wiki/Unique_Population_Registry_Code
+    http://sistemas.uaeh.edu.mx/dce/admisiones/docs/guia_CURP.pdf
+    python version of https://github.com/d3249/curp
+    """
+    METADATA = SimpleNamespace(**{
+        'iso3166_alpha2': 'MX',
+        'min_length': 18,
+        'max_length': 18,
+        'parsable': True,
+        'checksum': True,
+        'regexp': re.compile(r'^(?P<initial>[A-Z]{4})'
+                             r'(?P<yy>\d{2})(?P<mm>\d{2})(?P<dd>\d{2})'
+                             r'(?P<gender>[HMX])'
+                             r'(?P<location>[A-Z]{2})'
+                             r'(?P<consonant>[A-Z]{3})'
+                             r'(?P<sn>[0-9A-Z])'
+                             r'(?P<checksum>\d)$')
+    })
+
+    ID_CHARS = '0123456789ABCDEFGHIJKLMNÑOPQRSTUVWXYZ'
+    GENDER_MAP = {
+        'H': Gender.MALE,
+        'M': Gender.FEMALE,
+        'X': Gender.NON_BINARY
+    }
+
+    ALLOW_LOCATIONS = ['AS', 'BC', 'BS', 'CC', 'CH', 'CL',
+                       'CM', 'CS', 'DF', 'DG', 'GR', 'GT',
+                       'HG', 'JC', 'MC', 'MN', 'MS', 'NE',
+                       'NL', 'NT', 'OC', 'PL', 'QR', 'QT',
+                       'SL', 'SP', 'SR', 'TC', 'TL', 'TS',
+                       'VZ', 'YN', 'ZS']
+
+    @staticmethod
+    def validate(id_number: str) -> bool:
+        if not validate_regexp(id_number, NationalID.METADATA.regexp):
+            return False
+        return NationalID.parse(id_number) is not None
+
+    @staticmethod
+    def parse(id_number: str) -> Optional[ParseResult]:
+        match_obj = NationalID.METADATA.regexp.match(id_number)
+        if not match_obj:
+            return None
+        location = match_obj.group('location')
+        checksum = NationalID.checksum(id_number)
+        if not checksum:
+            return None
+        elif location not in NationalID.ALLOW_LOCATIONS:
+            return None
+        else:
+            yy = int(match_obj.group('yy'))
+            mm = int(match_obj.group('mm'))
+            dd = int(match_obj.group('dd'))
+            sn = match_obj.group('sn')
+            year_base = 1900 if ord(sn) < 65 else 2000
+            gender = match_obj.group('gender')
+            return {
+                'name_initial_chars': match_obj.group('initial'),
+                'name_consonants': match_obj.group('consonant'),
+                'yyyymmdd': date(yy + year_base, mm, dd),
+                'gender': NationalID.GENDER_MAP[gender],
+                'location': match_obj.group('location'),
+                'sn': sn,
+                'checksum': int(match_obj.group('checksum'))
+            }
+
+    @staticmethod
+    def checksum(id_number) -> bool:
+        if not validate_regexp(id_number, NationalID.METADATA.regexp):
+            return False
+        check = sum(NationalID.ID_CHARS.index(c) * (18 - i) for i, c in enumerate(id_number[:17]))
+        return int(id_number[17]) == (10 - check % 10) % 10
+
+
+CURP = NationalID
diff --git a/idnumbers/nationalid/constant.py b/idnumbers/nationalid/constant.py
index 5d5f454e..16e7371e 100644
--- a/idnumbers/nationalid/constant.py
+++ b/idnumbers/nationalid/constant.py
@@ -4,6 +4,7 @@
 class Gender(Enum):
     MALE = 'male'
     FEMALE = 'female'
+    NON_BINARY = 'non_binary'
 
 
 class Citizenship(Enum):


Test patch:
diff --git a/idnumbers/nationalid/test_MEX.py b/idnumbers/nationalid/test_MEX.py
new file mode 100644
index 00000000..2e9a6d53
--- /dev/null
+++ b/idnumbers/nationalid/test_MEX.py
@@ -0,0 +1,38 @@
+from unittest import TestCase, main
+
+from idnumbers.nationalid import MEX
+from idnumbers.nationalid.constant import Gender
+
+
+class TestMEXValidation(TestCase):
+    def test_normal_case(self):
+        self.assertTrue(MEX.NationalID.validate('HEGG560427MVZRRL04'))
+        self.assertTrue(MEX.NationalID.validate('AUAM630703HGTGRR02'))
+        self.assertTrue(MEX.NationalID.validate('HORA500201MNELSR04'))
+        self.assertTrue(MEX.NationalID.validate('CAMP800404HNEHDD03'))
+        self.assertTrue(MEX.NationalID.validate('DIXM870113HNEFXS06'))
+        self.assertTrue(MEX.NationalID.validate('MACD880521HNERVN09'))
+        self.assertTrue(MEX.NationalID.validate('DIXB881003HNELXB01'))
+        self.assertTrue(MEX.NationalID.validate('SOMD911221HNEBJN03'))
+
+    def test_error_case(self):
+        self.assertFalse(MEX.NationalID.validate('HEGG560427MVZRRL05'))
+        self.assertFalse(MEX.NationalID.validate('AUAM630703KGTGRR02'))
+        self.assertFalse(MEX.NationalID.validate('HEGGGG0427MVZRRL05'))
+        self.assertFalse(MEX.NationalID.validate('HEGG560427MXXRRL05'))
+
+    def test_parse(self):
+        result = MEX.NationalID.parse('HEGG560427MVZRRL04')
+        self.assertEqual('HEGG', result['name_initial_chars'])
+        self.assertEqual(1956, result['yyyymmdd'].year)
+        self.assertEqual(4, result['yyyymmdd'].month)
+        self.assertEqual(27, result['yyyymmdd'].day)
+        self.assertEqual(Gender.FEMALE, result['gender'])
+        self.assertEqual('VZ', result['location'])
+        self.assertEqual('RRL', result['name_consonants'])
+        self.assertEqual('0', result['sn'])
+        self.assertEqual(4, result['checksum'])
+
+
+if __name__ == '__main__':
+    main()