Skip to content

Commit eab16dc

Browse files
imperosolyozachar
andauthored
feat: add french i18n validation (#308)
* feat: add french i18n validation --------- Co-authored-by: Jovial Joe Jayarson <jovial7joe@hotmail.com>
1 parent 54484c4 commit eab16dc

File tree

4 files changed

+227
-2
lines changed

4 files changed

+227
-2
lines changed

src/validators/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from .email import email
1010
from .hashes import md5, sha1, sha224, sha256, sha512
1111
from .hostname import hostname
12-
from .i18n import es_cif, es_doi, es_nie, es_nif, fi_business_id, fi_ssn
12+
from .i18n import es_cif, es_doi, es_nie, es_nif, fi_business_id, fi_ssn, fr_department, fr_ssn
1313
from .iban import iban
1414
from .ip_address import ipv4, ipv6
1515
from .length import length
@@ -57,6 +57,8 @@
5757
"es_nif",
5858
"fi_business_id",
5959
"fi_ssn",
60+
"fr_department",
61+
"fr_ssn",
6062
# ...
6163
"iban",
6264
# ip addresses

src/validators/i18n/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,15 @@
55
# local
66
from .es import es_cif, es_doi, es_nie, es_nif
77
from .fi import fi_business_id, fi_ssn
8+
from .fr import fr_department, fr_ssn
89

9-
__all__ = ("fi_business_id", "fi_ssn", "es_cif", "es_doi", "es_nie", "es_nif")
10+
__all__ = (
11+
"fi_business_id",
12+
"fi_ssn",
13+
"es_cif",
14+
"es_doi",
15+
"es_nie",
16+
"es_nif",
17+
"fr_department",
18+
"fr_ssn",
19+
)

src/validators/i18n/fr.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""France."""
2+
3+
# standard
4+
from functools import lru_cache
5+
import re
6+
import typing
7+
8+
# local
9+
from validators.utils import validator
10+
11+
12+
@lru_cache
13+
def _ssn_pattern():
14+
"""SSN Pattern."""
15+
return re.compile(
16+
r"^([1,2])" # gender (1=M, 2=F)
17+
r"\s(\d{2})" # year of birth
18+
r"\s(0[1-9]|1[0-2])" # month of birth
19+
r"\s(\d{2,3}|2[A,B])" # department of birth
20+
r"\s(\d{2,3})" # town of birth
21+
r"\s(\d{3})" # registration number
22+
r"(?:\s(\d{2}))?$", # control key (may or may not be provided)
23+
re.VERBOSE,
24+
)
25+
26+
27+
@validator
28+
def fr_department(value: typing.Union[str, int]):
29+
"""Validate a french department number.
30+
31+
Examples:
32+
>>> fr_department(20) # can be an integer
33+
# Output: True
34+
>>> fr_department("20")
35+
# Output: True
36+
>>> fr_department("971") # Guadeloupe
37+
# Output: True
38+
>>> fr_department("00")
39+
# Output: ValidationError(func=fr_department, args=...)
40+
>>> fr_department('2A') # Corsica
41+
# Output: True
42+
>>> fr_department('2B')
43+
# Output: True
44+
>>> fr_department('2C')
45+
# Output: ValidationError(func=fr_department, args=...)
46+
47+
Args:
48+
value:
49+
French department number to validate.
50+
51+
Returns:
52+
(Literal[True]):
53+
If `value` is a valid french department number.
54+
(ValidationError):
55+
If `value` is an invalid french department number.
56+
57+
> *New in version 0.23.0*.
58+
"""
59+
if not value:
60+
return False
61+
if isinstance(value, str):
62+
if value in ("2A", "2B"): # Corsica
63+
return True
64+
try:
65+
value = int(value)
66+
except ValueError:
67+
return False
68+
return 1 <= value <= 19 or 21 <= value <= 95 or 971 <= value <= 976 # Overseas departments
69+
70+
71+
@validator
72+
def fr_ssn(value: str):
73+
"""Validate a french Social Security Number.
74+
75+
Each french citizen has a distinct Social Security Number.
76+
For more information see [French Social Security Number][1] (sadly unavailable in english).
77+
78+
[1]: https://fr.wikipedia.org/wiki/Num%C3%A9ro_de_s%C3%A9curit%C3%A9_sociale_en_France
79+
80+
Examples:
81+
>>> fr_ssn('1 84 12 76 451 089 46')
82+
# Output: True
83+
>>> fr_ssn('1 84 12 76 451 089') # control key is optional
84+
# Output: True
85+
>>> fr_ssn('3 84 12 76 451 089 46') # wrong gender number
86+
# Output: ValidationError(func=fr_ssn, args=...)
87+
>>> fr_ssn('1 84 12 76 451 089 47') # wrong control key
88+
# Output: ValidationError(func=fr_ssn, args=...)
89+
90+
Args:
91+
value:
92+
French Social Security Number string to validate.
93+
94+
Returns:
95+
(Literal[True]):
96+
If `value` is a valid french Social Security Number.
97+
(ValidationError):
98+
If `value` is an invalid french Social Security Number.
99+
100+
> *New in version 0.23.0*.
101+
"""
102+
if not value:
103+
return False
104+
matched = re.match(_ssn_pattern(), value)
105+
if not matched:
106+
return False
107+
groups = list(matched.groups())
108+
control_key = groups[-1]
109+
department = groups[3]
110+
if department != "99" and not fr_department(department):
111+
# 99 stands for foreign born people
112+
return False
113+
if control_key is None:
114+
# no control key provided, no additional check needed
115+
return True
116+
if len(department) == len(groups[4]):
117+
# if the department number is 3 digits long (overseas departments),
118+
# the town number must be 2 digits long
119+
# and vice versa
120+
return False
121+
if department in ("2A", "2B"):
122+
# Corsica's department numbers are not in the same range as the others
123+
# thus 2A and 2B are replaced by 19 and 18 respectively to compute the control key
124+
groups[3] = "19" if department == "2A" else "18"
125+
# the control key is valid if it is equal to 97 - (the first 13 digits modulo 97)
126+
digits = int("".join(groups[:-1]))
127+
return int(control_key) == (97 - (digits % 97))

tests/i18n/test_fr.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Test French validators."""
2+
3+
# standard
4+
from typing import Union
5+
6+
# external
7+
import pytest
8+
9+
# local
10+
from validators import ValidationError
11+
from validators.i18n.fr import fr_department, fr_ssn
12+
13+
14+
@pytest.mark.parametrize(
15+
("value",),
16+
[
17+
("1 84 12 76 451 089 46",),
18+
("1 84 12 76 451 089",), # control key is optional
19+
("2 99 05 75 202 818 97",),
20+
("2 99 05 75 202 817 01",),
21+
("2 99 05 2A 202 817 58",),
22+
("2 99 05 2B 202 817 85",),
23+
("2 99 05 971 12 817 70",),
24+
],
25+
)
26+
def test_returns_true_on_valid_ssn(value: str):
27+
"""Test returns true on valid ssn."""
28+
assert fr_ssn(value)
29+
30+
31+
@pytest.mark.parametrize(
32+
("value",),
33+
[
34+
(None,),
35+
("",),
36+
("3 84 12 76 451 089 46",), # wrong gender number
37+
("1 84 12 76 451 089 47",), # wrong control key
38+
("1 84 00 76 451 089",), # invalid month
39+
("1 84 13 76 451 089",), # invalid month
40+
("1 84 12 00 451 089",), # invalid department
41+
("1 84 12 2C 451 089",),
42+
("1 84 12 98 451 089",), # invalid department
43+
# ("1 84 12 971 451 089",), # ?
44+
],
45+
)
46+
def test_returns_failed_validation_on_invalid_ssn(value: str):
47+
"""Test returns failed validation on invalid_ssn."""
48+
assert isinstance(fr_ssn(value), ValidationError)
49+
50+
51+
@pytest.mark.parametrize(
52+
("value",),
53+
[
54+
("01",),
55+
("2A",), # Corsica
56+
("2B",),
57+
(14,),
58+
("95",),
59+
("971",),
60+
(971,),
61+
],
62+
)
63+
def test_returns_true_on_valid_department(value: Union[str, int]):
64+
"""Test returns true on valid department."""
65+
assert fr_department(value)
66+
67+
68+
@pytest.mark.parametrize(
69+
("value",),
70+
[
71+
(None,),
72+
("",),
73+
("00",),
74+
(0,),
75+
("2C",),
76+
("97",),
77+
("978",),
78+
("98",),
79+
("96",),
80+
("20",),
81+
(20,),
82+
],
83+
)
84+
def test_returns_failed_validation_on_invalid_department(value: Union[str, int]):
85+
"""Test returns failed validation on invalid department."""
86+
assert isinstance(fr_department(value), ValidationError)

0 commit comments

Comments
 (0)