Repository: identique/idnumbers (language: python)

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


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index 6914857c..b667c516 100644
--- a/README.md
+++ b/README.md
@@ -55,6 +55,7 @@ print(id_data['citizenship'])
 * CAN - Canada
 * CHL - Chile
 * CHN - China
+* FRA - France
 * GBR - United Kingdom of Great Britain and Northern Ireland
 * HKG - Hong Kong
 * JPN - Japan
diff --git a/idnumbers/nationalid/FRA.py b/idnumbers/nationalid/FRA.py
new file mode 100644
index 00000000..a9d7f4f3
--- /dev/null
+++ b/idnumbers/nationalid/FRA.py
@@ -0,0 +1,99 @@
+import re
+from types import SimpleNamespace
+from typing import Optional, TypedDict
+from .util import validate_regexp
+from .constant import Gender
+
+
+class BirthDepartment(TypedDict):
+    department: str
+    city: str
+    country: str
+
+
+class ParseResult(TypedDict):
+    gender: Gender
+    yy: str
+    mm: str
+    checksum: str
+
+
+class NationalID:
+    """
+    France National ID number
+    https://en.wikipedia.org/wiki/National_identification_number#France
+    https://fr.wikipedia.org/wiki/Num%C3%A9ro_de_s%C3%A9curit%C3%A9_sociale_en_France#Signification_des_chiffres_du_NIR
+    """
+    METADATA = SimpleNamespace(**{
+        'iso3166_alpha2': 'FR',
+        # length without insignificant chars
+        'min_length': 15,
+        'max_length': 15,
+        'parsable': True,
+        'checksum': True,
+        'regexp': re.compile(r'^(?P<gender>([123478]))'
+                             r'(?P<yy>\d{2})'
+                             r'(?P<mm>(0[1-9]|1[0-2]|[2-3][0-9]|4[0-2]|[5-9][0-9]))'
+                             r'(?P<birth_department>((\d{2}|2[AaBb])\d{3}))'
+                             r'(?P<cert_number>((?!000)\d{3}))'
+                             r'(?P<control_key>((?!(00|98|99))\d{2}))$')
+
+    })
+
+    @staticmethod
+    def validate(id_number: str) -> bool:
+        """
+        Validate the FRA id number
+        """
+        if not validate_regexp(id_number, NationalID.METADATA.regexp):
+            return False
+        if not NationalID.parse(id_number):
+            return False
+        return NationalID.checksum(id_number)
+
+    @staticmethod
+    def parse(id_number: str) -> Optional[ParseResult]:
+        match_obj = NationalID.METADATA.regexp.match(id_number)
+        if not match_obj:
+            return None
+        if not NationalID.validate_birth_department(match_obj.group('birth_department')):
+            return None
+        gender = match_obj.group('gender')
+        yy = match_obj.group('yy')
+        mm = match_obj.group('mm')
+        control_key = match_obj.group('control_key')
+        return {
+            'gender': Gender.MALE if gender == '1' else Gender.FEMALE,
+            'yy': yy,
+            'mm': mm,
+            'checksum': control_key,
+        }
+
+    @staticmethod
+    def checksum(id_number: str) -> bool:
+        normalized = id_number.upper().replace('2A', '19').replace('2B', '18')
+        return 97 - int(normalized[:-2]) % 97 == int(normalized[-2:])
+
+    @staticmethod
+    def validate_birth_department(birth_department: str) -> Optional[BirthDepartment]:
+        department_code = birth_department[:2].upper()
+        # TODO: Map the area
+        if (department_code.isdigit() and 1 <= int(department_code) <= 95) or department_code in ['2A', '2B']:
+            return {
+                "department": birth_department[:2],
+                "city": birth_department[2:],
+                "country": ""
+            }
+        if 97 <= int(department_code) <= 98:
+            return {
+                "department": birth_department[:3],
+                "city": birth_department[3:],
+                "country": ""
+            }
+        if department_code == '99':
+            return {
+                "department": '',
+                "city": '',
+                "country": birth_department[2:]
+            }
+        return None


Test patch:
diff --git a/idnumbers/nationalid/test_FRA.py b/idnumbers/nationalid/test_FRA.py
new file mode 100644
index 00000000..53cd9da3
--- /dev/null
+++ b/idnumbers/nationalid/test_FRA.py
@@ -0,0 +1,26 @@
+from unittest import TestCase, main
+
+from .constant import Gender
+from idnumbers.nationalid import FRA
+
+
+class TestFRAValidation(TestCase):
+    def test_normal_case(self):
+        self.assertTrue(FRA.NationalID.validate('255081416802538'))
+        self.assertTrue(FRA.NationalID.validate('283209921625930'))
+        self.assertTrue(FRA.NationalID.validate('255082a16802597'))
+
+    def test_error_case(self):
+        self.assertFalse(FRA.NationalID.validate('180126955222381'))
+        self.assertFalse(FRA.NationalID.validate('255082e16802597'))
+
+    def test_parse(self):
+        result = FRA.NationalID.parse('255082a16802597')
+        self.assertEqual(Gender.FEMALE, result['gender'])
+        self.assertEqual('55', result['yy'])
+        self.assertEqual('08', result['mm'])
+        self.assertEqual('97', result['checksum'])
+
+
+if __name__ == '__main__':
+    main()