Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implemented cache system for API services #7

Merged
merged 10 commits into from
Feb 6, 2025
5 changes: 4 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ jobs:
cache: pip
cache-dependency-path: pyproject.toml

- name: Start docker-compose.yaml services
run: docker compose up -d

- name: Install dependencies
run: pip install .[dev]

Expand Down Expand Up @@ -50,4 +53,4 @@ jobs:
run: pip install .[dev]

- name: Test with pytest
run: pytest -vv
run: pytest -vv -m "not external_service"
44 changes: 44 additions & 0 deletions anycoin/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from aiocache import Cache as _Cache

from ._enums import CoinSymbols, QuoteSymbols


class Cache(_Cache):
"""
This class is just a wrapper around the aiocache.Cache class

You can instantiate a cache type using the ``cache_type`` attribute like-
this:

>>> from anycoin.cache import Cache
>>> Cache(Cache.REDIS)
RedisCache (127.0.0.1:6379)

OR
>>> from anycoin.cache import Cache
>>> Cache(Cache.MEMCACHED)
MemcachedCache (127.0.0.1:6379)

``Cache.MEMORY`` is also available for use. See the aiocache documentation-
to see which types are actually supported: https://aiocache.aio-libs.org/en/latest/
"""


def _get_cache_key_for_get_coin_quotes_method_params(
coins: list[CoinSymbols],
quotes_in: list[QuoteSymbols],
) -> str:
"""
Example result:
"coins:btc,ltc;quotes_in:usd,eur"
"""

assert coins
assert quotes_in

cache_key = ''

cache_key += 'coins:' + ','.join(coin.value for coin in coins)

cache_key += ';quotes_in:' + ','.join(quote.value for quote in quotes_in)
return cache_key
59 changes: 29 additions & 30 deletions anycoin/response_models.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
from decimal import Decimal
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, Field

from ._enums import CoinSymbols, QuoteSymbols
from .abc import APIService


def _api_service_serializer(value: APIService) -> str:
return str(value)


class QuoteRow(BaseModel):
Expand All @@ -20,20 +16,24 @@ class CoinRow(BaseModel):

class CoinQuotes(BaseModel):
coins: dict[CoinSymbols, CoinRow]
api_service: APIService
raw_data: dict = Field(description=('Raw API response data'))
api_service: Literal['coinmarketcap', 'coingecko'] = Field(
description='API Service Name'
)
raw_data: dict = Field(description='Raw API response data')

@staticmethod
async def from_cmc_raw_data(
api_service: APIService, raw_data: dict
) -> 'CoinQuotes':
async def from_cmc_raw_data(raw_data: dict) -> 'CoinQuotes':
from anycoin.services.coinmarketcap import ( # noqa: PLC0415
CoinMarketCapService,
)

