Skip to content

Commit 12e2e13

Browse files
authored
Feature/exchange rates (#14)
* Add live exchange rates service * Add exchange rates conversion service * Add historical exchange rates service
1 parent 371e485 commit 12e2e13

File tree

11 files changed

+381
-0
lines changed

11 files changed

+381
-0
lines changed

src/abstract_api/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from .avatars import Avatars
44
from .email_validation import EmailValidation
5+
from .exchange_rates import ExchangeRates
56
from .holidays import Holidays
67
from .iban_validation import IBANValidation
78
from .ip_geolocation import IPGeolocation
@@ -11,6 +12,7 @@
1112
__all__: Final[list[str]] = [
1213
"Avatars",
1314
"EmailValidation",
15+
"ExchangeRates",
1416
"Holidays",
1517
"IBANValidation",
1618
"IPGeolocation",
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from typing import Final
2+
3+
from .exchange_rates import ExchangeRates
4+
from .exchange_rates_conversion_response import ExchangeRatesConversionResponse
5+
from .historical_exchange_rates_response import HistoricalExchangeRatesResponse
6+
from .live_exchange_rates_response import LiveExchangeRatesResponse
7+
8+
__all__: Final[list[str]] = [
9+
"ExchangeRates",
10+
"ExchangeRatesConversionResponse",
11+
"HistoricalExchangeRatesResponse",
12+
"LiveExchangeRatesResponse"
13+
]
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from typing import TYPE_CHECKING
2+
3+
import requests
4+
5+
from abstract_api.bases import JSONResponse
6+
7+
8+
class ExchangeRate:
9+
"""Exchange rate entity in live/historical exchange rates response."""
10+
11+
def __init__(self, currency: str, rate: float) -> None:
12+
"""Initializes a new Country."""
13+
self._currency = currency
14+
self._rate = rate
15+
16+
@property
17+
def currency(self) -> str:
18+
"""Target currency."""
19+
return self._currency
20+
21+
@property
22+
def rate(self) -> float:
23+
"""Exchange rate versus the base currency."""
24+
return self._rate
25+
26+
27+
class MultipleExchangeRatesResponse(JSONResponse):
28+
"""Base response for services that return multiple exchange rates."""
29+
30+
def __init__(
31+
self,
32+
response: requests.models.Response,
33+
response_fields: frozenset[str]
34+
) -> None:
35+
"""Initializes a new MultipleExchangeRatesResponse."""
36+
super().__init__(response)
37+
self._response_fields = response_fields
38+
not_in_response = object()
39+
for field in response_fields:
40+
if TYPE_CHECKING:
41+
assert isinstance(self.meta.body_json, dict)
42+
value = self.meta.body_json.get(field, not_in_response)
43+
# Set property only if field was returned
44+
if value is not not_in_response:
45+
# TODO: Move to parent class
46+
if field == "exchange_rates":
47+
exchange_rates = []
48+
for currency, rate in value.items():
49+
exchange_rates.append(
50+
ExchangeRate(currency=currency, rate=rate)
51+
)
52+
value = frozenset(exchange_rates)
53+
setattr(self, f"_{field}", value)
54+
55+
@property
56+
def base(self) -> str | None:
57+
"""The base currency used to get the exchange rates."""
58+
return self._get_response_field("base")
59+
60+
@property
61+
def exchange_rates(self) -> frozenset[ExchangeRate] | None:
62+
"""Target currencies exchange rates versus the base currency."""
63+
return self._get_response_field("exchange_rates")
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
from typing import Iterable
2+
3+
from abstract_api.bases import BaseService
4+
from abstract_api.exceptions import ResponseParseError
5+
6+
from .exchange_rates_conversion_response import ExchangeRatesConversionResponse
7+
from .historical_exchange_rates_response import HistoricalExchangeRatesResponse
8+
from .live_exchange_rates_response import LiveExchangeRatesResponse
9+
10+
11+
class ExchangeRates(BaseService):
12+
"""AbstractAPI exchange rates service.
13+
14+
Used to looking up the latest exchange rates for 80+ currencies, getting
15+
historical exchange rates, and converting an arbitrary amount from one
16+
currency to another.
17+
18+
Attributes:
19+
_subdomain: Exchange rates service subdomain.
20+
"""
21+
_subdomain: str = "exchange-rates"
22+
23+
@staticmethod
24+
def _target_as_param(target: Iterable[str] | None = None) -> str | None:
25+
"""Builds 'target' URL query parameter.
26+
27+
Builds a string that contains selected target currencies to be used
28+
as a URL query parameter.
29+
30+
Args:
31+
target: Selected target currencies.
32+
33+
Returns:
34+
Comma-separated string with all selected target currencies.
35+
"""
36+
if target is None:
37+
return None
38+
39+
return ",".join(target)
40+
41+
def live(
42+
self,
43+
base: str,
44+
target: Iterable[str] | None = None
45+
) -> LiveExchangeRatesResponse:
46+
"""Finds exchange rates from base currency to target currency/ies.
47+
48+
Args:
49+
base: Base currency used to get the latest exchange rate(s) for.
50+
Uses the ISO 4217 currency standard (e.g., USD for United
51+
States Dollars).
52+
target: The target currency or currencies to get the exchange rate
53+
of versus the base currency. Like the base parameters, any
54+
currency passed here follows the ISO 4217 standard.
55+
56+
Returns:
57+
VATValidationResponse representing API call response.
58+
"""
59+
response = self._service_request(
60+
action="live",
61+
base=base,
62+
target=self._target_as_param(target)
63+
)
64+
65+
try:
66+
live_exchange_rates_response = LiveExchangeRatesResponse(
67+
response=response
68+
)
69+
except Exception as e:
70+
raise ResponseParseError(
71+
"Failed to parse response as LiveExchangeRatesResponse"
72+
) from e
73+
74+
return live_exchange_rates_response
75+
76+
def convert(
77+
self,
78+
base: str,
79+
target: str,
80+
date: str | None = None,
81+
base_amount: float | None = None
82+
) -> ExchangeRatesConversionResponse:
83+
"""Finds exchange rates from base currency to target currency/ies.
84+
85+
Args:
86+
base: Base currency used to get the latest exchange rate(s) for.
87+
Uses the ISO 4217 currency standard (e.g., USD for United
88+
States Dollars).
89+
target: The target currency to convert the base_amount to.
90+
Like the base parameters, any currency passed here follows the
91+
ISO 4217 standard. Note that unlike the other endpoints,
92+
convert only accepts one target currency at a time.
93+
date: The historical date you’d like to get rates from, in the
94+
format of YYYY-MM-DD. If you leave this blank, it will use the
95+
latest available rate.
96+
base_amount: The amount of the base currency you would like to
97+
convert to the target currency.
98+
99+
Returns:
100+
ExchangeRatesConversionResponse representing API call response.
101+
"""
102+
response = self._service_request(
103+
action="convert",
104+
base=base,
105+
target=target,
106+
date=date,
107+
base_amount=base_amount
108+
)
109+
110+
try:
111+
exchange_rate_conversion_response = (
112+
ExchangeRatesConversionResponse(response=response)
113+
)
114+
except Exception as e:
115+
raise ResponseParseError(
116+
"Failed to parse response as "
117+
"ExchangeRatesConversionResponse"
118+
) from e
119+
120+
return exchange_rate_conversion_response
121+
122+
def historical(
123+
self,
124+
base: str,
125+
date: str,
126+
target: Iterable[str] | None = None
127+
) -> HistoricalExchangeRatesResponse:
128+
"""Finds historical exchange rates from base to target currencies.
129+
130+
Args:
131+
base: Base currency used to get the latest exchange rate(s) for.
132+
Uses the ISO 4217 currency standard (e.g., USD for United
133+
States Dollars).
134+
date: The historical date you’d like to get rates from, in the
135+
format of YYYY-MM-DD.
136+
target: The target currency or currencies to get the exchange rate
137+
of versus the base currency. Like the base parameters, any
138+
currency passed here follows the ISO 4217 standard.
139+
140+
Returns:
141+
HistoricalExchangeRatesResponse representing API call response.
142+
"""
143+
response = self._service_request(
144+
action="historical",
145+
base=base,
146+
target=self._target_as_param(target),
147+
date=date
148+
)
149+
150+
try:
151+
historical_exchange_rate_response = (
152+
HistoricalExchangeRatesResponse(response=response)
153+
)
154+
except Exception as e:
155+
raise ResponseParseError(
156+
"Failed to parse response as "
157+
"HistoricalExchangeRatesResponse"
158+
) from e
159+
160+
return historical_exchange_rate_response
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from typing import TYPE_CHECKING
2+
3+
import requests
4+
5+
from abstract_api.bases import JSONResponse
6+
7+
from .response_fields import CONVERSION_RESPONSE_FIELDS
8+
9+
10+
class ExchangeRatesConversionResponse(JSONResponse):
11+
"""Exchange rate conversion service response."""
12+
13+
def __init__(self, response: requests.models.Response) -> None:
14+
"""Initializes a new ExchangeRateConversionResponse."""
15+
super().__init__(response)
16+
self._response_fields = CONVERSION_RESPONSE_FIELDS
17+
not_in_response = object()
18+
for field in CONVERSION_RESPONSE_FIELDS:
19+
if TYPE_CHECKING:
20+
assert isinstance(self.meta.body_json, dict)
21+
value = self.meta.body_json.get(field, not_in_response)
22+
# Set property only if field was returned
23+
if value is not not_in_response:
24+
# TODO: Move to parent class
25+
setattr(self, f"_{field}", value)
26+
27+
@property
28+
def base(self) -> str | None:
29+
"""The base currency used to get the exchange rates."""
30+
return self._get_response_field("base")
31+
32+
@property
33+
def target(self) -> str | None:
34+
"""The target currency that the base_amount was converted into."""
35+
return self._get_response_field("target")
36+
37+
@property
38+
def date(self) -> str | None:
39+
"""The date the currencies were pulled from.
40+
41+
This is per successful request.
42+
"""
43+
return self._get_response_field("date")
44+
45+
@property
46+
def base_amount(self) -> str | None:
47+
"""The amount of the base currency from the request."""
48+
return self._get_response_field("base_amount")
49+
50+
@property
51+
def converted_amount(self) -> str | None:
52+
"""The amount after conversion.
53+
54+
The amount of the target currency that the base_amount has been
55+
converted into.
56+
"""
57+
return self._get_response_field("converted_amount")
58+
59+
@property
60+
def exchange_rate(self) -> str | None:
61+
"""The exchange rate used in conversion."""
62+
return self._get_response_field("exchange_rate")
63+
64+
@property
65+
def last_updated(self) -> str | None:
66+
"""The exchange rate used in conversion."""
67+
return self._get_response_field("last_updated")
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import requests
2+
3+
from ._multiple_exchange_rates_response import MultipleExchangeRatesResponse
4+
from .response_fields import HISTORICAL_RESPONSE_FIELDS
5+
6+
7+
class HistoricalExchangeRatesResponse(MultipleExchangeRatesResponse):
8+
"""Historical exchange rates service response."""
9+
10+
def __init__(self, response: requests.models.Response) -> None:
11+
"""Initializes a new HistoricalExchangeRatesResponse."""
12+
super().__init__(response, HISTORICAL_RESPONSE_FIELDS)
13+
14+
@property
15+
def date(self) -> str | None:
16+
"""The date the currencies were pulled from.
17+
18+
This is per successful request.
19+
"""
20+
return self._get_response_field("date")
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import requests
2+
3+
from ._multiple_exchange_rates_response import MultipleExchangeRatesResponse
4+
from .response_fields import LIVE_RESPONSE_FIELDS
5+
6+
7+
class LiveExchangeRatesResponse(MultipleExchangeRatesResponse):
8+
"""Live exchange rates service response."""
9+
10+
def __init__(self, response: requests.models.Response) -> None:
11+
"""Initializes a new LiveExchangeRatesResponse."""
12+
super().__init__(response, LIVE_RESPONSE_FIELDS)
13+
14+
@property
15+
def last_updated(self) -> int | None:
16+
"""The Unix timestamp of when the returned data was last updated."""
17+
return self._get_response_field("last_updated")
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from typing import Final
2+
3+
from .conversion import CONVERSION_RESPONSE_FIELDS
4+
from .historical import HISTORICAL_RESPONSE_FIELDS
5+
from .live import LIVE_RESPONSE_FIELDS
6+
7+
__all__: Final[list[str]] = [
8+
"CONVERSION_RESPONSE_FIELDS",
9+
"HISTORICAL_RESPONSE_FIELDS",
10+
"LIVE_RESPONSE_FIELDS"
11+
]
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Response fields of exchange rates conversion service endpoint."""
2+
3+
4+
CONVERSION_RESPONSE_FIELDS: frozenset[str] = frozenset({
5+
"base",
6+
"target",
7+
"date",
8+
"base_amount",
9+
"converted_amount",
10+
"exchange_rate",
11+
"last_updated"
12+
})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Response fields of historical exchange rates service endpoint."""
2+
3+
4+
HISTORICAL_RESPONSE_FIELDS: frozenset[str] = frozenset({
5+
"base",
6+
"date",
7+
"exchange_rates"
8+
})

0 commit comments

Comments
 (0)