Skip to content

Commit

Permalink
first commit
Browse files Browse the repository at this point in the history
  • Loading branch information
max-muoto committed Jun 12, 2024
0 parents commit 238b93c
Show file tree
Hide file tree
Showing 8 changed files with 397 additions and 0 deletions.
Empty file added .github/workflows/ci.yml
Empty file.
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# oxr

`oxr` is a thin-wrapper over the [Open Exchange Rates API](https://openexchangerates.org/). It provides a type-safe interface to the API, allowing you to easily fetch exchange rates and convert between currencies.


## Installation

```bash
pip install oxr
```


## Usage

```python
import oxr

import datetime as dt

# Base default to USD
client = oxr.Client(app_id='your_app_id')

# Fetch the latest exchange rates
rates = client.latest(symbols=['EUR', 'JPY'])

# Convert 100 USD to EUR
converted = client.convert(amount, 'USD', 'EUR')

# Get time series data
timeseries = client.timeseries(start_date=dt.date(2020, 1, 1), end_date=dt.date(2020, 1, 31), symbols=['EUR', 'JPY'])

# Get open, high, low, close data
olhc = client.olhc(start_time=dt.datetime(2020, 1, 1), period="1m", symbols=['EUR', 'JPY'])
```

## License

MIT


29 changes: 29 additions & 0 deletions oxr/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Client for Open Exchange Rates API.
Examples:
```python
import openexchangerates as oxr
client = oxr.Client("YOUR_APP_ID")
# Get the latest exchange rates.
latest = client.latest("USD")
# Get historical exchange rates.
historical = client.historical("2021-01-01")
# Get time series exchange rates.
time_series = client.time_series(dt.date(2021, 1, 1), dt.date(2021, 1, 31))
# Get OHLC exchange rates.
ohlc = client.ohlc(dt.date(2021, 1, 1), dt.date(2021, 1, 31))
# Convert currency.
conversion = client.convert("USD", "EUR", 100)
```
"""

from openexchangerates.core import Client

__all__ = ["Client"]
20 changes: 20 additions & 0 deletions oxr/_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from typing import Final
from openexchangerates.exceptions import (
Error,
InvalidAppID,
InvalidCurrency,
InvalidDate,
InvalidDateRange,
)

_EXCEPTION_MAP: Final[dict[tuple[int, str], type[Error]]] = {
(401, "invalid_app_id"): InvalidAppID,
(400, "invalid_currency"): InvalidCurrency,
(400, "invalid_date"): InvalidDate,
(400, "invalid_date_range"): InvalidDateRange,
}


def get(code: int, message: str) -> type[Error] | None:
"""Get the error class for the given code and message."""
return _EXCEPTION_MAP.get((code, message), None)
172 changes: 172 additions & 0 deletions oxr/core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
from __future__ import annotations

from oxr import responses, exceptions, _exceptions
from typing import Any, Final, Iterable, Literal, TypeAlias, cast
import datetime as dt
import requests


_Endpoint: TypeAlias = Literal[
"latest",
"historical",
"convert",
"time-series",
"ohlc",
"usage",
]

_BASE_URL: Final = "https://openexchangerates.org/api"


class Client:
"""A client for the Open Exchange Rates API."""

def __init__(
self,
app_id: str,
*,
base: responses.Currency = "USD",
base_url: str = _BASE_URL,
) -> None:
self.app_id = app_id
self._base = base
self._base_url = base_url

def _get(
self,
endpoint: _Endpoint,
params: dict[str, Any],
) -> dict[str, Any]:
"""Make a GET request to the API."""
url = f"{self._base_url}/{endpoint}.json"
response = requests.get(url, params={**params, "app_id": self.app_id})
try:
response.raise_for_status()
except requests.HTTPError as error:
msg = response.json().get("message", "")
exc = _exceptions.get(response.status_code, msg)
if exc is not None:
raise exc from error
raise exceptions.Error from error

return response.json()

def latest(
self,
base: str | None = None,
symbols: Iterable[responses.Currency] | None = None,
show_alternative: bool = False,
) -> responses.Rates:
"""Get the latest exchange rates.
Args:
base: The base currency.
symbols: The target currencies.
show_alternative: Whether to show alternative currencies.
Such as black market and digital currency rates.
"""
params = {"base": base or self._base, "show_alternative": show_alternative}
if symbols is not None:
params["symbols"] = ",".join(symbols)
return cast(responses.Rates, self._get("latest", params))

def historical(
self,
date: dt.date,
base: str | None = None,
symbols: Iterable[responses.Currency] | None = None,
show_alternative: bool = False,
) -> responses.Rates:
"""Get historical exchange rates.
Args:
date: The date of the rates.
base: The base currency.
symbols: The target currencies.
show_alternative: Whether to show alternative currencies.
Such as black market and digital currency rates.
"""
params = {
"base": base or self._base,
"date": date.isoformat(),
"show_alternative": show_alternative,
}

if symbols is not None:
params["symbols"] = ",".join(symbols)
return cast(responses.Rates, self._get("historical", params))

def convert(
self,
amount: float,
from_: str,
to: str,
) -> responses.Conversion:
"""Convert an amount between two currencies.
Args:
amount: The amount to convert.
from_: The source currency.
to: The target currency.
date: The date of the rates to use.
"""
params = {"from": from_, "to": to, "amount": amount}
return cast(responses.Conversion, self._get("convert", params))

def time_series(
self,
start: dt.date,
end: dt.date,
symbols: Iterable[responses.Currency] | None = None,
base: str | None = None,
show_alternative: bool = False,
) -> responses.TimeSeries:
"""Get historical exchange rates for a range of dates.
Args:
start: The start date of the range.
end: The end date of the range.
symbols: The target currencies.
base: The base currency.
show_alternative: Whether to show alternative currencies.
Such as black market and digital currency rates.
"""
params = {
"start": start.isoformat(),
"end": end.isoformat(),
"show_alternative": show_alternative,
}
params["base"] = base or self._base
if symbols is not None:
params["symbols"] = ",".join(symbols)
return cast(responses.TimeSeries, self._get("time-series", params))

def olhc(
self,
start_time: dt.datetime,
period: Literal["1m", "5m", "15m", "30m", "1h", "12", "1d", "1w", "1mo"],
base: str | None = None,
symbols: Iterable[responses.Currency] | None = None,
show_alternative: bool = False,
) -> responses.OHLC:
"""Get the latest open, low, high, and close rates for a currency.
Args:
base: The base currency.
symbols: The target currencies.
show_alternative: Whether to show alternative currencies.
Such as black market and digital currency rates.
"""
params = {
"start_time": start_time.isoformat(),
"period": period,
"show_alternative": show_alternative,
}
params["base"] = base or self._base
if symbols is not None:
params["symbols"] = ",".join(symbols)
return cast(responses.OHLC, self._get("ohlc", params))

def usage(self) -> dict[str, Any]:
"""Get the usage statistics for the API key."""
return self._get("usage", {})
53 changes: 53 additions & 0 deletions oxr/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from typing import ClassVar, Final


class Error(Exception):
"""Base exception class for `openexchangerates`."""

code: ClassVar[int | None] = None


class InvalidCurrency(Error):
"""Raised when an invalid currency is provided."""

code = 400


class InvalidDate(Error):
"""Raised when an invalid date is provided."""

code = 400


class InvalidDateRange(Error):
"""Raised when an invalid date range is provided."""

code = 400


class InvalidAppID(Error):
"""Raised when an invalid App ID is provided."""

code = 401


from typing import Final
from openexchangerates.exceptions import (
Error,
InvalidAppID,
InvalidCurrency,
InvalidDate,
InvalidDateRange,
)

_MAPPING: Final[dict[tuple[int, str], type[Error]]] = {
(401, "invalid_app_id"): InvalidAppID,
(400, "invalid_currency"): InvalidCurrency,
(400, "invalid_date"): InvalidDate,
(400, "invalid_date_range"): InvalidDateRange,
}


def get(code: int, message: str) -> type[Error] | None:
"""Get the error class for the given code and message."""
return MAPPING.get((code, message), None)
Loading

0 comments on commit 238b93c

Please sign in to comment.