async def get_coin_quotes(coin_id) -> dict[CoinSymbols, QuoteRow]:
quotes: dict[CoinSymbols, QuoteRow] = {}
for quote_coin_id, quote_data in raw_data['data'][coin_id][
'quote'
].items():
quote_coin_symbol: QuoteSymbols = (
await api_service.get_quote_symbol_by_id(
await CoinMarketCapService.get_quote_symbol_by_id(
quote_id=str(quote_coin_id)
)
)
Expand All @@ -45,8 +45,10 @@ async def get_coin_quotes(coin_id) -> dict[CoinSymbols, QuoteRow]:

coins_data: dict[CoinSymbols, CoinRow] = {}
for coin_id, coin_data in raw_data['data'].items():
coin_symbol: CoinSymbols = await api_service.get_coin_symbol_by_id(
coin_id=str(coin_id)
coin_symbol: CoinSymbols = (
await CoinMarketCapService.get_coin_symbol_by_id(
coin_id=str(coin_id)
)
)
quotes: dict[CoinSymbols, QuoteRow] = await get_coin_quotes(
coin_id
Expand All @@ -55,19 +57,21 @@ async def get_coin_quotes(coin_id) -> dict[CoinSymbols, QuoteRow]:

return CoinQuotes(
coins=coins_data,
api_service=api_service,
api_service='coinmarketcap',
raw_data=raw_data,
)

@staticmethod
async def from_cgk_raw_data(
api_service: APIService, raw_data: dict
) -> 'CoinQuotes':
async def from_cgk_raw_data(raw_data: dict) -> 'CoinQuotes':
from anycoin.services.coingecko import ( # noqa: PLC0415
CoinGeckoService,
)

async def get_coin_quotes(coin_id) -> dict[CoinSymbols, QuoteRow]:
quotes: dict[CoinSymbols, QuoteRow] = {}
for quote_coin_id, quote_value in raw_data[coin_id].items():
quote_coin_symbol: QuoteSymbols = (
await api_service.get_quote_symbol_by_id(
await CoinGeckoService.get_quote_symbol_by_id(
quote_id=str(quote_coin_id)
)
)
Expand All @@ -79,8 +83,10 @@ async def get_coin_quotes(coin_id) -> dict[CoinSymbols, QuoteRow]:

coins_data: dict[CoinSymbols, CoinRow] = {}
for coin_id, coin_data in raw_data.items():
coin_symbol: CoinSymbols = await api_service.get_coin_symbol_by_id(
coin_id=str(coin_id)
coin_symbol: CoinSymbols = (
await CoinGeckoService.get_coin_symbol_by_id(
coin_id=str(coin_id)
)
)
quotes: dict[CoinSymbols, QuoteRow] = await get_coin_quotes(
coin_id
Expand All @@ -89,7 +95,7 @@ async def get_coin_quotes(coin_id) -> dict[CoinSymbols, QuoteRow]:

return CoinQuotes(
coins=coins_data,
api_service=api_service,
api_service='coingecko',
raw_data=raw_data,
)

Expand All @@ -98,12 +104,5 @@ def __str__(self) -> str:

def __repr__(self) -> str:
return (
f'CoinQuotes(coins={self.coins}, api_service={self.api_service})'
f"CoinQuotes(coins={self.coins}, api_service='{self.api_service}')"
)

model_config = ConfigDict(
arbitrary_types_allowed=True,
json_encoders={
APIService: _api_service_serializer,
},
)
94 changes: 70 additions & 24 deletions anycoin/services/coingecko.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
from http import HTTPStatus

import httpx
from aiocache.lock import RedLock

from .._enums import CoinSymbols, QuoteSymbols
from .._mapped_ids import get_cgk_coin_ids as _get_cgk_coin_ids
from .._mapped_ids import get_cgk_quotes_ids as _get_cgk_quotes_ids
from ..cache import Cache, _get_cache_key_for_get_coin_quotes_method_params
from ..exeptions import (
CoinNotSupportedCGK as CoinNotSupportedCGKException,
)
Expand All @@ -18,39 +20,54 @@


class CoinGeckoService(BaseAPIService):
def __init__(self, api_key: str) -> None:
def __init__(
self,
api_key: str,
cache: Cache | None = None,
cache_ttl: int = 300,
) -> None:
self._api_key = api_key
self._cache = cache
self._cache_ttl = cache_ttl

self._cache_lock = None
if self._cache is not None:
self._cache_lock = RedLock(
self._cache,
key='get_coin_quotes',
lease=20,
)

async def get_coin_quotes(
self,
coins: list[CoinSymbols],
quotes_in: list[QuoteSymbols],
) -> CoinQuotes:
try:
coin_ids: list[str] = [
await self.get_coin_id_by_symbol(coin) for coin in coins
]
convert_ids: list[str] = [
await self.get_quote_id_by_symbol(quote) for quote in quotes_in
]
except CoinNotSupportedCGKException as expt:
raise GetCoinQuotesException(str(expt)) from expt

except QuoteCoinNotSupportedCGKException as expt:
raise GetCoinQuotesException(str(expt)) from expt
if self._cache is None:
coin_quotes: CoinQuotes = await self._get_coin_quotes(
coins=coins, quotes_in=quotes_in
)
else:
async with self._cache_lock:
cache_key: str = (
_get_cache_key_for_get_coin_quotes_method_params(
coins=coins, quotes_in=quotes_in
)
)

params = {
'ids': ','.join(coin_ids),
'vs_currencies': ','.join(convert_ids),
'precision': 'full',
}
if cached_value := await self._cache.get(cache_key):
coin_quotes = CoinQuotes.model_validate_json(cached_value)
else:
coin_quotes: CoinQuotes = await self._get_coin_quotes(
coins=coins, quotes_in=quotes_in
)
await self._cache.set(
cache_key,
coin_quotes.model_dump_json(),
ttl=self._cache_ttl,
)

raw_data = await self._send_request(
path='/simple/price', method='get', params=params
)
return await CoinQuotes.from_cgk_raw_data(
api_service=self, raw_data=raw_data
)
return coin_quotes

@staticmethod
async def get_coin_id_by_symbol(coin_symbol: CoinSymbols) -> str:
Expand Down Expand Up @@ -105,6 +122,35 @@ async def get_quote_symbol_by_id(
coin_symbol_str = coins[0][0]
return QuoteSymbols(coin_symbol_str)

async def _get_coin_quotes(
self,
coins: list[CoinSymbols],
quotes_in: list[QuoteSymbols],
) -> CoinQuotes:
try:
coin_ids: list[str] = [
await self.get_coin_id_by_symbol(coin) for coin in coins
]
convert_ids: list[str] = [
await self.get_quote_id_by_symbol(quote) for quote in quotes_in
]
except CoinNotSupportedCGKException as expt:
raise GetCoinQuotesException(str(expt)) from expt

except QuoteCoinNotSupportedCGKException as expt:
raise GetCoinQuotesException(str(expt)) from expt

params = {
'ids': ','.join(coin_ids),
'vs_currencies': ','.join(convert_ids),
'precision': 'full',
}

raw_data = await self._send_request(
path='/simple/price', method='get', params=params
)
return await CoinQuotes.from_cgk_raw_data(raw_data=raw_data)

async def _send_request(
self,
path: str,
Expand Down
Loading