Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,24 @@ codicefiscale.encode(

# "CCCFBA85D03L219P"
```

For people born abroad, the birthplace code in the fiscal code reflects the country
code assigned at **first document issuance**, not at birth. Pass optional
`issuingdate` to select the foreign country code valid on that date (Italian
municipalities always use the code valid at `birthdate`):

```python
codicefiscale.encode(
lastname="Rossi",
firstname="Mario",
gender="M",
birthdate="20/08/1987",
birthplace="Georgia",
issuingdate="01/01/2000",
)

# birthplace code Z254 instead of Z136 (valid at birth in 1987)
```
#### Decode
```python
codicefiscale.decode("CCCFBA85D03L219P")
Expand Down Expand Up @@ -119,6 +137,12 @@ python -m codicefiscale --help
python -m codicefiscale encode --firstname Fabio --lastname Caccamo --gender M --birthdate 03/04/1985 --birthplace Torino
```

Optional `--issuingdate` (foreign birthplaces only):

```bash
python -m codicefiscale encode --firstname Mario --lastname Rossi --gender M --birthdate 20/08/1987 --birthplace Georgia --issuingdate 01/01/2000
```

#### Decode (CLI)
```bash
python -m codicefiscale decode CCCFBA85D03L219P
Expand Down
10 changes: 10 additions & 0 deletions src/codicefiscale/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def _encode_from_args(args: argparse.Namespace) -> None:
gender=args.gender,
birthdate=args.birthdate,
birthplace=args.birthplace,
issuingdate=args.issuingdate,
)
except Exception as error:
sys.stderr.write(f"{error}\n")
Expand Down Expand Up @@ -119,6 +120,15 @@ def run() -> None:
required=True,
help="Place of birth (city, province)",
)
encode_parser.add_argument(
"--issuingdate",
required=False,
default=None,
help=(
"Document issuing date (DD/MM/YYYY); for foreign birthplaces, "
"selects the country code valid at this date"
),
)

