From cd653d990410610887330d0d944ebc19c45f7ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ribeiro?= Date: Thu, 25 Apr 2024 08:31:55 -0300 Subject: [PATCH 1/3] add common module --- cow_py/common/api/api_base.py | 85 +++++++++++++++++ cow_py/common/api/decorators.py | 58 ++++++++++++ cow_py/common/chains/__init__.py | 32 ++++--- cow_py/common/chains/utils.py | 4 +- cow_py/common/config.py | 31 +++++++ cow_py/common/constants.py | 5 +- tests/common/__init__.py | 0 tests/common/api/test_api_base.py | 129 ++++++++++++++++++++++++++ tests/common/api/test_rate_limiter.py | 28 ++++++ tests/common/test_core_api.py | 8 ++ tests/common/test_cow_error.py | 22 +++++ 11 files changed, 383 insertions(+), 19 deletions(-) create mode 100644 cow_py/common/api/api_base.py create mode 100644 cow_py/common/api/decorators.py create mode 100644 tests/common/__init__.py create mode 100644 tests/common/api/test_api_base.py create mode 100644 tests/common/api/test_rate_limiter.py create mode 100644 tests/common/test_core_api.py create mode 100644 tests/common/test_cow_error.py diff --git a/cow_py/common/api/api_base.py b/cow_py/common/api/api_base.py new file mode 100644 index 0000000..545150e --- /dev/null +++ b/cow_py/common/api/api_base.py @@ -0,0 +1,85 @@ +from abc import ABC +from typing import Any, Optional + +import httpx + +from cow_py.common.api.decorators import rate_limitted, with_backoff +from cow_py.common.config import SupportedChainId + +Context = dict[str, Any] + + +class APIConfig(ABC): + """Base class for API configuration with common functionality.""" + + config_map = {} + + def __init__( + self, chain_id: SupportedChainId, base_context: Optional[Context] = None + ): + self.chain_id = chain_id + self.context = base_context or {} + + def get_base_url(self) -> str: + return self.config_map.get( + self.chain_id, "default URL if chain_id is not found" + ) + + def get_context(self) -> Context: + return {"base_url": self.get_base_url(), **self.context} + + +class RequestStrategy: + async def make_request(self, client, url, method, **request_kwargs): + headers = { + "accept": "application/json", + "content-type": "application/json", + } + + return await client.request( + url=url, headers=headers, method=method, **request_kwargs + ) + + +class ResponseAdapter: + async def adapt_response(self, _response): + raise NotImplementedError() + + +class RequestBuilder: + def __init__(self, strategy, response_adapter): + self.strategy = strategy + self.response_adapter = response_adapter + + async def execute(self, client, url, method, **kwargs): + response = await self.strategy.make_request(client, url, method, **kwargs) + return self.response_adapter.adapt_response(response) + + +class JsonResponseAdapter(ResponseAdapter): + def adapt_response(self, response): + if response.headers.get("content-type") == "application/json": + return response.json() + else: + return response.text + + +class ApiBase: + """Base class for APIs utilizing configuration and request execution.""" + + def __init__(self, config: APIConfig): + self.config = config + + @with_backoff() + @rate_limitted() + async def _fetch(self, path, method="GET", **kwargs): + url = self.config.get_base_url() + path + + del kwargs["context_override"] + + async with httpx.AsyncClient() as client: + builder = RequestBuilder( + RequestStrategy(), + JsonResponseAdapter(), + ) + return await builder.execute(client, url, method, **kwargs) diff --git a/cow_py/common/api/decorators.py b/cow_py/common/api/decorators.py new file mode 100644 index 0000000..305121a --- /dev/null +++ b/cow_py/common/api/decorators.py @@ -0,0 +1,58 @@ +import backoff +import httpx +from aiolimiter import AsyncLimiter + +DEFAULT_LIMITER_OPTIONS = {"rate": 5, "per": 1.0} + +DEFAULT_BACKOFF_OPTIONS = { + "max_tries": 10, + "max_time": None, + "jitter": None, +} + + +def dig(self, *keys): + try: + for key in keys: + self = self[key] + return self + except KeyError: + return None + + +def with_backoff(): + def decorator(func): + async def wrapper(*args, **kwargs): + backoff_opts = dig(kwargs, "context_override", "backoff_opts") + + if backoff_opts is None: + internal_backoff_opts = DEFAULT_BACKOFF_OPTIONS + else: + internal_backoff_opts = backoff_opts + + @backoff.on_exception( + backoff.expo, httpx.HTTPStatusError, **internal_backoff_opts + ) + async def closure(): + return await func(*args, **kwargs) + + return await closure() + + return wrapper + + return decorator + + +def rate_limitted( + rate=DEFAULT_LIMITER_OPTIONS["rate"], per=DEFAULT_LIMITER_OPTIONS["per"] +): + limiter = AsyncLimiter(rate, per) + + def decorator(func): + async def wrapper(*args, **kwargs): + async with limiter: + return await func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/cow_py/common/chains/__init__.py b/cow_py/common/chains/__init__.py index 083df3e..bf21c1d 100644 --- a/cow_py/common/chains/__init__.py +++ b/cow_py/common/chains/__init__.py @@ -6,24 +6,26 @@ class Chain(Enum): Supported chains and their respective `chainId` for the SDK. """ - MAINNET = 1 - GNOSIS = 100 - SEPOLIA = 11155111 + MAINNET = (1, "ethereum", "https://etherscan.io") + GNOSIS = (100, "gnosis", "https://gnosisscan.io") + SEPOLIA = (11155111, "sepolia", "https://sepolia.etherscan.io/") - def __init__(self, id) -> None: + def __init__(self, id: int, network_name: str, explorer_url: str) -> None: self.id = id + self.network_name = network_name + self.explorer_url = explorer_url + @property + def name(self) -> str: + return self.network_name -SUPPORTED_CHAINS = {Chain.MAINNET, Chain.GNOSIS, Chain.SEPOLIA} + @property + def explorer(self) -> str: + return self.explorer_url -CHAIN_NAMES = { - Chain.MAINNET: "ethereum", - Chain.GNOSIS: "gnosis", - Chain.SEPOLIA: "sepolia", -} + @property + def chain_id(self) -> int: + return self.id -CHAIN_SCANNER_MAP = { - Chain.MAINNET: "https://etherscan.io", - Chain.GNOSIS: "https://gnosisscan.io", - Chain.SEPOLIA: "https://sepolia.etherscan.io/", -} + +SUPPORTED_CHAINS = {chain for chain in Chain} diff --git a/cow_py/common/chains/utils.py b/cow_py/common/chains/utils.py index b428e63..7fdf2dc 100644 --- a/cow_py/common/chains/utils.py +++ b/cow_py/common/chains/utils.py @@ -1,6 +1,6 @@ -from cow_py.common.chains import CHAIN_SCANNER_MAP, Chain +from cow_py.common.chains import Chain def get_explorer_link(chain: Chain, tx_hash: str) -> str: """Return the scan link for the provided transaction hash.""" - return f"{CHAIN_SCANNER_MAP[chain]}/tx/{tx_hash}" + return f"{chain.explorer_url}/tx/{tx_hash}" diff --git a/cow_py/common/config.py b/cow_py/common/config.py index c82a166..ae4316b 100644 --- a/cow_py/common/config.py +++ b/cow_py/common/config.py @@ -1,4 +1,35 @@ +from dataclasses import dataclass from enum import Enum +from typing import Dict, Optional + + +class SupportedChainId(Enum): + MAINNET = 1 + GNOSIS_CHAIN = 100 + SEPOLIA = 11155111 + + +class CowEnv(Enum): + PROD = "prod" + STAGING = "staging" + + +ApiBaseUrls = Dict[SupportedChainId, str] + + +@dataclass +class ApiContext: + chain_id: SupportedChainId + env: CowEnv + base_urls: Optional[ApiBaseUrls] = None + max_tries: Optional[int] = 5 + + +# Define the list of available environments. +ENVS_LIST = [CowEnv.PROD, CowEnv.STAGING] + +# Define the default CoW Protocol API context. +DEFAULT_COW_API_CONTEXT = ApiContext(env=CowEnv.PROD, chain_id=SupportedChainId.MAINNET) class IPFSConfig(Enum): diff --git a/cow_py/common/constants.py b/cow_py/common/constants.py index 29ca7c3..2978a26 100644 --- a/cow_py/common/constants.py +++ b/cow_py/common/constants.py @@ -1,5 +1,6 @@ from enum import Enum from typing import Dict + from .chains import Chain """ @@ -17,14 +18,14 @@ class CowContractAddress(Enum): EXTENSIBLE_FALLBACK_HANDLER = "0x2f55e8b20D0B9FEFA187AA7d00B6Cbe563605bF5" -def map_address_to_supported_networks(address) -> Dict[Chain, str]: +def map_address_to_supported_networks(address) -> Dict[int, str]: """ Maps a given address to all supported networks. :param address: The address to be mapped. :return: A dictionary mapping the address to each supported chain. """ - return {chain_id: address for chain_id in Chain} + return {chain.chain_id: address for chain in Chain} COW_PROTOCOL_SETTLEMENT_CONTRACT_CHAIN_ADDRESS_MAP = map_address_to_supported_networks( diff --git a/tests/common/__init__.py b/tests/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/common/api/test_api_base.py b/tests/common/api/test_api_base.py new file mode 100644 index 0000000..1c3dd9f --- /dev/null +++ b/tests/common/api/test_api_base.py @@ -0,0 +1,129 @@ +from unittest.mock import AsyncMock, Mock, patch + +import httpx +import pytest + +from cow_py.common.api.api_base import ApiBase, APIConfig +from cow_py.common.api.decorators import DEFAULT_BACKOFF_OPTIONS +from cow_py.common.config import SupportedChainId +from httpx import Request + +ERROR_MESSAGE = "💣💥 Booom!" +OK_RESPONSE = {"status": 200, "ok": True, "content": {"some": "data"}} + + +@pytest.fixture +def sut(): + class MyConfig(APIConfig): + def __init__(self): + super().__init__(SupportedChainId.SEPOLIA, None) + + def get_base_url(self): + return "http://localhost" + + class MyAPI(ApiBase): + @staticmethod + def get_config(context): + return Mock( + chain_id="mainnet", get_base_url=Mock(return_value="http://localhost") + ) + + async def get_version(self, context_override={}): + return await self._fetch( + path="/api/v1/version", context_override=context_override + ) + + return MyAPI(config=MyConfig()) + + +@pytest.fixture +def mock_success_response(): + return AsyncMock( + status_code=200, + headers={"content-type": "application/json"}, + json=Mock(return_value=OK_RESPONSE), + ) + + +@pytest.fixture +def mock_http_status_error(): + return httpx.HTTPStatusError( + message=ERROR_MESSAGE, + request=Request("GET", "http://example.com"), + response=httpx.Response(500), + ) + + +@pytest.mark.asyncio +async def test_no_re_attempt_if_success(sut, mock_success_response): + with patch( + "httpx.AsyncClient.send", side_effect=[mock_success_response] + ) as mock_request: + response = await sut.get_version() + assert mock_request.awaited_once() + assert response["content"]["some"] == "data" + + +@pytest.mark.asyncio +async def test_re_attempts_if_fails_then_succeeds( + sut, mock_success_response, mock_http_status_error +): + with patch( + "httpx.AsyncClient.send", + side_effect=[ + *([mock_http_status_error] * 3), + mock_success_response, + ], + ) as mock_request: + response = await sut.get_version() + + assert response["ok"] is True + assert mock_request.call_count == 4 + + +@pytest.mark.asyncio +async def test_succeeds_last_attempt( + sut, mock_success_response, mock_http_status_error +): + with patch( + "httpx.AsyncClient.send", + side_effect=[ + mock_http_status_error, + mock_http_status_error, + mock_success_response, + ], + ) as mock_send: + response = await sut.get_version() + assert response["ok"] is True + assert mock_send.call_count == 3 + + +@pytest.mark.asyncio +async def test_does_not_reattempt_after_max_failures(sut, mock_http_status_error): + with patch( + "httpx.AsyncClient.request", side_effect=[mock_http_status_error] * 3 + ) as mock_call: + with pytest.raises(httpx.HTTPStatusError): + await sut.get_version(context_override={"backoff_opts": {"max_tries": 3}}) + + assert mock_call.call_count == 3 + + +@pytest.mark.asyncio +async def test_backoff_uses_function_options_instead_of_default( + sut, mock_http_status_error +): + max_tries = 1 + + assert max_tries != DEFAULT_BACKOFF_OPTIONS["max_tries"] + + with patch( + "httpx.AsyncClient.request", + side_effect=[mock_http_status_error] * max_tries, + ) as mock_call: + with pytest.raises(httpx.HTTPStatusError): + await sut.get_version( + context_override={"backoff_opts": {"max_tries": max_tries}} + ) + + assert mock_call.call_count == max_tries diff --git a/tests/common/api/test_rate_limiter.py b/tests/common/api/test_rate_limiter.py new file mode 100644 index 0000000..1e6ca12 --- /dev/null +++ b/tests/common/api/test_rate_limiter.py @@ -0,0 +1,28 @@ +import asyncio + +import pytest + +from cow_py.common.api.decorators import rate_limitted + + +@pytest.mark.asyncio +async def test_call_intervals(): + async def test_function(): + return "called" + + # Set the rate limit for easy calculation (e.g., 2 calls per second) + decorated_function = rate_limitted(2, 1)(test_function) + + call_times = [] + + # Perform a number of calls and record the time each was completed + for _ in range(6): + await decorated_function() + call_times.append(asyncio.get_event_loop().time()) + + # Verify intervals between calls are as expected (at least 0.5 seconds apart after the first batch of 2) + intervals = [call_times[i] - call_times[i - 1] for i in range(1, len(call_times))] + for interval in intervals[2:]: # Ignore the first two immediate calls + assert ( + interval >= 0.5 + ), f"Interval of {interval} too short, should be at least 0.5" diff --git a/tests/common/test_core_api.py b/tests/common/test_core_api.py new file mode 100644 index 0000000..992d22d --- /dev/null +++ b/tests/common/test_core_api.py @@ -0,0 +1,8 @@ +import pytest + +from cow_py.common import chains, config, constants, cow_error + + +@pytest.mark.parametrize("module", [constants, cow_error, chains, config]) +def test_module_existence(module): + assert module is not None diff --git a/tests/common/test_cow_error.py b/tests/common/test_cow_error.py new file mode 100644 index 0000000..31c3aaa --- /dev/null +++ b/tests/common/test_cow_error.py @@ -0,0 +1,22 @@ +from cow_py.common.cow_error import CowError + + +def test_cow_error_inheritance(): + assert issubclass(CowError, Exception) + + +def test_cow_error_initialization(): + message = "An error occurred" + error_code = 1001 + error = CowError(message, error_code) + + assert str(error) == message + assert error.error_code == error_code + + +def test_cow_error_initialization_without_error_code(): + message = "An error occurred" + error = CowError(message) + + assert str(error) == message + assert error.error_code is None From 15df625c197c566b3b7d2fcd7a00f22df226cb43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ribeiro?= Date: Thu, 25 Apr 2024 08:32:14 -0300 Subject: [PATCH 2/3] remove stale subgraphs/core modules --- cow_py/order_signing/__init__.py | 0 cow_py/subgraphs/__init__.py | 0 cow_py/subgraphs/base/client.py | 52 --------------------------- cow_py/subgraphs/base/query.py | 23 ------------ cow_py/subgraphs/client.py | 14 -------- cow_py/subgraphs/deployments.py | 36 ------------------- cow_py/subgraphs/queries.py | 60 -------------------------------- tests/core/__init__.py | 0 tests/core/test_core_api.py | 7 ---- tests/core/test_cow_error.py | 27 -------------- tests/subgraphs/__init__.py | 0 tests/subgraphs/deployments.py | 43 ----------------------- 12 files changed, 262 deletions(-) delete mode 100644 cow_py/order_signing/__init__.py delete mode 100644 cow_py/subgraphs/__init__.py delete mode 100644 cow_py/subgraphs/base/client.py delete mode 100644 cow_py/subgraphs/base/query.py delete mode 100644 cow_py/subgraphs/client.py delete mode 100644 cow_py/subgraphs/deployments.py delete mode 100644 cow_py/subgraphs/queries.py delete mode 100644 tests/core/__init__.py delete mode 100644 tests/core/test_core_api.py delete mode 100644 tests/core/test_cow_error.py delete mode 100644 tests/subgraphs/__init__.py delete mode 100644 tests/subgraphs/deployments.py diff --git a/cow_py/order_signing/__init__.py b/cow_py/order_signing/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/cow_py/subgraphs/__init__.py b/cow_py/subgraphs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/cow_py/subgraphs/base/client.py b/cow_py/subgraphs/base/client.py deleted file mode 100644 index 4dd6d77..0000000 --- a/cow_py/subgraphs/base/client.py +++ /dev/null @@ -1,52 +0,0 @@ -from abc import ABC, abstractmethod - -from cow_py.common.chains import Chain - -import json -import logging - -import httpx - - -class GraphQLError(Exception): - pass - - -async def gql(url, query, variables={}): - logging.debug(f"Executing query: {query[:15]}") - logging.debug(f"URL: {url}") - logging.debug(f"Variables: {variables}") - async with httpx.AsyncClient() as client: - r = await client.post( - url, - json=dict(query=query, variables=variables), - ) - logging.debug(f"Response status: {r.status_code}") - logging.debug(f"Response body: {r.text}") - r.raise_for_status() - - try: - return r.json().get("data", r.json()) - except KeyError: - print(json.dumps(r.json(), indent=2)) - raise GraphQLError - - -class GraphQLClient(ABC): - def __init__(self, chain) -> None: - self.url = self.get_url(chain) - - async def instance_query(self, query, variables=dict()): - return await gql(self.url, query, variables=variables) - - @abstractmethod - def get_url(self, chain) -> str: - pass - - @classmethod - async def query(cls, chain=Chain.MAINNET, query=None, variables=dict()): - if not query: - raise ValueError("query must be provided") - - client = cls(chain) - return await client.instance_query(query, variables) diff --git a/cow_py/subgraphs/base/query.py b/cow_py/subgraphs/base/query.py deleted file mode 100644 index 2d94085..0000000 --- a/cow_py/subgraphs/base/query.py +++ /dev/null @@ -1,23 +0,0 @@ -from abc import ABC, abstractmethod - -from cow_py.common.chains import Chain -from cow_py.subgraphs.base.client import GraphQLClient - - -class GraphQLQuery(ABC): - def __init__(self, chain=Chain.MAINNET, variables=dict()) -> None: - self.chain = chain - self.variables = variables - - @abstractmethod - def get_query(self) -> str: - pass - - @abstractmethod - def get_client(self) -> GraphQLClient: - pass - - async def execute(self): - query = self.get_query() - client = self.get_client() - return await client.__class__.query(self.chain, query, self.variables) diff --git a/cow_py/subgraphs/client.py b/cow_py/subgraphs/client.py deleted file mode 100644 index 035bbb5..0000000 --- a/cow_py/subgraphs/client.py +++ /dev/null @@ -1,14 +0,0 @@ -from cow_py.subgraphs.base.query import GraphQLQuery -from cow_py.subgraphs.base.client import GraphQLClient -from cow_py.subgraphs.deployments import build_subgraph_url, SubgraphEnvironment - - -class CoWSubgraph(GraphQLClient): - def get_url(self, chain): - # TODO: add a nice way to change the environment - return build_subgraph_url(chain, SubgraphEnvironment.PRODUCTION) - - -class CoWSubgraphQuery(GraphQLQuery): - def get_client(self): - return CoWSubgraph(self.chain) diff --git a/cow_py/subgraphs/deployments.py b/cow_py/subgraphs/deployments.py deleted file mode 100644 index ab4ed00..0000000 --- a/cow_py/subgraphs/deployments.py +++ /dev/null @@ -1,36 +0,0 @@ -from cow_py.common.chains import Chain -from dataclasses import dataclass -from enum import Enum - - -class SubgraphEnvironment(Enum): - PRODUCTION = "production" - STAGING = "staging" - - -SUBGRAPH_BASE_URL = "https://api.thegraph.com/subgraphs/name/cowprotocol" - - -def build_subgraph_url(chain: Chain, env: SubgraphEnvironment) -> str: - base_url = SUBGRAPH_BASE_URL - - network_suffix = "" if chain == Chain.MAINNET else "-gc" - env_suffix = "-" + env.value if env == SubgraphEnvironment.STAGING else "" - - if chain == Chain.SEPOLIA: - raise ValueError(f"Unsupported chain: {chain}") - - return f"{base_url}/cow{network_suffix}{env_suffix}" - - -@dataclass -class SubgraphConfig: - chain: Chain - - @property - def production(self) -> str: - return build_subgraph_url(self.chain, SubgraphEnvironment.PRODUCTION) - - @property - def staging(self) -> str: - return build_subgraph_url(self.chain, SubgraphEnvironment.STAGING) diff --git a/cow_py/subgraphs/queries.py b/cow_py/subgraphs/queries.py deleted file mode 100644 index b25ba9a..0000000 --- a/cow_py/subgraphs/queries.py +++ /dev/null @@ -1,60 +0,0 @@ -from cow_py.subgraphs.client import CoWSubgraphQuery - -# /** -# * GraphQL query for the total number of tokens, orders, traders, settlements, volume, and fees. -# */ -TOTALS_QUERY = """ - query Totals { - totals { - tokens - orders - traders - settlements - volumeUsd - volumeEth - feesUsd - feesEth - } - } -""" - -# /** -# * GraphQL query for the total volume over the last N days. -# * @param days The number of days to query. -# */ -LAST_DAYS_VOLUME_QUERY = """ - query LastDaysVolume($days: Int!) { - dailyTotals(orderBy: timestamp, orderDirection: desc, first: $days) { - timestamp - volumeUsd - } - } -""" - -# /** -# * GraphQL query for the total volume over the last N hours. -# * @param hours The number of hours to query. -# */ -LAST_HOURS_VOLUME_QUERY = """ - query LastHoursVolume($hours: Int!) { - hourlyTotals(orderBy: timestamp, orderDirection: desc, first: $hours) { - timestamp - volumeUsd - } - } -""" - - -class TotalsQuery(CoWSubgraphQuery): - def get_query(self): - return TOTALS_QUERY - - -class LastDaysVolumeQuery(CoWSubgraphQuery): - def get_query(self): - return LAST_DAYS_VOLUME_QUERY - - -class LastHoursVolumeQuery(CoWSubgraphQuery): - def get_query(self): - return LAST_HOURS_VOLUME_QUERY diff --git a/tests/core/__init__.py b/tests/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/core/test_core_api.py b/tests/core/test_core_api.py deleted file mode 100644 index f03dd13..0000000 --- a/tests/core/test_core_api.py +++ /dev/null @@ -1,7 +0,0 @@ -import pytest -from cow_py.common import constants, cow_error, chains, config - - -@pytest.mark.parametrize("module", [constants, cow_error, chains, config]) -def test_module_existence(module): - assert module is not None diff --git a/tests/core/test_cow_error.py b/tests/core/test_cow_error.py deleted file mode 100644 index aada517..0000000 --- a/tests/core/test_cow_error.py +++ /dev/null @@ -1,27 +0,0 @@ -from cow_py.common.cow_error import ( - CowError, -) # Adjust the import path according to your project structure - - -def test_cow_error_inheritance(): - # Test that CowError is a subclass of Exception - assert issubclass(CowError, Exception) - - -def test_cow_error_initialization(): - # Test CowError initialization with a message and error_code - message = "An error occurred" - error_code = 1001 - error = CowError(message, error_code) - - assert str(error) == message - assert error.error_code == error_code - - -def test_cow_error_initialization_without_error_code(): - # Test CowError initialization with only a message - message = "An error occurred" - error = CowError(message) - - assert str(error) == message - assert error.error_code is None diff --git a/tests/subgraphs/__init__.py b/tests/subgraphs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/subgraphs/deployments.py b/tests/subgraphs/deployments.py deleted file mode 100644 index a7c8580..0000000 --- a/tests/subgraphs/deployments.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest -from cow_py.common.chains import Chain -from cow_py.subgraphs.deployments import ( - build_subgraph_url, - SubgraphConfig, - SubgraphEnvironment, - SUBGRAPH_BASE_URL, -) - - -def test_build_subgraph_url(): - assert ( - build_subgraph_url(Chain.MAINNET, SubgraphEnvironment.PRODUCTION) - == f"{SUBGRAPH_BASE_URL}/cow" - ) - assert ( - build_subgraph_url(Chain.MAINNET, SubgraphEnvironment.STAGING) - == f"{SUBGRAPH_BASE_URL}/cow-staging" - ) - assert ( - build_subgraph_url(Chain.GNOSIS, SubgraphEnvironment.PRODUCTION) - == f"{SUBGRAPH_BASE_URL}/cow-gc" - ) - assert ( - build_subgraph_url(Chain.GNOSIS, SubgraphEnvironment.STAGING) - == f"{SUBGRAPH_BASE_URL}/cow-gc-staging" - ) - - with pytest.raises(ValueError): - build_subgraph_url(Chain.SEPOLIA, SubgraphEnvironment.PRODUCTION) - - -def test_subgraph_config(): - mainnet_config = SubgraphConfig(Chain.MAINNET) - assert mainnet_config.production == f"{SUBGRAPH_BASE_URL}/cow" - assert mainnet_config.staging == f"{SUBGRAPH_BASE_URL}/cow-staging" - - gnosis_chain_config = SubgraphConfig(Chain.GNOSIS) - assert gnosis_chain_config.production == f"{SUBGRAPH_BASE_URL}/cow-gc" - assert gnosis_chain_config.staging == f"{SUBGRAPH_BASE_URL}/cow-gc-staging" - - with pytest.raises(ValueError): - SubgraphConfig(Chain.SEPOLIA).production From 5f733aa4c8bdf9d3de8cd79a56025ee8225f26d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ribeiro?= Date: Sun, 28 Apr 2024 23:24:43 -0300 Subject: [PATCH 3/3] remove trailing slash from sepolia etherscan url --- cow_py/common/chains/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cow_py/common/chains/__init__.py b/cow_py/common/chains/__init__.py index bf21c1d..10285f4 100644 --- a/cow_py/common/chains/__init__.py +++ b/cow_py/common/chains/__init__.py @@ -8,7 +8,7 @@ class Chain(Enum): MAINNET = (1, "ethereum", "https://etherscan.io") GNOSIS = (100, "gnosis", "https://gnosisscan.io") - SEPOLIA = (11155111, "sepolia", "https://sepolia.etherscan.io/") + SEPOLIA = (11155111, "sepolia", "https://sepolia.etherscan.io") def __init__(self, id: int, network_name: str, explorer_url: str) -> None: self.id = id