Skip to content

Commit

Permalink
✨ Add currency code ISO4217 (#143)
Browse files Browse the repository at this point in the history
* Add currency code ISO 4217 and its subset that includes only currencies

* added ISO 4217
* added tests

* ♻️ Update currency code parsing

---------

Co-authored-by: 07pepa <“no@sharebcs.spam”>
Co-authored-by: Yasser Tahiri <yasserth19@gmail.com>
  • Loading branch information
3 people authored Feb 27, 2024
1 parent e727b1f commit 10386a9
Show file tree
Hide file tree
Showing 3 changed files with 289 additions and 0 deletions.
179 changes: 179 additions & 0 deletions pydantic_extra_types/currency_code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""
Currency definitions that are based on the [ISO4217](https://en.wikipedia.org/wiki/ISO_4217).
"""
from __future__ import annotations

from typing import Any

from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
from pydantic_core import PydanticCustomError, core_schema

try:
import pycountry
except ModuleNotFoundError: # pragma: no cover
raise RuntimeError(
'The `currency_code` module requires "pycountry" to be installed. You can install it with "pip install '
'pycountry".'
)

# List of codes that should not be usually used within regular transactions
_CODES_FOR_BONDS_METAL_TESTING = {
'XTS', # testing
'XAU', # gold
'XAG', # silver
'XPD', # palladium
'XPT', # platinum
'XBA', # Bond Markets Unit European Composite Unit (EURCO)
'XBB', # Bond Markets Unit European Monetary Unit (E.M.U.-6)
'XBC', # Bond Markets Unit European Unit of Account 9 (E.U.A.-9)
'XBD', # Bond Markets Unit European Unit of Account 17 (E.U.A.-17)
'XXX', # no currency
'XDR', # SDR (Special Drawing Right)
}


class ISO4217(str):
"""ISO4217 parses Currency in the [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) format.
```py
from pydantic import BaseModel
from pydantic_extra_types.currency_code import ISO4217
class Currency(BaseModel):
alpha_3: ISO4217
currency = Currency(alpha_3='AED')
print(currency)
# > alpha_3='AED'
```
"""

allowed_countries_list = [country.alpha_3 for country in pycountry.currencies]
allowed_currencies = set(allowed_countries_list)

@classmethod
def _validate(cls, currency_code: str, _: core_schema.ValidationInfo) -> str:
"""
Validate a ISO 4217 language code from the provided str value.
Args:
currency_code: The str value to be validated.
_: The Pydantic ValidationInfo.
Returns:
The validated ISO 4217 currency code.
Raises:
PydanticCustomError: If the ISO 4217 currency code is not valid.
"""
if currency_code not in cls.allowed_currencies:
raise PydanticCustomError(
'ISO4217', 'Invalid ISO 4217 currency code. See https://en.wikipedia.org/wiki/ISO_4217'
)
return currency_code

@classmethod
def __get_pydantic_core_schema__(cls, _: type[Any], __: GetCoreSchemaHandler) -> core_schema.CoreSchema:
return core_schema.with_info_after_validator_function(
cls._validate,
core_schema.str_schema(min_length=3, max_length=3),
)

@classmethod
def __get_pydantic_json_schema__(
cls, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
) -> dict[str, Any]:
json_schema = handler(schema)
json_schema.update({'enum': cls.allowed_countries_list})
return json_schema


class Currency(str):
"""Currency parses currency subset of the [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) format.
It excludes bonds testing codes and precious metals.
```py
from pydantic import BaseModel
from pydantic_extra_types.currency_code import Currency
class currency(BaseModel):
alpha_3: Currency
cur = currency(alpha_3='AED')
print(cur)
# > alpha_3='AED'
```
"""

allowed_countries_list = list(
filter(lambda x: x not in _CODES_FOR_BONDS_METAL_TESTING, ISO4217.allowed_countries_list)
)
allowed_currencies = set(allowed_countries_list)

@classmethod
def _validate(cls, currency_symbol: str, _: core_schema.ValidationInfo) -> str:
"""
Validate a subset of the [ISO4217](https://en.wikipedia.org/wiki/ISO_4217) format.
It excludes bonds testing codes and precious metals.
Args:
currency_symbol: The str value to be validated.
_: The Pydantic ValidationInfo.
Returns:
The validated ISO 4217 currency code.
Raises:
PydanticCustomError: If the ISO 4217 currency code is not valid or is bond, precious metal or testing code.
"""
if currency_symbol not in cls.allowed_currencies:
raise PydanticCustomError(
'InvalidCurrency',
'Invalid currency code.'
' See https://en.wikipedia.org/wiki/ISO_4217. '
'Bonds, testing and precious metals codes are not allowed.',
)
return currency_symbol

@classmethod
def __get_pydantic_core_schema__(cls, _: type[Any], __: GetCoreSchemaHandler) -> core_schema.CoreSchema:
"""
Return a Pydantic CoreSchema with the currency subset of the
[ISO4217](https://en.wikipedia.org/wiki/ISO_4217) format.
It excludes bonds testing codes and precious metals.
Args:
_: The source type.
__: The handler to get the CoreSchema.
Returns:
A Pydantic CoreSchema with the subset of the currency subset of the
[ISO4217](https://en.wikipedia.org/wiki/ISO_4217) format.
It excludes bonds testing codes and precious metals.
"""
return core_schema.with_info_after_validator_function(
cls._validate,
core_schema.str_schema(min_length=3, max_length=3),
)

@classmethod
def __get_pydantic_json_schema__(
cls, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
) -> dict[str, Any]:
"""
Return a Pydantic JSON Schema with subset of the [ISO4217](https://en.wikipedia.org/wiki/ISO_4217) format.
Excluding bonds testing codes and precious metals.
Args:
schema: The Pydantic CoreSchema.
handler: The handler to get the JSON Schema.
Returns:
A Pydantic JSON Schema with the subset of the ISO4217 currency code validation. without bonds testing codes
and precious metals.
"""
json_schema = handler(schema)
json_schema.update({'enum': cls.allowed_countries_list})
return json_schema
64 changes: 64 additions & 0 deletions tests/test_currency_code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import re

import pycountry
import pytest
from pydantic import BaseModel, ValidationError

from pydantic_extra_types import currency_code


class ISO4217CheckingModel(BaseModel):
currency: currency_code.ISO4217


class CurrencyCheckingModel(BaseModel):
currency: currency_code.Currency


forbidden_currencies = sorted(currency_code._CODES_FOR_BONDS_METAL_TESTING)


@pytest.mark.parametrize('currency', map(lambda code: code.alpha_3, pycountry.currencies))
def test_ISO4217_code_ok(currency: str):
model = ISO4217CheckingModel(currency=currency)
assert model.currency == currency
assert model.model_dump() == {'currency': currency} # test serialization


@pytest.mark.parametrize(
'currency',
filter(
lambda code: code not in currency_code._CODES_FOR_BONDS_METAL_TESTING,
map(lambda code: code.alpha_3, pycountry.currencies),
),
)
def test_everyday_code_ok(currency: str):
model = CurrencyCheckingModel(currency=currency)
assert model.currency == currency
assert model.model_dump() == {'currency': currency} # test serialization


def test_ISO4217_fails():
with pytest.raises(
ValidationError,
match=re.escape(
'1 validation error for ISO4217CheckingModel\ncurrency\n '
'Invalid ISO 4217 currency code. See https://en.wikipedia.org/wiki/ISO_4217 '
"[type=ISO4217, input_value='OMG', input_type=str]"
),
):
ISO4217CheckingModel(currency='OMG')


@pytest.mark.parametrize('forbidden_currency', forbidden_currencies)
def test_forbidden_everyday(forbidden_currency):
with pytest.raises(
ValidationError,
match=re.escape(
'1 validation error for CurrencyCheckingModel\ncurrency\n '
'Invalid currency code. See https://en.wikipedia.org/wiki/ISO_4217. '
'Bonds, testing and precious metals codes are not allowed. '
f"[type=InvalidCurrency, input_value='{forbidden_currency}', input_type=str]"
),
):
CurrencyCheckingModel(currency=forbidden_currency)
46 changes: 46 additions & 0 deletions tests/test_json_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import pytest
from pydantic import BaseModel

import pydantic_extra_types
from pydantic_extra_types.color import Color
from pydantic_extra_types.coordinate import Coordinate, Latitude, Longitude
from pydantic_extra_types.country import (
Expand All @@ -10,6 +11,7 @@
CountryNumericCode,
CountryShortName,
)
from pydantic_extra_types.currency_code import ISO4217, Currency
from pydantic_extra_types.isbn import ISBN
from pydantic_extra_types.language_code import ISO639_3, ISO639_5
from pydantic_extra_types.mac_address import MacAddress
Expand All @@ -22,6 +24,16 @@
languages.sort()
language_families.sort()

currencies = [currency.alpha_3 for currency in pycountry.currencies]
currencies.sort()
everyday_currencies = [
currency.alpha_3
for currency in pycountry.currencies
if currency.alpha_3 not in pydantic_extra_types.currency_code._CODES_FOR_BONDS_METAL_TESTING
]

everyday_currencies.sort()


@pytest.mark.parametrize(
'cls,expected',
Expand Down Expand Up @@ -241,6 +253,40 @@
'type': 'object',
},
),
(
ISO4217,
{
'properties': {
'x': {
'title': 'X',
'type': 'string',
'enum': currencies,
'maxLength': 3,
'minLength': 3,
}
},
'required': ['x'],
'title': 'Model',
'type': 'object',
},
),
(
Currency,
{
'properties': {
'x': {
'title': 'X',
'type': 'string',
'enum': everyday_currencies,
'maxLength': 3,
'minLength': 3,
}
},
'required': ['x'],
'title': 'Model',
'type': 'object',
},
),
],
)
def test_json_schema(cls, expected):
Expand Down

0 comments on commit 10386a9

Please sign in to comment.