diff --git a/src/validators/__init__.py b/src/validators/__init__.py index a7ca68eb..cf70899a 100644 --- a/src/validators/__init__.py +++ b/src/validators/__init__.py @@ -9,7 +9,7 @@ from .email import email from .hashes import md5, sha1, sha224, sha256, sha512 from .hostname import hostname -from .i18n import es_cif, es_doi, es_nie, es_nif, fi_business_id, fi_ssn +from .i18n import es_cif, es_doi, es_nie, es_nif, fi_business_id, fi_ssn, fr_department, fr_ssn from .iban import iban from .ip_address import ipv4, ipv6 from .length import length @@ -57,6 +57,8 @@ "es_nif", "fi_business_id", "fi_ssn", + "fr_department", + "fr_ssn", # ... "iban", # ip addresses diff --git a/src/validators/i18n/__init__.py b/src/validators/i18n/__init__.py index 822a3fbc..45f45704 100644 --- a/src/validators/i18n/__init__.py +++ b/src/validators/i18n/__init__.py @@ -5,5 +5,15 @@ # local from .es import es_cif, es_doi, es_nie, es_nif from .fi import fi_business_id, fi_ssn +from .fr import fr_department, fr_ssn -__all__ = ("fi_business_id", "fi_ssn", "es_cif", "es_doi", "es_nie", "es_nif") +__all__ = ( + "fi_business_id", + "fi_ssn", + "es_cif", + "es_doi", + "es_nie", + "es_nif", + "fr_department", + "fr_ssn", +) diff --git a/src/validators/i18n/fr.py b/src/validators/i18n/fr.py new file mode 100644 index 00000000..60c20f33 --- /dev/null +++ b/src/validators/i18n/fr.py @@ -0,0 +1,127 @@ +"""France.""" + +# standard +from functools import lru_cache +import re +import typing + +# local +from validators.utils import validator + + +@lru_cache +def _ssn_pattern(): + """SSN Pattern.""" + return re.compile( + r"^([1,2])" # gender (1=M, 2=F) + r"\s(\d{2})" # year of birth + r"\s(0[1-9]|1[0-2])" # month of birth + r"\s(\d{2,3}|2[A,B])" # department of birth + r"\s(\d{2,3})" # town of birth + r"\s(\d{3})" # registration number + r"(?:\s(\d{2}))?$", # control key (may or may not be provided) + re.VERBOSE, + ) + + +@validator +def fr_department(value: typing.Union[str, int]): + """Validate a french department number. + + Examples: + >>> fr_department(20) # can be an integer + # Output: True + >>> fr_department("20") + # Output: True + >>> fr_department("971") # Guadeloupe + # Output: True + >>> fr_department("00") + # Output: ValidationError(func=fr_department, args=...) + >>> fr_department('2A') # Corsica + # Output: True + >>> fr_department('2B') + # Output: True + >>> fr_department('2C') + # Output: ValidationError(func=fr_department, args=...) + + Args: + value: + French department number to validate. + + Returns: + (Literal[True]): + If `value` is a valid french department number. + (ValidationError): + If `value` is an invalid french department number. + + > *New in version 0.23.0*. + """ + if not value: + return False + if isinstance(value, str): + if value in ("2A", "2B"): # Corsica + return True + try: + value = int(value) + except ValueError: + return False + return 1 <= value <= 19 or 21 <= value <= 95 or 971 <= value <= 976 # Overseas departments + + +@validator +def fr_ssn(value: str): + """Validate a french Social Security Number. + + Each french citizen has a distinct Social Security Number. + For more information see [French Social Security Number][1] (sadly unavailable in english). + + [1]: https://fr.wikipedia.org/wiki/Num%C3%A9ro_de_s%C3%A9curit%C3%A9_sociale_en_France + + Examples: + >>> fr_ssn('1 84 12 76 451 089 46') + # Output: True + >>> fr_ssn('1 84 12 76 451 089') # control key is optional + # Output: True + >>> fr_ssn('3 84 12 76 451 089 46') # wrong gender number + # Output: ValidationError(func=fr_ssn, args=...) + >>> fr_ssn('1 84 12 76 451 089 47') # wrong control key + # Output: ValidationError(func=fr_ssn, args=...) + + Args: + value: + French Social Security Number string to validate. + + Returns: + (Literal[True]): + If `value` is a valid french Social Security Number. + (ValidationError): + If `value` is an invalid french Social Security Number. + + > *New in version 0.23.0*. + """ + if not value: + return False + matched = re.match(_ssn_pattern(), value) + if not matched: + return False + groups = list(matched.groups()) + control_key = groups[-1] + department = groups[3] + if department != "99" and not fr_department(department): + # 99 stands for foreign born people + return False + if control_key is None: + # no control key provided, no additional check needed + return True + if len(department) == len(groups[4]): + # if the department number is 3 digits long (overseas departments), + # the town number must be 2 digits long + # and vice versa + return False + if department in ("2A", "2B"): + # Corsica's department numbers are not in the same range as the others + # thus 2A and 2B are replaced by 19 and 18 respectively to compute the control key + groups[3] = "19" if department == "2A" else "18" + # the control key is valid if it is equal to 97 - (the first 13 digits modulo 97) + digits = int("".join(groups[:-1])) + return int(control_key) == (97 - (digits % 97)) diff --git a/tests/i18n/test_fr.py b/tests/i18n/test_fr.py new file mode 100644 index 00000000..1e648222 --- /dev/null +++ b/tests/i18n/test_fr.py @@ -0,0 +1,86 @@ +"""Test French validators.""" + +# standard +from typing import Union + +# external +import pytest + +# local +from validators import ValidationError +from validators.i18n.fr import fr_department, fr_ssn + + +@pytest.mark.parametrize( + ("value",), + [ + ("1 84 12 76 451 089 46",), + ("1 84 12 76 451 089",), # control key is optional + ("2 99 05 75 202 818 97",), + ("2 99 05 75 202 817 01",), + ("2 99 05 2A 202 817 58",), + ("2 99 05 2B 202 817 85",), + ("2 99 05 971 12 817 70",), + ], +) +def test_returns_true_on_valid_ssn(value: str): + """Test returns true on valid ssn.""" + assert fr_ssn(value) + + +@pytest.mark.parametrize( + ("value",), + [ + (None,), + ("",), + ("3 84 12 76 451 089 46",), # wrong gender number + ("1 84 12 76 451 089 47",), # wrong control key + ("1 84 00 76 451 089",), # invalid month + ("1 84 13 76 451 089",), # invalid month + ("1 84 12 00 451 089",), # invalid department + ("1 84 12 2C 451 089",), + ("1 84 12 98 451 089",), # invalid department + # ("1 84 12 971 451 089",), # ? + ], +) +def test_returns_failed_validation_on_invalid_ssn(value: str): + """Test returns failed validation on invalid_ssn.""" + assert isinstance(fr_ssn(value), ValidationError) + + +@pytest.mark.parametrize( + ("value",), + [ + ("01",), + ("2A",), # Corsica + ("2B",), + (14,), + ("95",), + ("971",), + (971,), + ], +) +def test_returns_true_on_valid_department(value: Union[str, int]): + """Test returns true on valid department.""" + assert fr_department(value) + + +@pytest.mark.parametrize( + ("value",), + [ + (None,), + ("",), + ("00",), + (0,), + ("2C",), + ("97",), + ("978",), + ("98",), + ("96",), + ("20",), + (20,), + ], +) +def test_returns_failed_validation_on_invalid_department(value: Union[str, int]): + """Test returns failed validation on invalid department.""" + assert isinstance(fr_department(value), ValidationError)