From 10386a9cabfc7c90cc6f7102a6b445b096e97535 Mon Sep 17 00:00:00 2001 From: Pepa Date: Tue, 27 Feb 2024 21:48:03 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20currency=20code=20ISO4217=20(?= =?UTF-8?q?#143)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add currency code ISO 4217 and its subset that includes only currencies * added ISO 4217 * added tests * :recycle: Update currency code parsing --------- Co-authored-by: 07pepa <“no@sharebcs.spam”> Co-authored-by: Yasser Tahiri --- pydantic_extra_types/currency_code.py | 179 ++++++++++++++++++++++++++ tests/test_currency_code.py | 64 +++++++++ tests/test_json_schema.py | 46 +++++++ 3 files changed, 289 insertions(+) create mode 100644 pydantic_extra_types/currency_code.py create mode 100644 tests/test_currency_code.py diff --git a/pydantic_extra_types/currency_code.py b/pydantic_extra_types/currency_code.py new file mode 100644 index 00000000..c19d9bf2 --- /dev/null +++ b/pydantic_extra_types/currency_code.py @@ -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 diff --git a/tests/test_currency_code.py b/tests/test_currency_code.py new file mode 100644 index 00000000..d259a8d4 --- /dev/null +++ b/tests/test_currency_code.py @@ -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) diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py index 4d428c03..5daa2460 100644 --- a/tests/test_json_schema.py +++ b/tests/test_json_schema.py @@ -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 ( @@ -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 @@ -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', @@ -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):