diff --git a/pydantic_extra_types/iban.py b/pydantic_extra_types/iban.py new file mode 100644 index 00000000..8df31066 --- /dev/null +++ b/pydantic_extra_types/iban.py @@ -0,0 +1,133 @@ +""" +The `pydantic_extra_types.iban` module provides functionality to recieve and validate [IBAN (International Bank Account Number)](https://en.wikipedia.org/wiki/International_Bank_Account_Number). +""" + + +from __future__ import annotations + +from typing import Any + +from pydantic import GetCoreSchemaHandler +from pydantic_core import PydanticCustomError, core_schema + +try: + import schwifty +except ModuleNotFoundError: # pragma: no cover + raise RuntimeError( + 'The `iban` module requires "schwifty" to be installed. You can install it with "pip install schwifty".' + ) + + +class Iban(str): + """Represents a IBAN and provides methods for conversion, validation, and serialization. + + + ```py + from pydantic import BaseModel, constr + from pydantic_extra_types.iban import Iban + class IbanExample(BaseModel): + name: constr(strip_whitespace=True, min_length=1) + number: Iban + iban = IbanExample( + name='Georg Wilhelm Friedrich Hegel', + number='DE89 3704 0044 0532 0130 00', + ) + assert iban.number.account_code == '0532013000' + assert iban.number.bank_code == '37040044' + assert iban.number.numeric == 370400440532013000131489 + assert iban.number.bic == 'COBADEFFXXX' + assert iban.number.bank_name == 'Commerzbank' + assert iban.number.bban == '370400440532013000' + ``` + """ + + def __init__(self, iban: str): + self.iban = self.validate_iban_digits(iban) + + @classmethod + def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: + return core_schema.with_info_after_validator_function( + cls._validate, + core_schema.str_schema(), + ) + + @classmethod + def _validate(cls, __input_value: str, _: core_schema.ValidationInfo) -> str: + return cls(__input_value) + + @classmethod + def validate_iban_digits(cls, iban: str) -> schwifty.iban.IBAN: + """Validate that the IBAN is all digits.""" + if not isinstance(iban, str): + raise PydanticCustomError('iban_digits', 'IBAN is invalid') # pragma: no cover + return schwifty.IBAN(iban) + + @property + def bank(self) -> Any: + """The bank of the IBAN.""" + return self.iban.bank + + @property + def compact(self) -> str: + """The compact IBAN.""" + return self.iban.compact + + @property + def formatted(self) -> str: + """The formatted IBAN.""" + return self.iban.formatted + + @property + def account_code(self) -> str: + """The account code of the IBAN.""" + return self.iban.account_code + + @property + def bank_code(self) -> str: + """The bank code of the IBAN.""" + return self.iban.bank_code + + @property + def numeric(self) -> int: + """The numeric IBAN.""" + return self.iban.numeric + + @property + def spec(self) -> Any: + """The IBAN spec.""" + return self.iban.spec + + @property + def bic(self) -> None | schwifty.bic.BIC: + """The BIC of the IBAN.""" + return self.iban.bic + + @property + def country(self) -> Any: + """The country of the IBAN.""" + return self.iban.country + + @property + def bank_name(self) -> None | str: + """The bank name of the IBAN.""" + return self.iban.bank_name + + @property + def bank_short_name(self) -> None | str: + """The bank short name of the IBAN.""" + return self.iban.bank_short_name + + @property + def branch_code(self) -> str: + """The branch code of the IBAN.""" + return self.iban.branch_code + + @property + def bban(self) -> str: + """The BBAN of the IBAN.""" + return self.iban.bban + + @property + def checksum_digits(self) -> str: + """The checksum digits of the IBAN.""" + return self.iban.checksum_digits diff --git a/pyproject.toml b/pyproject.toml index cc51c1ba..5b26a390 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ all = [ 'phonenumbers>=8,<9', 'pycountry>=23,<24', 'python-ulid>=1,<2', + 'schwifty>=2023.10.0,<=2024.01.1', ] [project.urls] diff --git a/requirements/pyproject.txt b/requirements/pyproject.txt index 2b7d35c8..5e287ae4 100644 --- a/requirements/pyproject.txt +++ b/requirements/pyproject.txt @@ -6,20 +6,23 @@ # annotated-types==0.6.0 # via pydantic +iso3166==2.1.1 + # via schwifty phonenumbers==8.13.27 # via pydantic-extra-types (pyproject.toml) pycountry==23.12.11 - # via pydantic-extra-types (pyproject.toml) + # via + # pydantic-extra-types (pyproject.toml) + # schwifty pydantic==2.5.3 # via pydantic-extra-types (pyproject.toml) pydantic-core==2.14.6 # via pydantic python-ulid==1.1.0 # via pydantic-extra-types (pyproject.toml) +schwifty==2023.11.2 + # via pydantic-extra-types (pyproject.toml) typing-extensions==4.9.0 # via # pydantic # pydantic-core - -# The following packages are considered to be unsafe in a requirements file: -# setuptools diff --git a/tests/test_iban.py b/tests/test_iban.py new file mode 100644 index 00000000..8473209f --- /dev/null +++ b/tests/test_iban.py @@ -0,0 +1,52 @@ +from string import printable + +import pytest +from pydantic import BaseModel, ValidationError + +from pydantic_extra_types.iban import Iban + + +@pytest.fixture(scope='module', name='IBANFixture') +def iban_fixture(): + class IBANFixture(BaseModel): + made_in: Iban + + return IBANFixture + + +@pytest.mark.parametrize( + 'iban', + [ + 'DE89 3704 0044 0532 0130 00', + 'DE89370400440532013000', + 'DE89370400440532013000', + 'NL56ABNA2238591354', + 'GB64BARC20040149326928', + ], +) +def test_iban_properties(iban, IBANFixture): + iban_obj = IBANFixture(made_in=iban).made_in + + assert iban_obj.bank == iban_obj.iban.bank + assert iban_obj.compact == iban_obj.iban.compact + assert iban_obj.formatted == iban_obj.iban.formatted + assert iban_obj.account_code == iban_obj.iban.account_code + assert iban_obj.bank_code == iban_obj.iban.bank_code + assert iban_obj.numeric == iban_obj.iban.numeric + assert iban_obj.spec == iban_obj.iban.spec + assert iban_obj.bic == iban_obj.iban.bic + assert iban_obj.country == iban_obj.iban.country + assert iban_obj.bank_name == iban_obj.iban.bank_name + assert iban_obj.bank_short_name == iban_obj.iban.bank_short_name + assert iban_obj.branch_code == iban_obj.iban.branch_code + assert iban_obj.bban == iban_obj.iban.bban + assert iban_obj.checksum_digits == iban_obj.iban.checksum_digits + + +@pytest.mark.parametrize( + 'iban', + list(printable), +) +def test_invalid_iban(iban, IBANFixture): + with pytest.raises(ValidationError, match='Invalid characters in IBAN'): + IBANFixture(made_in=iban) diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py index 8508e87a..c3ea90aa 100644 --- a/tests/test_json_schema.py +++ b/tests/test_json_schema.py @@ -9,6 +9,7 @@ CountryNumericCode, CountryShortName, ) +from pydantic_extra_types.iban import Iban from pydantic_extra_types.isbn import ISBN from pydantic_extra_types.mac_address import MacAddress from pydantic_extra_types.payment import PaymentCardNumber @@ -190,6 +191,20 @@ 'type': 'object', }, ), + ( + Iban, + { + 'properties': { + 'x': { + 'title': 'X', + 'type': 'string', + } + }, + 'required': ['x'], + 'title': 'Model', + 'type': 'object', + }, + ), ], ) def test_json_schema(cls, expected):