Skip to content

Commit

Permalink
feat: add french i18n validation
Browse files Browse the repository at this point in the history
  • Loading branch information
imperosol committed Oct 15, 2023
1 parent 15984e8 commit dcf73fe
Show file tree
Hide file tree
Showing 2 changed files with 205 additions and 0 deletions.
125 changes: 125 additions & 0 deletions src/validators/i18n/fr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""France."""

from functools import lru_cache
import re
import typing

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))
80 changes: 80 additions & 0 deletions tests/i18n/test_fr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Test French validators."""

import pytest
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: 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: str | int):
"""Test returns failed validation on invalid department."""
assert isinstance(fr_department(value), ValidationError)

0 comments on commit dcf73fe

Please sign in to comment.