Skip to content

Commit 10386a9

Browse files
07pepa07pepayezz123
authored
✨ Add currency code ISO4217 (#143)
* 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 <“[email protected]”> Co-authored-by: Yasser Tahiri <[email protected]>
1 parent e727b1f commit 10386a9

File tree

3 files changed

+289
-0
lines changed

3 files changed

+289
-0
lines changed
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"""
2+
Currency definitions that are based on the [ISO4217](https://en.wikipedia.org/wiki/ISO_4217).
3+
"""
4+
from __future__ import annotations
5+
6+
from typing import Any
7+
8+
from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
9+
from pydantic_core import PydanticCustomError, core_schema
10+
11+
try:
12+
import pycountry
13+
except ModuleNotFoundError: # pragma: no cover
14+
raise RuntimeError(
15+
'The `currency_code` module requires "pycountry" to be installed. You can install it with "pip install '
16+
'pycountry".'
17+
)
18+
19+
# List of codes that should not be usually used within regular transactions
20+
_CODES_FOR_BONDS_METAL_TESTING = {
21+
'XTS', # testing
22+
'XAU', # gold
23+
'XAG', # silver
24+
'XPD', # palladium
25+
'XPT', # platinum
26+
'XBA', # Bond Markets Unit European Composite Unit (EURCO)
27+
'XBB', # Bond Markets Unit European Monetary Unit (E.M.U.-6)
28+
'XBC', # Bond Markets Unit European Unit of Account 9 (E.U.A.-9)
29+
'XBD', # Bond Markets Unit European Unit of Account 17 (E.U.A.-17)
30+
'XXX', # no currency
31+
'XDR', # SDR (Special Drawing Right)
32+
}
33+
34+
35+
class ISO4217(str):
36+
"""ISO4217 parses Currency in the [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) format.
37+
38+
```py
39+
from pydantic import BaseModel
40+
41+
from pydantic_extra_types.currency_code import ISO4217
42+
43+
class Currency(BaseModel):
44+
alpha_3: ISO4217
45+
46+
currency = Currency(alpha_3='AED')
47+
print(currency)
48+
# > alpha_3='AED'
49+
```
50+
"""
51+
52+
allowed_countries_list = [country.alpha_3 for country in pycountry.currencies]
53+
allowed_currencies = set(allowed_countries_list)
54+
55+
@classmethod
56+
def _validate(cls, currency_code: str, _: core_schema.ValidationInfo) -> str:
57+
"""
58+
Validate a ISO 4217 language code from the provided str value.
59+
60+
Args:
61+
currency_code: The str value to be validated.
62+
_: The Pydantic ValidationInfo.
63+
64+
Returns:
65+
The validated ISO 4217 currency code.
66+
67+
Raises:
68+
PydanticCustomError: If the ISO 4217 currency code is not valid.
69+
"""
70+
if currency_code not in cls.allowed_currencies:
71+
raise PydanticCustomError(
72+
'ISO4217', 'Invalid ISO 4217 currency code. See https://en.wikipedia.org/wiki/ISO_4217'
73+
)
74+
return currency_code
75+
76+
@classmethod
77+
def __get_pydantic_core_schema__(cls, _: type[Any], __: GetCoreSchemaHandler) -> core_schema.CoreSchema:
78+
return core_schema.with_info_after_validator_function(
79+
cls._validate,
80+
core_schema.str_schema(min_length=3, max_length=3),
81+
)
82+
83+
@classmethod
84+
def __get_pydantic_json_schema__(
85+
cls, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
86+
) -> dict[str, Any]:
87+
json_schema = handler(schema)
88+
json_schema.update({'enum': cls.allowed_countries_list})
89+
return json_schema
90+
91+
92+
class Currency(str):
93+
"""Currency parses currency subset of the [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) format.
94+
It excludes bonds testing codes and precious metals.
95+
```py
96+
from pydantic import BaseModel
97+
98+
from pydantic_extra_types.currency_code import Currency
99+
100+
class currency(BaseModel):
101+
alpha_3: Currency
102+
103+
cur = currency(alpha_3='AED')
104+
print(cur)
105+
# > alpha_3='AED'
106+
```
107+
"""
108+
109+
allowed_countries_list = list(
110+
filter(lambda x: x not in _CODES_FOR_BONDS_METAL_TESTING, ISO4217.allowed_countries_list)
111+
)
112+
allowed_currencies = set(allowed_countries_list)
113+
114+
@classmethod
115+
def _validate(cls, currency_symbol: str, _: core_schema.ValidationInfo) -> str:
116+
"""
117+
Validate a subset of the [ISO4217](https://en.wikipedia.org/wiki/ISO_4217) format.
118+
It excludes bonds testing codes and precious metals.
119+
120+
Args:
121+
currency_symbol: The str value to be validated.
122+
_: The Pydantic ValidationInfo.
123+
124+
Returns:
125+
The validated ISO 4217 currency code.
126+
127+
Raises:
128+
PydanticCustomError: If the ISO 4217 currency code is not valid or is bond, precious metal or testing code.
129+
"""
130+
if currency_symbol not in cls.allowed_currencies:
131+
raise PydanticCustomError(
132+
'InvalidCurrency',
133+
'Invalid currency code.'
134+
' See https://en.wikipedia.org/wiki/ISO_4217. '
135+
'Bonds, testing and precious metals codes are not allowed.',
136+
)
137+
return currency_symbol
138+
139+
@classmethod
140+
def __get_pydantic_core_schema__(cls, _: type[Any], __: GetCoreSchemaHandler) -> core_schema.CoreSchema:
141+
"""
142+
Return a Pydantic CoreSchema with the currency subset of the
143+
[ISO4217](https://en.wikipedia.org/wiki/ISO_4217) format.
144+
It excludes bonds testing codes and precious metals.
145+
146+
Args:
147+
_: The source type.
148+
__: The handler to get the CoreSchema.
149+
150+
Returns:
151+
A Pydantic CoreSchema with the subset of the currency subset of the
152+
[ISO4217](https://en.wikipedia.org/wiki/ISO_4217) format.
153+
It excludes bonds testing codes and precious metals.
154+
"""
155+
return core_schema.with_info_after_validator_function(
156+
cls._validate,
157+
core_schema.str_schema(min_length=3, max_length=3),
158+
)
159+
160+
@classmethod
161+
def __get_pydantic_json_schema__(
162+
cls, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
163+
) -> dict[str, Any]:
164+
"""
165+
Return a Pydantic JSON Schema with subset of the [ISO4217](https://en.wikipedia.org/wiki/ISO_4217) format.
166+
Excluding bonds testing codes and precious metals.
167+
168+
Args:
169+
schema: The Pydantic CoreSchema.
170+
handler: The handler to get the JSON Schema.
171+
172+
Returns:
173+
A Pydantic JSON Schema with the subset of the ISO4217 currency code validation. without bonds testing codes
174+
and precious metals.
175+
176+
"""
177+
json_schema = handler(schema)
178+
json_schema.update({'enum': cls.allowed_countries_list})
179+
return json_schema

tests/test_currency_code.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import re
2+
3+
import pycountry
4+
import pytest
5+
from pydantic import BaseModel, ValidationError
6+
7+
from pydantic_extra_types import currency_code
8+
9+
10+
class ISO4217CheckingModel(BaseModel):
11+
currency: currency_code.ISO4217
12+
13+
14+
class CurrencyCheckingModel(BaseModel):
15+
currency: currency_code.Currency
16+
17+
18+
forbidden_currencies = sorted(currency_code._CODES_FOR_BONDS_METAL_TESTING)
19+
20+
21+
@pytest.mark.parametrize('currency', map(lambda code: code.alpha_3, pycountry.currencies))
22+
def test_ISO4217_code_ok(currency: str):
23+
model = ISO4217CheckingModel(currency=currency)
24+
assert model.currency == currency
25+
assert model.model_dump() == {'currency': currency} # test serialization
26+
27+
28+
@pytest.mark.parametrize(
29+
'currency',
30+
filter(
31+
lambda code: code not in currency_code._CODES_FOR_BONDS_METAL_TESTING,
32+
map(lambda code: code.alpha_3, pycountry.currencies),
33+
),
34+
)
35+
def test_everyday_code_ok(currency: str):
36+
model = CurrencyCheckingModel(currency=currency)
37+
assert model.currency == currency
38+
assert model.model_dump() == {'currency': currency} # test serialization
39+
40+
41+
def test_ISO4217_fails():
42+
with pytest.raises(
43+
ValidationError,
44+
match=re.escape(
45+
'1 validation error for ISO4217CheckingModel\ncurrency\n '
46+
'Invalid ISO 4217 currency code. See https://en.wikipedia.org/wiki/ISO_4217 '
47+
"[type=ISO4217, input_value='OMG', input_type=str]"
48+
),
49+
):
50+
ISO4217CheckingModel(currency='OMG')
51+
52+
53+
@pytest.mark.parametrize('forbidden_currency', forbidden_currencies)
54+
def test_forbidden_everyday(forbidden_currency):
55+
with pytest.raises(
56+
ValidationError,
57+
match=re.escape(
58+
'1 validation error for CurrencyCheckingModel\ncurrency\n '
59+
'Invalid currency code. See https://en.wikipedia.org/wiki/ISO_4217. '
60+
'Bonds, testing and precious metals codes are not allowed. '
61+
f"[type=InvalidCurrency, input_value='{forbidden_currency}', input_type=str]"
62+
),
63+
):
64+
CurrencyCheckingModel(currency=forbidden_currency)

tests/test_json_schema.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import pytest
33
from pydantic import BaseModel
44

5+
import pydantic_extra_types
56
from pydantic_extra_types.color import Color
67
from pydantic_extra_types.coordinate import Coordinate, Latitude, Longitude
78
from pydantic_extra_types.country import (
@@ -10,6 +11,7 @@
1011
CountryNumericCode,
1112
CountryShortName,
1213
)
14+
from pydantic_extra_types.currency_code import ISO4217, Currency
1315
from pydantic_extra_types.isbn import ISBN
1416
from pydantic_extra_types.language_code import ISO639_3, ISO639_5
1517
from pydantic_extra_types.mac_address import MacAddress
@@ -22,6 +24,16 @@
2224
languages.sort()
2325
language_families.sort()
2426

27+
currencies = [currency.alpha_3 for currency in pycountry.currencies]
28+
currencies.sort()
29+
everyday_currencies = [
30+
currency.alpha_3
31+
for currency in pycountry.currencies
32+
if currency.alpha_3 not in pydantic_extra_types.currency_code._CODES_FOR_BONDS_METAL_TESTING
33+
]
34+
35+
everyday_currencies.sort()
36+
2537

2638
@pytest.mark.parametrize(
2739
'cls,expected',
@@ -241,6 +253,40 @@
241253
'type': 'object',
242254
},
243255
),
256+
(
257+
ISO4217,
258+
{
259+
'properties': {
260+
'x': {
261+
'title': 'X',
262+
'type': 'string',
263+
'enum': currencies,
264+
'maxLength': 3,
265+
'minLength': 3,
266+
}
267+
},
268+
'required': ['x'],
269+
'title': 'Model',
270+
'type': 'object',
271+
},
272+
),
273+
(
274+
Currency,
275+
{
276+
'properties': {
277+
'x': {
278+
'title': 'X',
279+
'type': 'string',
280+
'enum': everyday_currencies,
281+
'maxLength': 3,
282+
'minLength': 3,
283+
}
284+
},
285+
'required': ['x'],
286+
'title': 'Model',
287+
'type': 'object',
288+
},
289+
),
244290
],
245291
)
246292
def test_json_schema(cls, expected):

0 commit comments

Comments
 (0)