Skip to content

Commit 7271822

Browse files
authored
Async client (#31)
Add Async Client
1 parent 855d787 commit 7271822

File tree

10 files changed

+417
-37
lines changed

10 files changed

+417
-37
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
.env
22
__pycache__/
33
dist
4-
*.egg-info
4+
*.egg-info
5+
_version.py
6+
.idea/

dune_client/base_client.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
""""
2+
Basic Dune Client Class responsible for refreshing Dune Queries
3+
Framework built on Dune's API Documentation
4+
https://duneanalytics.notion.site/API-Documentation-1b93d16e0fa941398e15047f643e003a
5+
"""
6+
from __future__ import annotations
7+
8+
import logging.config
9+
from typing import Dict
10+
11+
12+
# pylint: disable=too-few-public-methods
13+
class BaseDuneClient:
14+
"""
15+
A Base Client for Dune which sets up default values
16+
and provides some convenient functions to use in other clients
17+
"""
18+
19+
BASE_URL = "https://api.dune.com"
20+
API_PATH = "/api/v1"
21+
DEFAULT_TIMEOUT = 10
22+
23+
def __init__(self, api_key: str):
24+
self.token = api_key
25+
self.logger = logging.getLogger(__name__)
26+
logging.basicConfig(format="%(asctime)s %(levelname)s %(name)s %(message)s")
27+
28+
def default_headers(self) -> Dict[str, str]:
29+
"""Return default headers containing Dune Api token"""
30+
return {"x-dune-api-key": self.token}

dune_client/client.py

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,13 @@
55
"""
66
from __future__ import annotations
77

8-
import logging.config
98
import time
10-
from json import JSONDecodeError
119
from typing import Any
1210

1311
import requests
14-
from requests import Response
12+
from requests import Response, JSONDecodeError
1513

14+
from dune_client.base_client import BaseDuneClient
1615
from dune_client.interface import DuneInterface
1716
from dune_client.models import (
1817
ExecutionResponse,
@@ -24,53 +23,55 @@
2423

2524
from dune_client.query import Query
2625

27-
log = logging.getLogger(__name__)
28-
logging.basicConfig(
29-
format="%(asctime)s %(levelname)s %(name)s %(message)s", level=logging.DEBUG
30-
)
31-
32-
BASE_URL = "https://api.dune.com/api/v1"
3326

34-
35-
class DuneClient(DuneInterface):
27+
class DuneClient(DuneInterface, BaseDuneClient):
3628
"""
3729
An interface for Dune API with a few convenience methods
3830
combining the use of endpoints (e.g. refresh)
3931
"""
4032

41-
def __init__(self, api_key: str):
42-
self.token = api_key
43-
44-
@staticmethod
4533
def _handle_response(
34+
self,
4635
response: Response,
4736
) -> Any:
4837
try:
4938
# Some responses can be decoded and converted to DuneErrors
5039
response_json = response.json()
51-
log.debug(f"received response {response_json}")
40+
self.logger.debug(f"received response {response_json}")
5241
return response_json
5342
except JSONDecodeError as err:
5443
# Others can't. Only raise HTTP error for not decodable errors
5544
response.raise_for_status()
5645
raise ValueError("Unreachable since previous line raises") from err
5746

58-
def _get(self, url: str) -> Any:
59-
log.debug(f"GET received input url={url}")
60-
response = requests.get(url, headers={"x-dune-api-key": self.token}, timeout=10)
47+
def _route_url(self, route: str) -> str:
48+
return f"{self.BASE_URL}{self.API_PATH}/{route}"
49+
50+
def _get(self, route: str) -> Any:
51+
url = self._route_url(route)
52+
self.logger.debug(f"GET received input url={url}")
53+
response = requests.get(
54+
url,
55+
headers={"x-dune-api-key": self.token},
56+
timeout=self.DEFAULT_TIMEOUT,
57+
)
6158
return self._handle_response(response)
6259

63-
def _post(self, url: str, params: Any) -> Any:
64-
log.debug(f"POST received input url={url}, params={params}")
60+
def _post(self, route: str, params: Any) -> Any:
61+
url = self._route_url(route)
62+
self.logger.debug(f"POST received input url={url}, params={params}")
6563
response = requests.post(
66-
url=url, json=params, headers={"x-dune-api-key": self.token}, timeout=10
64+
url=url,
65+
json=params,
66+
headers={"x-dune-api-key": self.token},
67+
timeout=self.DEFAULT_TIMEOUT,
6768
)
6869
return self._handle_response(response)
6970

7071
def execute(self, query: Query) -> ExecutionResponse:
7172
"""Post's to Dune API for execute `query`"""
7273
response_json = self._post(
73-
url=f"{BASE_URL}/query/{query.query_id}/execute",
74+
route=f"query/{query.query_id}/execute",
7475
params={
7576
"query_parameters": {
7677
p.key: p.to_dict()["value"] for p in query.parameters()
@@ -85,7 +86,7 @@ def execute(self, query: Query) -> ExecutionResponse:
8586
def get_status(self, job_id: str) -> ExecutionStatusResponse:
8687
"""GET status from Dune API for `job_id` (aka `execution_id`)"""
8788
response_json = self._get(
88-
url=f"{BASE_URL}/execution/{job_id}/status",
89+
route=f"execution/{job_id}/status",
8990
)
9091
try:
9192
return ExecutionStatusResponse.from_dict(response_json)
@@ -94,17 +95,15 @@ def get_status(self, job_id: str) -> ExecutionStatusResponse:
9495

9596
def get_result(self, job_id: str) -> ResultsResponse:
9697
"""GET results from Dune API for `job_id` (aka `execution_id`)"""
97-
response_json = self._get(url=f"{BASE_URL}/execution/{job_id}/results")
98+
response_json = self._get(route=f"execution/{job_id}/results")
9899
try:
99100
return ResultsResponse.from_dict(response_json)
100101
except KeyError as err:
101102
raise DuneError(response_json, "ResultsResponse", err) from err
102103

103104
def cancel_execution(self, job_id: str) -> bool:
104105
"""POST Execution Cancellation to Dune API for `job_id` (aka `execution_id`)"""
105-
response_json = self._post(
106-
url=f"{BASE_URL}/execution/{job_id}/cancel", params=None
107-
)
106+
response_json = self._post(route=f"execution/{job_id}/cancel", params=None)
108107
try:
109108
# No need to make a dataclass for this since it's just a boolean.
110109
success: bool = response_json["success"]
@@ -121,12 +120,14 @@ def refresh(self, query: Query, ping_frequency: int = 5) -> ResultsResponse:
121120
job_id = self.execute(query).execution_id
122121
status = self.get_status(job_id)
123122
while status.state not in ExecutionState.terminal_states():
124-
log.info(f"waiting for query execution {job_id} to complete: {status}")
123+
self.logger.info(
124+
f"waiting for query execution {job_id} to complete: {status}"
125+
)
125126
time.sleep(ping_frequency)
126127
status = self.get_status(job_id)
127128

128129
full_response = self.get_result(job_id)
129130
if status.state == ExecutionState.FAILED:
130-
log.error(status)
131+
self.logger.error(status)
131132
raise Exception(f"{status}. Perhaps your query took too long to run!")
132133
return full_response

dune_client/client_async.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
""""
2+
Async Dune Client Class responsible for refreshing Dune Queries
3+
Framework built on Dune's API Documentation
4+
https://duneanalytics.notion.site/API-Documentation-1b93d16e0fa941398e15047f643e003a
5+
"""
6+
import asyncio
7+
from typing import Any
8+
9+
from aiohttp import (
10+
ClientSession,
11+
ClientResponse,
12+
ContentTypeError,
13+
TCPConnector,
14+
ClientTimeout,
15+
)
16+
17+
from dune_client.base_client import BaseDuneClient
18+
from dune_client.models import (
19+
ExecutionResponse,
20+
DuneError,
21+
ExecutionStatusResponse,
22+
ResultsResponse,
23+
ExecutionState,
24+
)
25+
26+
from dune_client.query import Query
27+
28+
29+
# pylint: disable=duplicate-code
30+
class AsyncDuneClient(BaseDuneClient):
31+
"""
32+
An asynchronous interface for Dune API with a few convenience methods
33+
combining the use of endpoints (e.g. refresh)
34+
"""
35+
36+
_connection_limit = 3
37+
38+
def __init__(self, api_key: str, connection_limit: int = 3):
39+
"""
40+
api_key - Dune API key
41+
connection_limit - number of parallel requests to execute.
42+
For non-pro accounts Dune allows only up to 3 requests but that number can be increased.
43+
"""
44+
super().__init__(api_key=api_key)
45+
self._connection_limit = connection_limit
46+
self._session = self._create_session()
47+
48+
def _create_session(self) -> ClientSession:
49+
conn = TCPConnector(limit=self._connection_limit)
50+
return ClientSession(
51+
connector=conn,
52+
base_url=self.BASE_URL,
53+
timeout=ClientTimeout(total=self.DEFAULT_TIMEOUT),
54+
)
55+
56+
async def close_session(self) -> None:
57+
"""Closes client session"""
58+
await self._session.close()
59+
60+
async def __aenter__(self) -> None:
61+
self._session = self._create_session()
62+
63+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
64+
await self.close_session()
65+
66+
async def _handle_response(
67+
self,
68+
response: ClientResponse,
69+
) -> Any:
70+
try:
71+
# Some responses can be decoded and converted to DuneErrors
72+
response_json = await response.json()
73+
self.logger.debug(f"received response {response_json}")
74+
return response_json
75+
except ContentTypeError as err:
76+
# Others can't. Only raise HTTP error for not decodable errors
77+
response.raise_for_status()
78+
raise ValueError("Unreachable since previous line raises") from err
79+
80+
async def _get(self, url: str) -> Any:
81+
self.logger.debug(f"GET received input url={url}")
82+
response = await self._session.get(
83+
url=f"{self.API_PATH}{url}",
84+
headers=self.default_headers(),
85+
)
86+
return await self._handle_response(response)
87+
88+
async def _post(self, url: str, params: Any) -> Any:
89+
self.logger.debug(f"POST received input url={url}, params={params}")
90+
response = await self._session.post(
91+
url=f"{self.API_PATH}{url}",
92+
json=params,
93+
headers=self.default_headers(),
94+
)
95+
return await self._handle_response(response)
96+
97+
async def execute(self, query: Query) -> ExecutionResponse:
98+
"""Post's to Dune API for execute `query`"""
99+
response_json = await self._post(
100+
url=f"/query/{query.query_id}/execute",
101+
params=query.request_format(),
102+
)
103+
try:
104+
return ExecutionResponse.from_dict(response_json)
105+
except KeyError as err:
106+
raise DuneError(response_json, "ExecutionResponse", err) from err
107+
108+
async def get_status(self, job_id: str) -> ExecutionStatusResponse:
109+
"""GET status from Dune API for `job_id` (aka `execution_id`)"""
110+
response_json = await self._get(
111+
url=f"/execution/{job_id}/status",
112+
)
113+
try:
114+
return ExecutionStatusResponse.from_dict(response_json)
115+
except KeyError as err:
116+
raise DuneError(response_json, "ExecutionStatusResponse", err) from err
117+
118+
async def get_result(self, job_id: str) -> ResultsResponse:
119+
"""GET results from Dune API for `job_id` (aka `execution_id`)"""
120+
response_json = await self._get(url=f"/execution/{job_id}/results")
121+
try:
122+
return ResultsResponse.from_dict(response_json)
123+
except KeyError as err:
124+
raise DuneError(response_json, "ResultsResponse", err) from err
125+
126+
async def cancel_execution(self, job_id: str) -> bool:
127+
"""POST Execution Cancellation to Dune API for `job_id` (aka `execution_id`)"""
128+
response_json = await self._post(url=f"/execution/{job_id}/cancel", params=None)
129+
try:
130+
# No need to make a dataclass for this since it's just a boolean.
131+
success: bool = response_json["success"]
132+
return success
133+
except KeyError as err:
134+
raise DuneError(response_json, "CancellationResponse", err) from err
135+
136+
async def refresh(self, query: Query, ping_frequency: int = 5) -> ResultsResponse:
137+
"""
138+
Executes a Dune `query`, waits until execution completes,
139+
fetches and returns the results.
140+
Sleeps `ping_frequency` seconds between each status request.
141+
"""
142+
job_id = (await self.execute(query)).execution_id
143+
status = await self.get_status(job_id)
144+
while status.state not in ExecutionState.terminal_states():
145+
self.logger.info(
146+
f"waiting for query execution {job_id} to complete: {status}"
147+
)
148+
await asyncio.sleep(ping_frequency)
149+
status = await self.get_status(job_id)
150+
151+
full_response = await self.get_result(job_id)
152+
if status.state == ExecutionState.FAILED:
153+
self.logger.error(status)
154+
raise Exception(f"{status}. Perhaps your query took too long to run!")
155+
return full_response

dune_client/interface.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
"""
22
Abstract class for a basic Dune Interface with refresh method used by Query Runner.
33
"""
4-
from abc import ABC
4+
import abc
55

66
from dune_client.models import ResultsResponse
77
from dune_client.query import Query
88

99

1010
# pylint: disable=too-few-public-methods
11-
class DuneInterface(ABC):
11+
class DuneInterface(abc.ABC):
1212
"""
1313
User Facing Methods for a Dune Client
1414
"""
1515

16+
@abc.abstractmethod
1617
def refresh(self, query: Query) -> ResultsResponse:
1718
"""
1819
Executes a Dune query, waits till query execution completes,

dune_client/query.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44
import urllib.parse
55
from dataclasses import dataclass
6-
from typing import Optional, List
6+
from typing import Optional, List, Dict
77

88
from dune_client.types import QueryParameter
99

@@ -40,3 +40,9 @@ def __hash__(self) -> int:
4040
Thus, it is unique for caching purposes
4141
"""
4242
return self.url().__hash__()
43+
44+
def request_format(self) -> Dict[str, Dict[str, str]]:
45+
"""Transforms Query objects to params to pass in API"""
46+
return {
47+
"query_parameters": {p.key: p.to_dict()["value"] for p in self.parameters()}
48+
}

dune_client/types.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,9 @@ def value_str(self) -> str:
159159
return str(self.value.strftime("%Y-%m-%d %H:%M:%S"))
160160
raise TypeError(f"Type {self.type} not recognized!")
161161

162-
def to_dict(self) -> dict[str, str | list[str]]:
162+
def to_dict(self) -> dict[str, str]:
163163
"""Converts QueryParameter into string json format accepted by Dune API"""
164-
results: dict[str, str | list[str]] = {
164+
results: dict[str, str] = {
165165
"key": self.key,
166166
"type": self.type.value,
167167
"value": self.value_str(),

requirements/dev.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ black>=22.8.0
33
pylint>=2.15.0
44
pytest>=7.1.3
55
python-dotenv>=0.21.0
6-
mypy>=0.971
6+
mypy>=0.971
7+
aiounittest>=1.4.2

0 commit comments

Comments
 (0)