Skip to content

Commit

Permalink
feat: add french i18n validation (#308)
Browse files Browse the repository at this point in the history
* feat: add french i18n validation

---------

Co-authored-by: Jovial Joe Jayarson <[email protected]>
  • Loading branch information
imperosol and yozachar authored Nov 7, 2023
1 parent 54484c4 commit eab16dc
Show file tree
Hide file tree
Showing 4 changed files with 227 additions and 2 deletions.
4 changes: 3 additions & 1 deletion src/validators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,6 +57,8 @@
"es_nif",
"fi_business_id",
"fi_ssn",
"fr_department",
"fr_ssn",
# ...
"iban",
# ip addresses
Expand Down
12 changes: 11 additions & 1 deletion src/validators/i18n/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
127 changes: 127 additions & 0 deletions src/validators/i18n/fr.py
Original file line number Diff line number Diff line change
@@ -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))
86 changes: 86 additions & 0 deletions tests/i18n/test_fr.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit eab16dc

Please sign in to comment.