From d42bf5dd07c9e6df03beabc6956180e037ba5413 Mon Sep 17 00:00:00 2001 From: HK-Mattew Date: Thu, 6 Feb 2025 09:13:28 -0300 Subject: [PATCH 01/10] Implemented caching system in the ge_coin_quotes method in CoinGeckoService + Changed the type of the api_service field in the CoinQuotes model. Issue: #4 --- anycoin/cache.py | 44 +++++++++ anycoin/response_models.py | 59 ++++++------ anycoin/services/coingecko.py | 94 ++++++++++++++----- anycoin/services/coinmarketcap.py | 4 +- pyproject.toml | 6 ++ tests/_interfaces/test_async_.py | 2 +- tests/_interfaces/test_sync.py | 2 +- tests/services/test_coingecko.py | 135 +++++++++++++++++++++------ tests/services/test_coinmarketcap.py | 8 +- tests/test_response_models.py | 28 ++++-- 10 files changed, 286 insertions(+), 96 deletions(-) create mode 100644 anycoin/cache.py diff --git a/anycoin/cache.py b/anycoin/cache.py new file mode 100644 index 0000000..9b7beb7 --- /dev/null +++ b/anycoin/cache.py @@ -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 diff --git a/anycoin/response_models.py b/anycoin/response_models.py index 93ced2e..3ba4154 100644 --- a/anycoin/response_models.py +++ b/anycoin/response_models.py @@ -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): @@ -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) ) ) @@ -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 @@ -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) ) ) @@ -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 @@ -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, ) @@ -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, - }, - ) diff --git a/anycoin/services/coingecko.py b/anycoin/services/coingecko.py index 64f7790..92c8a6e 100644 --- a/anycoin/services/coingecko.py +++ b/anycoin/services/coingecko.py @@ -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, ) @@ -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: @@ -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, diff --git a/anycoin/services/coinmarketcap.py b/anycoin/services/coinmarketcap.py index a79e7e4..c4af526 100644 --- a/anycoin/services/coinmarketcap.py +++ b/anycoin/services/coinmarketcap.py @@ -47,9 +47,7 @@ async def get_coin_quotes( raw_data = await self._send_request( path='/cryptocurrency/quotes/latest', method='get', params=params ) - return await CoinQuotes.from_cmc_raw_data( - api_service=self, raw_data=raw_data - ) + return await CoinQuotes.from_cmc_raw_data(raw_data=raw_data) @staticmethod async def get_coin_id_by_symbol(coin_symbol: CoinSymbols) -> str: diff --git a/pyproject.toml b/pyproject.toml index ec068a1..61b7436 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,12 @@ dev = [ "pytest-cov>=6.0.0", "respx>=0.22.0", ] +redis-cache = [ + "aiocache[redis]>=0.12.3" +] +memcached-cache = [ + "aiocache[memcached]>=0.12.3" +] [project.urls] Homepage = "https://github.com/HK-Mattew/anycoin" diff --git a/tests/_interfaces/test_async_.py b/tests/_interfaces/test_async_.py index f0c690d..01c67f3 100644 --- a/tests/_interfaces/test_async_.py +++ b/tests/_interfaces/test_async_.py @@ -44,7 +44,7 @@ async def test_get_coin_quotes(): quotes_in=[QuoteSymbols.usd], ) assert result.coins[CoinSymbols.btc] - assert result.api_service is cgk_service + assert result.api_service == 'coingecko' assert result.raw_data diff --git a/tests/_interfaces/test_sync.py b/tests/_interfaces/test_sync.py index 357c127..81fb14b 100644 --- a/tests/_interfaces/test_sync.py +++ b/tests/_interfaces/test_sync.py @@ -42,7 +42,7 @@ def test_get_coin_quotes(): quotes_in=[QuoteSymbols.usd], ) assert result.coins[CoinSymbols.btc] - assert result.api_service is cgk_service + assert result.api_service == 'coingecko' assert result.raw_data diff --git a/tests/services/test_coingecko.py b/tests/services/test_coingecko.py index 5709a32..e583953 100644 --- a/tests/services/test_coingecko.py +++ b/tests/services/test_coingecko.py @@ -2,12 +2,14 @@ from decimal import Decimal from enum import Enum from http import HTTPStatus +from unittest.mock import AsyncMock import httpx import pytest import respx from anycoin import CoinSymbols, QuoteSymbols +from anycoin.cache import Cache from anycoin.exeptions import ( CoinNotSupportedCGK as CoinNotSupportedCGKException, ) @@ -81,9 +83,9 @@ async def test_send_request_path_not_startwith_bar(): ) ) - cmc_service = CoinGeckoService(api_key='') + cgk_service = CoinGeckoService(api_key='') - result = await cmc_service._send_request( + result = await cgk_service._send_request( path='simple/price', # path not start with / method='get', ) @@ -102,9 +104,9 @@ async def test_send_request(): ) ) - cmc_service = CoinGeckoService(api_key='') + cgk_service = CoinGeckoService(api_key='') - result = await cmc_service._send_request( + result = await cgk_service._send_request( path='/simple/price', method='get' ) assert result == EXAMPLE_RESPONSE @@ -122,13 +124,13 @@ async def test_send_request_no_success(): ) ) - cmc_service = CoinGeckoService(api_key='') + cgk_service = CoinGeckoService(api_key='') with pytest.raises( GetCoinQuotesException, match=('Error retrieving coin quotes. API response:'), ): - await cmc_service._send_request(path='/simple/price', method='get') + await cgk_service._send_request(path='/simple/price', method='get') @respx.mock @@ -138,13 +140,13 @@ async def test_send_request_request_error(): side_effect=httpx.RequestError ) - cmc_service = CoinGeckoService(api_key='') + cgk_service = CoinGeckoService(api_key='') with pytest.raises( GetCoinQuotesException, match=('Error retrieving coin quotes'), ): - await cmc_service._send_request(path='/simple/price', method='get') + await cgk_service._send_request(path='/simple/price', method='get') @respx.mock @@ -154,13 +156,13 @@ async def test_send_request_json_decode_error(): side_effect=json.JSONDecodeError('abc', '123', 0) ) - cmc_service = CoinGeckoService(api_key='') + cgk_service = CoinGeckoService(api_key='') with pytest.raises( GetCoinQuotesException, match=('Error retrieving coin quotes'), ): - await cmc_service._send_request(path='/simple/price', method='get') + await cgk_service._send_request(path='/simple/price', method='get') async def test_get_coin_quotes_coin_not_supported(): @@ -169,13 +171,13 @@ class FakeCoinSymbols(str, Enum): coin_symbol = FakeCoinSymbols.invalid_member - cmc_service = CoinGeckoService(api_key='') + cgk_service = CoinGeckoService(api_key='') with pytest.raises( GetCoinQuotesException, match=f'Coin {coin_symbol} not supported', ): - await cmc_service.get_coin_quotes( + await cgk_service.get_coin_quotes( coins=[coin_symbol], quotes_in=[QuoteSymbols.usd] ) @@ -186,13 +188,13 @@ class FakeQuoteSymbols(str, Enum): quote_symbol = FakeQuoteSymbols.invalid_member - cmc_service = CoinGeckoService(api_key='') + cgk_service = CoinGeckoService(api_key='') with pytest.raises( GetCoinQuotesException, match=f'Quote {quote_symbol} not supported', ): - await cmc_service.get_coin_quotes( + await cgk_service.get_coin_quotes( coins=[CoinSymbols.btc], quotes_in=[quote_symbol] ) @@ -209,13 +211,13 @@ async def test_get_coin_quotes(): ) ) - cmc_service = CoinGeckoService(api_key='') + cgk_service = CoinGeckoService(api_key='') - result: CoinQuotes = await cmc_service.get_coin_quotes( + result: CoinQuotes = await cgk_service.get_coin_quotes( coins=[CoinSymbols.btc], quotes_in=[QuoteSymbols.usd] ) assert isinstance(result, CoinQuotes) - assert result.api_service is cmc_service + assert result.api_service == 'coingecko' assert result.raw_data == EXAMPLE_RESPONSE assert result.model_dump()['coins'] == { @@ -237,13 +239,13 @@ async def test_get_coin_quotes_multi_coins(): ) ) - cmc_service = CoinGeckoService(api_key='') + cgk_service = CoinGeckoService(api_key='') - result: CoinQuotes = await cmc_service.get_coin_quotes( + result: CoinQuotes = await cgk_service.get_coin_quotes( coins=[CoinSymbols.btc, CoinSymbols.eth], quotes_in=[QuoteSymbols.usd] ) assert isinstance(result, CoinQuotes) - assert result.api_service is cmc_service + assert result.api_service == 'coingecko' assert result.raw_data == EXAMPLE_RESPONSE assert result.model_dump()['coins'] == { @@ -268,13 +270,13 @@ async def test_get_coin_quotes_multi_quotes(): ) ) - cmc_service = CoinGeckoService(api_key='') + cgk_service = CoinGeckoService(api_key='') - result: CoinQuotes = await cmc_service.get_coin_quotes( + result: CoinQuotes = await cgk_service.get_coin_quotes( coins=[CoinSymbols.btc], quotes_in=[QuoteSymbols.usd, QuoteSymbols.eur] ) assert isinstance(result, CoinQuotes) - assert result.api_service is cmc_service + assert result.api_service == 'coingecko' assert result.raw_data == EXAMPLE_RESPONSE assert result.model_dump()['coins'] == { @@ -302,14 +304,14 @@ async def test_get_coin_quotes_multi_coins_and_quotes(): ) ) - cmc_service = CoinGeckoService(api_key='') + cgk_service = CoinGeckoService(api_key='') - result: CoinQuotes = await cmc_service.get_coin_quotes( + result: CoinQuotes = await cgk_service.get_coin_quotes( coins=[CoinSymbols.btc, CoinSymbols.eth], quotes_in=[QuoteSymbols.usd, QuoteSymbols.eur], ) assert isinstance(result, CoinQuotes) - assert result.api_service is cmc_service + assert result.api_service == 'coingecko' assert result.raw_data == EXAMPLE_RESPONSE assert result.model_dump()['coins'] == { @@ -328,6 +330,87 @@ async def test_get_coin_quotes_multi_coins_and_quotes(): } +async def test_get_coin_quotes_with_cache_and_value_in_cache(): + EXAMPLE_RESPONSE = {'bitcoin': {'usd': 100811}} + + cache = Cache(Cache.MEMORY) + cache.get = AsyncMock( + return_value=( + '{' + """"coins": {"btc": {"quotes": {"usd": {"quote": "100811"}}}},""" + """"api_service": "coingecko",""" + """"raw_data": {"bitcoin":{"usd": 100811}}""" + '}' + ) + ) + + cgk_service = CoinGeckoService( + api_key='', + cache=cache, + ) + + result: CoinQuotes = await cgk_service.get_coin_quotes( + coins=[CoinSymbols.btc], quotes_in=[QuoteSymbols.usd] + ) + + cache.get.assert_called_once_with('coins:btc;quotes_in:usd') + + assert isinstance(result, CoinQuotes) + assert result.api_service == 'coingecko' + assert result.raw_data == EXAMPLE_RESPONSE + + assert result.model_dump()['coins'] == { + CoinSymbols.btc: { + 'quotes': {QuoteSymbols.usd: {'quote': Decimal('100811')}} + } + } + + +@respx.mock +async def test_get_coin_quotes_with_cache_and_value_not_in_cache(): + EXAMPLE_RESPONSE = {'bitcoin': {'usd': 100811}} + + # Mock api request + respx.get('https://pro-api.coingecko.com/api/v3/simple/price').mock( + httpx.Response( + status_code=200, + json=EXAMPLE_RESPONSE, + ) + ) + + cache = Cache(Cache.MEMORY) + + cgk_service = CoinGeckoService( + api_key='', + cache=cache, + ) + + result: CoinQuotes = await cgk_service.get_coin_quotes( + coins=[CoinSymbols.btc], quotes_in=[QuoteSymbols.usd] + ) + + # --------- Checks if the return was cached correctly --------- + EXPECTED_VALUE_IN_CACHE = ( + '{' + """"coins": {"btc": {"quotes": {"usd": {"quote": "100811"}}}},""" + """"api_service": "coingecko",""" + """"raw_data": {"bitcoin":{"usd": 100811}}""" + '}' + ) + await cache.get('coins:btc;quotes_in:usd') == EXPECTED_VALUE_IN_CACHE + # End + + assert isinstance(result, CoinQuotes) + assert result.api_service == 'coingecko' + assert result.raw_data == EXAMPLE_RESPONSE + + assert result.model_dump()['coins'] == { + CoinSymbols.btc: { + 'quotes': {QuoteSymbols.usd: {'quote': Decimal('100811')}} + } + } + + def test_repr(): service = CoinGeckoService(api_key='') assert repr(service) == ("CoinGeckoService(api_key='***')") diff --git a/tests/services/test_coinmarketcap.py b/tests/services/test_coinmarketcap.py index 8e92b52..a1875ba 100644 --- a/tests/services/test_coinmarketcap.py +++ b/tests/services/test_coinmarketcap.py @@ -408,7 +408,7 @@ async def test_get_coin_quotes(): coins=[CoinSymbols.btc], quotes_in=[QuoteSymbols.usd] ) assert isinstance(result, CoinQuotes) - assert result.api_service is cmc_service + assert result.api_service == 'coinmarketcap' assert result.raw_data == EXAMPLE_RESPONSE assert result.model_dump()['coins'] == { @@ -517,7 +517,7 @@ async def test_get_coin_quotes_multi_coins(): coins=[CoinSymbols.btc, CoinSymbols.eth], quotes_in=[QuoteSymbols.usd] ) assert isinstance(result, CoinQuotes) - assert result.api_service is cmc_service + assert result.api_service == 'coinmarketcap' assert result.raw_data == EXAMPLE_RESPONSE assert result.model_dump()['coins'] == { @@ -608,7 +608,7 @@ async def test_get_coin_quotes_multi_quotes(): coins=[CoinSymbols.btc], quotes_in=[QuoteSymbols.usd, QuoteSymbols.eur] ) assert isinstance(result, CoinQuotes) - assert result.api_service is cmc_service + assert result.api_service == 'coinmarketcap' assert result.raw_data == EXAMPLE_RESPONSE assert result.model_dump()['coins'] == { @@ -747,7 +747,7 @@ async def test_get_coin_quotes_multi_coins_and_quotes(): quotes_in=[QuoteSymbols.usd, QuoteSymbols.eur], ) assert isinstance(result, CoinQuotes) - assert result.api_service is cmc_service + assert result.api_service == 'coinmarketcap' assert result.raw_data == EXAMPLE_RESPONSE assert result.model_dump()['coins'] == { diff --git a/tests/test_response_models.py b/tests/test_response_models.py index fe45b57..707c693 100644 --- a/tests/test_response_models.py +++ b/tests/test_response_models.py @@ -2,7 +2,6 @@ from anycoin import CoinSymbols, QuoteSymbols from anycoin.response_models import CoinQuotes, CoinRow, QuoteRow -from anycoin.services.coingecko import CoinGeckoService def test_coin_quotes_repr(): @@ -12,11 +11,11 @@ def test_coin_quotes_repr(): quotes={QuoteSymbols.usd: QuoteRow(quote=Decimal('100000'))} ) }, - api_service=CoinGeckoService(api_key=''), + api_service='coingecko', raw_data={'bitcoin': {'usd': 100000}}, ) assert repr(model) == ( - f'CoinQuotes(coins={model.coins}, api_service={model.api_service})' + f"CoinQuotes(coins={model.coins}, api_service='coingecko')" ) @@ -27,11 +26,11 @@ def test_coin_quotes_str(): quotes={QuoteSymbols.usd: QuoteRow(quote=Decimal('100000'))} ) }, - api_service=CoinGeckoService(api_key=''), + api_service='coingecko', raw_data={'bitcoin': {'usd': 100000}}, ) assert str(model) == ( - f'CoinQuotes(coins={model.coins}, api_service={model.api_service})' + f"CoinQuotes(coins={model.coins}, api_service='coingecko')" ) @@ -42,11 +41,26 @@ def test_coin_quotes_dump_json(): quotes={QuoteSymbols.usd: QuoteRow(quote=Decimal('100000'))} ) }, - api_service=CoinGeckoService(api_key=''), + api_service='coingecko', raw_data={'bitcoin': {'usd': 100000}}, ) assert model.model_dump(mode='json') == { 'coins': {'btc': {'quotes': {'usd': {'quote': '100000'}}}}, - 'api_service': "CoinGeckoService(api_key='***')", + 'api_service': 'coingecko', 'raw_data': {'bitcoin': {'usd': 100000}}, } + + +def test_coin_quotes_model_validate_json(): + model = CoinQuotes( + coins={ + CoinSymbols.btc: CoinRow( + quotes={QuoteSymbols.usd: QuoteRow(quote=Decimal('100000'))} + ) + }, + api_service='coingecko', + raw_data={'bitcoin': {'usd': 100000}}, + ) + model_json: str = model.model_dump_json() + + assert CoinQuotes.model_validate_json(model_json) == model From ae594937f690a61f240569cc42301ba7838d0602 Mon Sep 17 00:00:00 2001 From: HK-Mattew Date: Thu, 6 Feb 2025 09:52:01 -0300 Subject: [PATCH 02/10] Implemented caching system in the get_coin_quotes method in CoinMarketCapService. Issue: #4 --- anycoin/services/coinmarketcap.py | 90 ++++++++++---- tests/services/test_coinmarketcap.py | 175 +++++++++++++++++++++++++++ 2 files changed, 244 insertions(+), 21 deletions(-) diff --git a/anycoin/services/coinmarketcap.py b/anycoin/services/coinmarketcap.py index c4af526..71346bc 100644 --- a/anycoin/services/coinmarketcap.py +++ b/anycoin/services/coinmarketcap.py @@ -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_cmc_coins_ids as _get_cmc_coins_ids from .._mapped_ids import get_cmc_quotes_ids as _get_cmc_quotes_ids +from ..cache import Cache, _get_cache_key_for_get_coin_quotes_method_params from ..exeptions import ( CoinNotSupportedCMC as CoinNotSupportedCMCException, ) @@ -18,36 +20,54 @@ class CoinMarketCapService(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 CoinNotSupportedCMCException as expt: - raise GetCoinQuotesException(str(expt)) from expt - - except QuoteCoinNotSupportedCMCException 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 = { - 'id': ','.join(coin_ids), - 'convert_id': ','.join(convert_ids), - } + 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='/cryptocurrency/quotes/latest', method='get', params=params - ) - return await CoinQuotes.from_cmc_raw_data(raw_data=raw_data) + return coin_quotes @staticmethod async def get_coin_id_by_symbol(coin_symbol: CoinSymbols) -> str: @@ -102,6 +122,34 @@ 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 CoinNotSupportedCMCException as expt: + raise GetCoinQuotesException(str(expt)) from expt + + except QuoteCoinNotSupportedCMCException as expt: + raise GetCoinQuotesException(str(expt)) from expt + + params = { + 'id': ','.join(coin_ids), + 'convert_id': ','.join(convert_ids), + } + + raw_data = await self._send_request( + path='/cryptocurrency/quotes/latest', method='get', params=params + ) + return await CoinQuotes.from_cmc_raw_data(raw_data=raw_data) + async def _send_request( self, path: str, diff --git a/tests/services/test_coinmarketcap.py b/tests/services/test_coinmarketcap.py index a1875ba..fce715a 100644 --- a/tests/services/test_coinmarketcap.py +++ b/tests/services/test_coinmarketcap.py @@ -1,12 +1,14 @@ import json from decimal import Decimal from enum import Enum +from unittest.mock import AsyncMock import httpx import pytest import respx from anycoin import CoinSymbols, QuoteSymbols +from anycoin.cache import Cache from anycoin.exeptions import ( CoinNotSupportedCMC as CoinNotSupportedCMCException, ) @@ -766,6 +768,179 @@ async def test_get_coin_quotes_multi_coins_and_quotes(): } +async def test_get_coin_quotes_with_cache_and_value_in_cache(): + EXAMPLE_RESPONSE = { + 'data': { + '1': { + 'id': 1, + 'name': 'Bitcoin', + 'symbol': 'BTC', + 'slug': 'bitcoin', + 'is_active': 1, + 'is_fiat': 0, + 'circulating_supply': 17199862, + 'total_supply': 17199862, + 'max_supply': 21000000, + 'date_added': '2013-04-28T00:00:00.000Z', + 'num_market_pairs': 331, + 'cmc_rank': 1, + 'last_updated': '2018-08-09T21:56:28.000Z', + 'tags': ['mineable'], + 'platform': None, + 'self_reported_circulating_supply': None, + 'self_reported_market_cap': None, + 'quote': { + '2781': { # USD + 'price': 6602.60701122, + 'volume_24h': 4314444687.5194, + 'volume_change_24h': -0.152774, + 'percent_change_1h': 0.988615, + 'percent_change_24h': 4.37185, + 'percent_change_7d': -12.1352, + 'percent_change_30d': -12.1352, + 'market_cap': 852164659250.2758, + 'market_cap_dominance': 51, + 'fully_diluted_market_cap': 952835089431.14, + 'last_updated': '2018-08-09T21:56:28.000Z', + } + }, + } + }, + 'status': { + 'timestamp': '2025-01-19T10:00:27.010Z', + 'error_code': 0, + 'error_message': '', + 'elapsed': 10, + 'credit_count': 1, + 'notice': '', + }, + } + + cache = Cache(Cache.MEMORY) + cache.get = AsyncMock( + return_value=( + '{' + """"coins": {"btc": {"quotes": {"usd": {"quote": "6602.60701122"}}}},""" # noqa: E501 + """"api_service": "coinmarketcap",""" + f""""raw_data": {json.dumps(EXAMPLE_RESPONSE)}""" + '}' + ) + ) + + cmc_service = CoinMarketCapService( + api_key='', + cache=cache, + ) + + result: CoinQuotes = await cmc_service.get_coin_quotes( + coins=[CoinSymbols.btc], quotes_in=[QuoteSymbols.usd] + ) + + cache.get.assert_called_once_with('coins:btc;quotes_in:usd') + + assert isinstance(result, CoinQuotes) + assert result.api_service == 'coinmarketcap' + assert result.raw_data == EXAMPLE_RESPONSE + + assert result.model_dump()['coins'] == { + CoinSymbols.btc: { + 'quotes': {QuoteSymbols.usd: {'quote': Decimal('6602.60701122')}} + } + } + + +@respx.mock +async def test_get_coin_quotes_with_cache_and_value_not_in_cache(): + EXAMPLE_RESPONSE = { + 'data': { + '1': { + 'id': 1, + 'name': 'Bitcoin', + 'symbol': 'BTC', + 'slug': 'bitcoin', + 'is_active': 1, + 'is_fiat': 0, + 'circulating_supply': 17199862, + 'total_supply': 17199862, + 'max_supply': 21000000, + 'date_added': '2013-04-28T00:00:00.000Z', + 'num_market_pairs': 331, + 'cmc_rank': 1, + 'last_updated': '2018-08-09T21:56:28.000Z', + 'tags': ['mineable'], + 'platform': None, + 'self_reported_circulating_supply': None, + 'self_reported_market_cap': None, + 'quote': { + '2781': { # USD + 'price': 6602.60701122, + 'volume_24h': 4314444687.5194, + 'volume_change_24h': -0.152774, + 'percent_change_1h': 0.988615, + 'percent_change_24h': 4.37185, + 'percent_change_7d': -12.1352, + 'percent_change_30d': -12.1352, + 'market_cap': 852164659250.2758, + 'market_cap_dominance': 51, + 'fully_diluted_market_cap': 952835089431.14, + 'last_updated': '2018-08-09T21:56:28.000Z', + } + }, + } + }, + 'status': { + 'timestamp': '2025-01-19T10:00:27.010Z', + 'error_code': 0, + 'error_message': '', + 'elapsed': 10, + 'credit_count': 1, + 'notice': '', + }, + } + + # Mock api request + respx.get( + 'https://pro-api.coinmarketcap.com/v2/cryptocurrency/quotes/latest' + ).mock( + httpx.Response( + status_code=200, + json=EXAMPLE_RESPONSE, + ) + ) + + cache = Cache(Cache.MEMORY) + + cmc_service = CoinMarketCapService( + api_key='', + cache=cache, + ) + + result: CoinQuotes = await cmc_service.get_coin_quotes( + coins=[CoinSymbols.btc], quotes_in=[QuoteSymbols.usd] + ) + + # --------- Checks if the return was cached correctly --------- + EXPECTED_VALUE_IN_CACHE = ( + '{' + """"coins": {"btc": {"quotes": {"usd": {"quote": "6602.60701122"}}}},""" # noqa: E501 + """"api_service": "coinmarketcap",""" + f""""raw_data": {json.dumps(EXAMPLE_RESPONSE)}""" + '}' + ) + await cache.get('coins:btc;quotes_in:usd') == EXPECTED_VALUE_IN_CACHE + # End + + assert isinstance(result, CoinQuotes) + assert result.api_service == 'coinmarketcap' + assert result.raw_data == EXAMPLE_RESPONSE + + assert result.model_dump()['coins'] == { + CoinSymbols.btc: { + 'quotes': {QuoteSymbols.usd: {'quote': Decimal('6602.60701122')}} + } + } + + def test_repr(): service = CoinMarketCapService(api_key='') assert repr(service) == ("CoinMarketCapService(api_key='***')") From bdd379ed880e6ad678194d78ddf981786c36f2a9 Mon Sep 17 00:00:00 2001 From: HK-Mattew Date: Thu, 6 Feb 2025 09:54:52 -0300 Subject: [PATCH 03/10] Improved test, Use json.dumps instead of writing json manually --- tests/services/test_coingecko.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/services/test_coingecko.py b/tests/services/test_coingecko.py index e583953..38a282e 100644 --- a/tests/services/test_coingecko.py +++ b/tests/services/test_coingecko.py @@ -339,7 +339,7 @@ async def test_get_coin_quotes_with_cache_and_value_in_cache(): '{' """"coins": {"btc": {"quotes": {"usd": {"quote": "100811"}}}},""" """"api_service": "coingecko",""" - """"raw_data": {"bitcoin":{"usd": 100811}}""" + f""""raw_data": {json.dumps(EXAMPLE_RESPONSE)}""" '}' ) ) @@ -394,7 +394,7 @@ async def test_get_coin_quotes_with_cache_and_value_not_in_cache(): '{' """"coins": {"btc": {"quotes": {"usd": {"quote": "100811"}}}},""" """"api_service": "coingecko",""" - """"raw_data": {"bitcoin":{"usd": 100811}}""" + f""""raw_data": {json.dumps(EXAMPLE_RESPONSE)}""" '}' ) await cache.get('coins:btc;quotes_in:usd') == EXPECTED_VALUE_IN_CACHE From 5e4f952fdc6ad84cc922ac5053c9052233dc9473 Mon Sep 17 00:00:00 2001 From: HK-Mattew Date: Thu, 6 Feb 2025 09:58:10 -0300 Subject: [PATCH 04/10] Remove unnecessary extra in aiocache dependency --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 61b7436..d20866e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ requires-python = ">=3.10" dependencies = [ "httpx>=0.25.0", "pydantic>=2.7.0", - "aiocache[memcached]>=0.12.3", + "aiocache>=0.12.3", ] [project.optional-dependencies] From aeb973929dfd089f8d0bc3b605f24a179e7d96f6 Mon Sep 17 00:00:00 2001 From: HK-Mattew Date: Thu, 6 Feb 2025 10:49:39 -0300 Subject: [PATCH 05/10] Test improvements: Added *any_aiocache* fixture with all aiocache cache backends so that all cache backends are tested + added docker-compose.yaml for services like Memcached and redis --- docker-compose.yaml | 17 +++++++++++++++ tests/conftest.py | 31 ++++++++++++++++++++++++++++ tests/services/test_coingecko.py | 20 ++++++++---------- tests/services/test_coinmarketcap.py | 20 ++++++++---------- 4 files changed, 66 insertions(+), 22 deletions(-) create mode 100644 docker-compose.yaml create mode 100644 tests/conftest.py diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..f492497 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,17 @@ +version: "3.8" + +services: + memcached: + image: memcached:latest + container_name: memcached + ports: + - "11211:11211" + restart: unless-stopped + + valkey: + image: valkey/valkey:latest + container_name: valkey + command: ["valkey-server", "--save", "", "--appendonly", "no"] + ports: + - "6379:6379" + restart: unless-stopped diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..23ffcd9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,31 @@ +import pytest +import pytest_asyncio +from aiocache import Cache + + +@pytest_asyncio.fixture( + params=[ + pytest.param({'cache_class': Cache.MEMORY}, id='memory-cache'), + pytest.param( + { + 'cache_class': Cache.MEMCACHED, + 'endpoint': '127.0.0.1', + 'port': 11211, + }, + id='memcached-cache', + ), + pytest.param( + { + 'cache_class': Cache.REDIS, + 'endpoint': '127.0.0.1', + 'port': 6379, + }, + id='redis-cache', + ), + ] +) +async def any_aiocache(request): + cache = Cache(**request.param) + await cache.clear() + yield cache + await cache.close() diff --git a/tests/services/test_coingecko.py b/tests/services/test_coingecko.py index 38a282e..eb0f54a 100644 --- a/tests/services/test_coingecko.py +++ b/tests/services/test_coingecko.py @@ -9,7 +9,6 @@ import respx from anycoin import CoinSymbols, QuoteSymbols -from anycoin.cache import Cache from anycoin.exeptions import ( CoinNotSupportedCGK as CoinNotSupportedCGKException, ) @@ -330,11 +329,10 @@ async def test_get_coin_quotes_multi_coins_and_quotes(): } -async def test_get_coin_quotes_with_cache_and_value_in_cache(): +async def test_get_coin_quotes_with_cache_and_value_in_cache(any_aiocache): EXAMPLE_RESPONSE = {'bitcoin': {'usd': 100811}} - cache = Cache(Cache.MEMORY) - cache.get = AsyncMock( + any_aiocache.get = AsyncMock( return_value=( '{' """"coins": {"btc": {"quotes": {"usd": {"quote": "100811"}}}},""" @@ -346,14 +344,14 @@ async def test_get_coin_quotes_with_cache_and_value_in_cache(): cgk_service = CoinGeckoService( api_key='', - cache=cache, + cache=any_aiocache, ) result: CoinQuotes = await cgk_service.get_coin_quotes( coins=[CoinSymbols.btc], quotes_in=[QuoteSymbols.usd] ) - cache.get.assert_called_once_with('coins:btc;quotes_in:usd') + any_aiocache.get.assert_called_once_with('coins:btc;quotes_in:usd') assert isinstance(result, CoinQuotes) assert result.api_service == 'coingecko' @@ -367,7 +365,7 @@ async def test_get_coin_quotes_with_cache_and_value_in_cache(): @respx.mock -async def test_get_coin_quotes_with_cache_and_value_not_in_cache(): +async def test_get_coin_quotes_with_cache_and_value_not_in_cache(any_aiocache): EXAMPLE_RESPONSE = {'bitcoin': {'usd': 100811}} # Mock api request @@ -378,11 +376,9 @@ async def test_get_coin_quotes_with_cache_and_value_not_in_cache(): ) ) - cache = Cache(Cache.MEMORY) - cgk_service = CoinGeckoService( api_key='', - cache=cache, + cache=any_aiocache, ) result: CoinQuotes = await cgk_service.get_coin_quotes( @@ -397,7 +393,9 @@ async def test_get_coin_quotes_with_cache_and_value_not_in_cache(): f""""raw_data": {json.dumps(EXAMPLE_RESPONSE)}""" '}' ) - await cache.get('coins:btc;quotes_in:usd') == EXPECTED_VALUE_IN_CACHE + await any_aiocache.get( + 'coins:btc;quotes_in:usd' + ) == EXPECTED_VALUE_IN_CACHE # End assert isinstance(result, CoinQuotes) diff --git a/tests/services/test_coinmarketcap.py b/tests/services/test_coinmarketcap.py index fce715a..e335d13 100644 --- a/tests/services/test_coinmarketcap.py +++ b/tests/services/test_coinmarketcap.py @@ -8,7 +8,6 @@ import respx from anycoin import CoinSymbols, QuoteSymbols -from anycoin.cache import Cache from anycoin.exeptions import ( CoinNotSupportedCMC as CoinNotSupportedCMCException, ) @@ -768,7 +767,7 @@ async def test_get_coin_quotes_multi_coins_and_quotes(): } -async def test_get_coin_quotes_with_cache_and_value_in_cache(): +async def test_get_coin_quotes_with_cache_and_value_in_cache(any_aiocache): EXAMPLE_RESPONSE = { 'data': { '1': { @@ -816,8 +815,7 @@ async def test_get_coin_quotes_with_cache_and_value_in_cache(): }, } - cache = Cache(Cache.MEMORY) - cache.get = AsyncMock( + any_aiocache.get = AsyncMock( return_value=( '{' """"coins": {"btc": {"quotes": {"usd": {"quote": "6602.60701122"}}}},""" # noqa: E501 @@ -829,14 +827,14 @@ async def test_get_coin_quotes_with_cache_and_value_in_cache(): cmc_service = CoinMarketCapService( api_key='', - cache=cache, + cache=any_aiocache, ) result: CoinQuotes = await cmc_service.get_coin_quotes( coins=[CoinSymbols.btc], quotes_in=[QuoteSymbols.usd] ) - cache.get.assert_called_once_with('coins:btc;quotes_in:usd') + any_aiocache.get.assert_called_once_with('coins:btc;quotes_in:usd') assert isinstance(result, CoinQuotes) assert result.api_service == 'coinmarketcap' @@ -850,7 +848,7 @@ async def test_get_coin_quotes_with_cache_and_value_in_cache(): @respx.mock -async def test_get_coin_quotes_with_cache_and_value_not_in_cache(): +async def test_get_coin_quotes_with_cache_and_value_not_in_cache(any_aiocache): EXAMPLE_RESPONSE = { 'data': { '1': { @@ -908,11 +906,9 @@ async def test_get_coin_quotes_with_cache_and_value_not_in_cache(): ) ) - cache = Cache(Cache.MEMORY) - cmc_service = CoinMarketCapService( api_key='', - cache=cache, + cache=any_aiocache, ) result: CoinQuotes = await cmc_service.get_coin_quotes( @@ -927,7 +923,9 @@ async def test_get_coin_quotes_with_cache_and_value_not_in_cache(): f""""raw_data": {json.dumps(EXAMPLE_RESPONSE)}""" '}' ) - await cache.get('coins:btc;quotes_in:usd') == EXPECTED_VALUE_IN_CACHE + await any_aiocache.get( + 'coins:btc;quotes_in:usd' + ) == EXPECTED_VALUE_IN_CACHE # End assert isinstance(result, CoinQuotes) From 5e275aaaea660bc2a94bd5166f227d0f2cc439c9 Mon Sep 17 00:00:00 2001 From: HK-Mattew Date: Thu, 6 Feb 2025 10:52:30 -0300 Subject: [PATCH 06/10] Added step to start docker services to CI --- .github/workflows/test.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 011aead..07fde3d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -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] @@ -46,6 +49,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] From 48d79169f014862a76dbe9050bf7d8fb83f90fc9 Mon Sep 17 00:00:00 2001 From: HK-Mattew Date: Thu, 6 Feb 2025 10:59:33 -0300 Subject: [PATCH 07/10] Add required dependencies for testing --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index d20866e..c66d960 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ [project.optional-dependencies] dev = [ + "anycoin[redis-cache,memcached-cache]", "ruff>=0.9.2", "taskipy>=1.14.1", "pytest-asyncio>=0.25.2", From 907f64fadc4ff36b414081921b48888248854df5 Mon Sep 17 00:00:00 2001 From: HK-Mattew Date: Thu, 6 Feb 2025 11:14:59 -0300 Subject: [PATCH 08/10] Added more tests, completing 100% coverage --- tests/services/test_base.py | 7 ++++++- tests/test_cache.py | 40 +++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 tests/test_cache.py diff --git a/tests/services/test_base.py b/tests/services/test_base.py index 8111808..96e8ab3 100644 --- a/tests/services/test_base.py +++ b/tests/services/test_base.py @@ -1,6 +1,11 @@ from anycoin.services.base import BaseAPIService -def test_repr(): +def test__repr__(): service = BaseAPIService() assert repr(service) == ('BaseAPIService(***)') + + +def test__str__(): + service = BaseAPIService() + assert str(service) == ('BaseAPIService(***)') diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..c2748de --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,40 @@ +from anycoin import CoinSymbols, QuoteSymbols +from anycoin.cache import ( + _get_cache_key_for_get_coin_quotes_method_params, # noqa: PLC2701 +) + + +def test_get_cache_key_for_get_coin_quotes_method_params_one_coin_and_one_quote(): # noqa: E501 + result = _get_cache_key_for_get_coin_quotes_method_params( + coins=[CoinSymbols.btc], + quotes_in=[ + QuoteSymbols.usd, + ], + ) + assert result == 'coins:btc;quotes_in:usd' + + +def test_get_cache_key_for_get_coin_quotes_method_params_multi_coin_and_one_quote(): # noqa: E501 + result = _get_cache_key_for_get_coin_quotes_method_params( + coins=[CoinSymbols.btc, CoinSymbols.ltc], + quotes_in=[ + QuoteSymbols.usd, + ], + ) + assert result == 'coins:btc,ltc;quotes_in:usd' + + +def test_get_cache_key_for_get_coin_quotes_method_params_multi_coin_and_multi_quote(): # noqa: E501 + result = _get_cache_key_for_get_coin_quotes_method_params( + coins=[CoinSymbols.btc, CoinSymbols.ltc], + quotes_in=[QuoteSymbols.usd, QuoteSymbols.eur], + ) + assert result == 'coins:btc,ltc;quotes_in:usd,eur' + + +def test_get_cache_key_for_get_coin_quotes_method_params_one_coin_and_multi_quote(): # noqa: E501 + result = _get_cache_key_for_get_coin_quotes_method_params( + coins=[CoinSymbols.btc], + quotes_in=[QuoteSymbols.usd, QuoteSymbols.eur], + ) + assert result == 'coins:btc;quotes_in:usd,eur' From e4252fa56f6d452ff0309b026679294aa9d8ea1a Mon Sep 17 00:00:00 2001 From: HK-Mattew Date: Thu, 6 Feb 2025 11:30:02 -0300 Subject: [PATCH 09/10] Remove version in docker-compose.yaml --- docker-compose.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index f492497..651369f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,3 @@ -version: "3.8" - services: memcached: image: memcached:latest From a198cc277bc5e80e56e7e5d3eff1f404b828cba3 Mon Sep 17 00:00:00 2001 From: HK-Mattew Date: Thu, 6 Feb 2025 11:46:38 -0300 Subject: [PATCH 10/10] Added mark to the any_aiocache fixture so that the test can be deselected in the test on other platforms in CI --- .github/workflows/test.yaml | 5 +---- tests/conftest.py | 7 ++++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 07fde3d..5cc8364 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -49,11 +49,8 @@ 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] - name: Test with pytest - run: pytest -vv + run: pytest -vv -m "not external_service" diff --git a/tests/conftest.py b/tests/conftest.py index 23ffcd9..3522c30 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,10 @@ @pytest_asyncio.fixture( params=[ - pytest.param({'cache_class': Cache.MEMORY}, id='memory-cache'), + pytest.param( + {'cache_class': Cache.MEMORY}, + id='memory-cache', + ), pytest.param( { 'cache_class': Cache.MEMCACHED, @@ -13,6 +16,7 @@ 'port': 11211, }, id='memcached-cache', + marks=[pytest.mark.external_service], ), pytest.param( { @@ -21,6 +25,7 @@ 'port': 6379, }, id='redis-cache', + marks=[pytest.mark.external_service], ), ] )