validate_parser = subparsers.add_parser(
"validate",
Expand Down
59 changes: 46 additions & 13 deletions src/codicefiscale/codicefiscale.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ def _get_date(
def _get_birthplace(
birthplace: str,
birthdate: datetime | str | None = None,
issuingdate: datetime | str | None = None,
) -> dict[str, Any] | None:
birthplace_unicode_slug = slugify(birthplace, allow_unicode=True)
birthplace_slug = slugify(birthplace)
Expand All @@ -173,23 +174,29 @@ def _get_birthplace(
return None

birthdate_date = _get_date(birthdate)
if not birthdate_date:
issuingdate_date = _get_date(issuingdate) if issuingdate else None
is_foreign = birthplaces_options[0].get("province") == "EE"
if is_foreign and issuingdate_date:
reference_date = issuingdate_date
elif birthdate_date:
reference_date = birthdate_date
else:
return cast(dict[str, Any], birthplaces_options[0].copy())

# search birthplace that has been created before / deleted after birthdate
# search birthplace that has been created before / deleted after reference_date
for birthplace_option in birthplaces_options:
date_created = _get_date(birthplace_option["date_created"]) or datetime.min
date_deleted = _get_date(birthplace_option["date_deleted"]) or datetime.max
# print(birthdate_date, date_created, date_deleted)
if birthdate_date >= date_created and birthdate_date <= date_deleted:
# print(reference_date, date_created, date_deleted)
if reference_date >= date_created and reference_date <= date_deleted:
return cast(dict[str, Any], birthplace_option.copy())

return _get_birthplace_fallback(birthplaces_options, birthdate_date)
return _get_birthplace_fallback(birthplaces_options, reference_date)


def _get_birthplace_fallback(
birthplaces_options: list[dict[str, Any]],
birthdate_date: datetime,
reference_date: datetime,
) -> dict[str, Any] | None:
# avoid wrong birthplace code error when birthdate falls in
# missing date-range in the data-source even if birthplace code is valid
Expand All @@ -201,8 +208,8 @@ def _get_birthplace_fallback(
date_deleted = _get_date(birthplace_option["date_deleted"])
date_created = _get_date(birthplace_option_next["date_created"])
if date_deleted and date_created:
if birthdate_date >= date_deleted and date_deleted <= date_created:
# the birthdate is in between a deleted munipality and a created one
if reference_date >= date_deleted and date_deleted <= date_created:
# the reference date is in between a deleted munipality and a created one
# if the deleted one has a very short active time delta,
# it means that probably the deleted_at value is wrong
date_created = _get_date(birthplace_option["date_created"])
Expand All @@ -216,12 +223,12 @@ def _get_birthplace_fallback(
# Fix issues #210, #213
# sometimes the code has been assigned after date of birth and
# the municipality was not yet active at birthdate time,
# let's return the first municipality created after birthdate_date
# let's return the first municipality created after reference_date
for index in range(birthplaces_options_count):
birthplace_option = birthplaces_options[index]
date_created = _get_date(birthplace_option["date_created"])
if date_created:
if birthdate_date <= date_created:
if reference_date <= date_created:
return birthplace_option.copy()
return None

Expand Down Expand Up @@ -363,32 +370,53 @@ def encode_birthdate(
def encode_birthplace(
birthplace: str,
birthdate: datetime | str | None = None,
issuingdate: datetime | str | None = None,
) -> str | None:
"""
Encodes birthplace to the code used in italian fiscal code.

:param birthplace: The birthplace
:type birthplace: string
:param birthdate: The birthdate
:type birthdate: datetime or string
:param issuingdate: Optional document issuing date; for foreign countries
(province EE) the birthplace code valid at this date is used instead of
the code valid at birthdate
:type issuingdate: datetime or string

:returns: The code used in italian fiscal code
:rtype: string
"""
if not birthplace:
raise ValueError("[codicefiscale] 'birthplace' argument cant be None")

if issuingdate is not None:
issuingdate_date = _get_date(issuingdate)
if not issuingdate_date:
raise ValueError(
"[codicefiscale] 'issuingdate' argument is not a valid date"
)
birthdate_date = _get_date(birthdate)
if birthdate_date and issuingdate_date < birthdate_date:
raise ValueError(
"[codicefiscale] 'issuingdate' cannot be before 'birthdate'"
)

birthplace_without_province = re.split(r",|\(", birthplace)[0]
birthplace_data = _get_birthplace(
birthplace,
birthdate,
issuingdate,
) or _get_birthplace(
birthplace_without_province,
birthdate,
issuingdate,
)

if not birthplace_data:
raise ValueError(
"[codicefiscale] 'birthplace' / 'birthdate' arguments "
f"({birthplace!r} / {birthdate!r}) not mapped to code"
"[codicefiscale] 'birthplace' / 'birthdate' / 'issuingdate' arguments "
f"({birthplace!r} / {birthdate!r} / {issuingdate!r}) not mapped to code"
)

birthplace_code = str(birthplace_data["code"])
Expand Down Expand Up @@ -429,6 +457,7 @@ def encode(
gender: Literal["m", "M", "f", "F"],
birthdate: datetime | str | None,
birthplace: str,
issuingdate: datetime | str | None = None,
) -> str:
"""
Encodes the italian fiscal code.
Expand All @@ -443,6 +472,10 @@ def encode(
:type birthdate: datetime or string
:param birthplace: The birthplace
:type birthplace: string
:param issuingdate: Optional document issuing date; for foreign countries
(province EE) the birthplace code valid at this date is used instead of
the code valid at birthdate
:type issuingdate: datetime or string

:returns: The italian fiscal code
:rtype: string
Expand All @@ -451,7 +484,7 @@ def encode(
lastname_code = encode_lastname(lastname)
firstname_code = encode_firstname(firstname)
birthdate_code = encode_birthdate(birthdate, gender)
birthplace_code = encode_birthplace(birthplace, birthdate)
birthplace_code = encode_birthplace(birthplace, birthdate, issuingdate)
code = f"{lastname_code}{firstname_code}{birthdate_code}{birthplace_code}"
cin_code = encode_cin(code)
code = f"{code}{cin_code}"
Expand Down
21 changes: 19 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def test_encode():
gender="M",
birthdate="01/01/1990",
birthplace="Roma,RM",
issuingdate=None,
subcommand="encode",
)
run_with_args(args)
Expand Down Expand Up @@ -85,16 +86,32 @@ def test_encode_with_wrong_birthplace():
gender="M",
birthdate="01/01/1990",
birthplace="Romaaa,RM",
issuingdate=None,
subcommand="encode",
)
run_with_args(args)
output = fake_output.getvalue().strip()
assert output == (
"[codicefiscale] 'birthplace' / 'birthdate' arguments "
"('Romaaa,RM' / '01/01/1990') not mapped to code"
"[codicefiscale] 'birthplace' / 'birthdate' / 'issuingdate' arguments "
"('Romaaa,RM' / '01/01/1990' / None) not mapped to code"
)


def test_encode_with_issuingdate_from_command_line():
assert_command_output(
(
"python -m codicefiscale encode "
"--firstname Test "
"--lastname Test "
"--gender M "
"--birthdate 20/08/1987 "
"--birthplace Georgia "
"--issuingdate 01/01/2000"
),
"TSTTST87M20Z254F",
)


def test_decode_without_omocodes():
with mock.patch("sys.stdout", new=StringIO()) as fake_output:
args = argparse.Namespace(
Expand Down
49 changes: 49 additions & 0 deletions tests/test_encode.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,52 @@ def test_encode(encode_test_cases):
"""Test encoding full fiscal codes."""
for case in encode_test_cases:
assert codicefiscale.encode(**case["input"]) == case["result"]


def test_encode_birthplace_foreign_with_issuingdate():
"""Foreign birthplace code uses issuingdate when provided."""
birthdate = "20/08/1987"
assert codicefiscale.encode_birthplace("Georgia", birthdate) == "Z136"
assert (
codicefiscale.encode_birthplace("Georgia", birthdate, "01/01/2000") == "Z254"
)


def test_encode_foreign_with_issuingdate():
"""Full encode uses foreign birthplace code from issuingdate."""
kwargs = {
"lastname": "Test",
"firstname": "Test",
"gender": "M",
"birthdate": "20/08/1987",
"birthplace": "Georgia",
}
assert codicefiscale.encode(**kwargs) == "TSTTST87M20Z136E"
assert (
codicefiscale.encode(**kwargs, issuingdate="01/01/2000")
== "TSTTST87M20Z254F"
)
assert codicefiscale.is_valid("TSTTST87M20Z254F")
decoded = codicefiscale.decode("TSTTST87M20Z254F")
assert decoded["birthplace"]["code"] == "Z254"


def test_encode_issuingdate_italy_unchanged():
"""Italian municipalities ignore issuingdate for birthplace lookup."""
birthdate = "03/04/1985"
birthplace = "Torino"
without = codicefiscale.encode_birthplace(birthplace, birthdate)
with_issuing = codicefiscale.encode_birthplace(
birthplace, birthdate, "01/01/2020"
)
assert without == with_issuing == "L219"


def test_encode_birthplace_issuingdate_invalid():
"""Invalid issuingdate raises ValueError."""
with pytest.raises(ValueError, match="issuingdate"):
codicefiscale.encode_birthplace("Georgia", "20/08/1987", "not-a-date")
with pytest.raises(ValueError, match="before"):
codicefiscale.encode_birthplace(
"Georgia", "20/08/1987", "01/01/1980"
)