Skip to content

Commit 395f826

Browse files
authored
[Feature] Introducing CRUD Operations (#59)
Dune recently added endpoints and documentation for their Queries Editing Support via the API. This PR introduces new functionality Create: create_query Read (Get): get_query Update update_query Delete (Archive) archive_query as well as make_private
1 parent 914b0d4 commit 395f826

File tree

12 files changed

+345
-50
lines changed

12 files changed

+345
-50
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ import os
2222

2323
from dune_client.types import QueryParameter
2424
from dune_client.client import DuneClient
25-
from dune_client.query import Query
25+
from dune_client.query import QueryBase
2626

27-
query = Query(
27+
query = QueryBase(
2828
name="Sample Query",
2929
query_id=1215383,
3030
params=[

dune_client/base_client.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,22 @@ class BaseDuneClient:
1717
"""
1818

1919
BASE_URL = "https://api.dune.com"
20-
API_PATH = "/api/v1"
2120
DEFAULT_TIMEOUT = 10
2221

23-
def __init__(self, api_key: str, performance: str = "medium"):
22+
def __init__(
23+
self, api_key: str, client_version: str = "v1", performance: str = "medium"
24+
):
2425
self.token = api_key
26+
self.client_version = client_version
2527
self.performance = performance
2628
self.logger = logging.getLogger(__name__)
2729
logging.basicConfig(format="%(asctime)s %(levelname)s %(name)s %(message)s")
2830

31+
@property
32+
def api_version(self) -> str:
33+
"""Returns client version string"""
34+
return f"/api/{self.client_version}"
35+
2936
def default_headers(self) -> Dict[str, str]:
3037
"""Return default headers containing Dune Api token"""
3138
return {"x-dune-api-key": self.token}

dune_client/client.py

Lines changed: 153 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
ExecutionState,
2525
)
2626

27-
from dune_client.query import Query
27+
from dune_client.query import QueryBase, DuneQuery
28+
from dune_client.types import QueryParameter
2829

2930

3031
class DuneClient(DuneInterface, BaseDuneClient):
@@ -34,6 +35,7 @@ class DuneClient(DuneInterface, BaseDuneClient):
3435
"""
3536

3637
def _handle_response(self, response: Response) -> Any:
38+
"""Generic response handler utilized by all Dune API routes"""
3739
try:
3840
# Some responses can be decoded and converted to DuneErrors
3941
response_json = response.json()
@@ -45,14 +47,15 @@ def _handle_response(self, response: Response) -> Any:
4547
raise ValueError("Unreachable since previous line raises") from err
4648

4749
def _route_url(self, route: str) -> str:
48-
return f"{self.BASE_URL}{self.API_PATH}{route}"
50+
return f"{self.BASE_URL}{self.api_version}{route}"
4951

5052
def _get(
5153
self,
5254
route: str,
5355
params: Optional[Any] = None,
5456
raw: bool = False,
5557
) -> Any:
58+
"""Generic interface for the GET method of a Dune API request"""
5659
url = self._route_url(route)
5760
self.logger.debug(f"GET received input url={url}")
5861
response = requests.get(
@@ -65,7 +68,8 @@ def _get(
6568
return response
6669
return self._handle_response(response)
6770

68-
def _post(self, route: str, params: Any) -> Any:
71+
def _post(self, route: str, params: Optional[Any] = None) -> Any:
72+
"""Generic interface for the POST method of a Dune API request"""
6973
url = self._route_url(route)
7074
self.logger.debug(f"POST received input url={url}, params={params}")
7175
response = requests.post(
@@ -76,8 +80,21 @@ def _post(self, route: str, params: Any) -> Any:
7680
)
7781
return self._handle_response(response)
7882

83+
def _patch(self, route: str, params: Any) -> Any:
84+
"""Generic interface for the PATCH method of a Dune API request"""
85+
url = self._route_url(route)
86+
self.logger.debug(f"PATCH received input url={url}, params={params}")
87+
response = requests.request(
88+
method="PATCH",
89+
url=url,
90+
json=params,
91+
headers={"x-dune-api-key": self.token},
92+
timeout=self.DEFAULT_TIMEOUT,
93+
)
94+
return self._handle_response(response)
95+
7996
def execute(
80-
self, query: Query, performance: Optional[str] = None
97+
self, query: QueryBase, performance: Optional[str] = None
8198
) -> ExecutionResponse:
8299
"""Post's to Dune API for execute `query`"""
83100
params = query.request_format()
@@ -126,15 +143,15 @@ def get_result_csv(self, job_id: str) -> ExecutionResultCSV:
126143
response.raise_for_status()
127144
return ExecutionResultCSV(data=BytesIO(response.content))
128145

129-
def get_latest_result(self, query: Union[Query, str, int]) -> ResultsResponse:
146+
def get_latest_result(self, query: Union[QueryBase, str, int]) -> ResultsResponse:
130147
"""
131148
GET the latest results for a query_id without having to execute the query again.
132149
133150
:param query: :class:`Query` object OR query id as string | int
134151
135152
https://dune.com/docs/api/api-reference/latest_results/
136153
"""
137-
if isinstance(query, Query):
154+
if isinstance(query, QueryBase):
138155
params = {
139156
f"params.{p.key}": p.to_dict()["value"] for p in query.parameters()
140157
}
@@ -167,7 +184,7 @@ def cancel_execution(self, job_id: str) -> bool:
167184

168185
def _refresh(
169186
self,
170-
query: Query,
187+
query: QueryBase,
171188
ping_frequency: int = 5,
172189
performance: Optional[str] = None,
173190
) -> str:
@@ -191,7 +208,10 @@ def _refresh(
191208
return job_id
192209

193210
def refresh(
194-
self, query: Query, ping_frequency: int = 5, performance: Optional[str] = None
211+
self,
212+
query: QueryBase,
213+
ping_frequency: int = 5,
214+
performance: Optional[str] = None,
195215
) -> ResultsResponse:
196216
"""
197217
Executes a Dune `query`, waits until execution completes,
@@ -204,7 +224,10 @@ def refresh(
204224
return self.get_result(job_id)
205225

206226
def refresh_csv(
207-
self, query: Query, ping_frequency: int = 5, performance: Optional[str] = None
227+
self,
228+
query: QueryBase,
229+
ping_frequency: int = 5,
230+
performance: Optional[str] = None,
208231
) -> ExecutionResultCSV:
209232
"""
210233
Executes a Dune query, waits till execution completes,
@@ -217,7 +240,7 @@ def refresh_csv(
217240
return self.get_result_csv(job_id)
218241

219242
def refresh_into_dataframe(
220-
self, query: Query, performance: Optional[str] = None
243+
self, query: QueryBase, performance: Optional[str] = None
221244
) -> Any:
222245
"""
223246
Execute a Dune Query, waits till execution completes,
@@ -233,3 +256,123 @@ def refresh_into_dataframe(
233256
) from exc
234257
data = self.refresh_csv(query, performance=performance).data
235258
return pandas.read_csv(data)
259+
260+
# CRUD Operations: https://dune.com/docs/api/api-reference/edit-queries/
261+
def create_query(
262+
self,
263+
name: str,
264+
query_sql: str,
265+
params: Optional[list[QueryParameter]] = None,
266+
is_private: bool = False,
267+
) -> DuneQuery:
268+
"""
269+
Creates Dune Query by ID
270+
https://dune.com/docs/api/api-reference/edit-queries/create-query/
271+
"""
272+
parameters = {
273+
"name": name,
274+
"query_sql": query_sql,
275+
"private": is_private,
276+
}
277+
if params is not None:
278+
parameters["parameters"] = [p.to_dict() for p in params]
279+
response_json = self._post(route="/query/", params=parameters)
280+
try:
281+
query_id = int(response_json["query_id"])
282+
# Note that this requires an extra request.
283+
return self.get_query(query_id)
284+
except KeyError as err:
285+
raise DuneError(response_json, "create_query Response", err) from err
286+
287+
def get_query(self, query_id: int) -> DuneQuery:
288+
"""
289+
Retrieves Dune Query by ID
290+
https://dune.com/docs/api/api-reference/edit-queries/get-query/
291+
"""
292+
response_json = self._get(route=f"/query/{query_id}")
293+
return DuneQuery.from_dict(response_json)
294+
295+
def update_query( # pylint: disable=too-many-arguments
296+
self,
297+
query_id: int,
298+
name: Optional[str] = None,
299+
query_sql: Optional[str] = None,
300+
params: Optional[list[QueryParameter]] = None,
301+
description: Optional[str] = None,
302+
tags: Optional[list[str]] = None,
303+
) -> int:
304+
"""
305+
Updates Dune Query by ID
306+
https://dune.com/docs/api/api-reference/edit-queries/update-query
307+
308+
The request body should contain all fields that need to be updated.
309+
Any omitted fields will be left untouched.
310+
If the tags or parameters are provided as an empty array,
311+
they will be deleted from the query.
312+
"""
313+
parameters: dict[str, Any] = {}
314+
if name is not None:
315+
parameters["name"] = name
316+
if description is not None:
317+
parameters["description"] = description
318+
if tags is not None:
319+
parameters["tags"] = tags
320+
if query_sql is not None:
321+
parameters["query_sql"] = query_sql
322+
if params is not None:
323+
parameters["parameters"] = [p.to_dict() for p in params]
324+
325+
if not bool(parameters):
326+
# Nothing to change no need to make reqeust
327+
self.logger.warning("called update_query with no proposed changes.")
328+
return query_id
329+
330+
response_json = self._patch(
331+
route=f"/query/{query_id}",
332+
params=parameters,
333+
)
334+
try:
335+
# No need to make a dataclass for this since it's just a boolean.
336+
return int(response_json["query_id"])
337+
except KeyError as err:
338+
raise DuneError(response_json, "update_query Response", err) from err
339+
340+
def archive_query(self, query_id: int) -> bool:
341+
"""
342+
https://dune.com/docs/api/api-reference/edit-queries/archive-query
343+
returns resulting value of Query.is_archived
344+
"""
345+
response_json = self._post(route=f"/query/{query_id}/archive")
346+
try:
347+
# No need to make a dataclass for this since it's just a boolean.
348+
return self.get_query(int(response_json["query_id"])).meta.is_archived
349+
except KeyError as err:
350+
raise DuneError(response_json, "make_private Response", err) from err
351+
352+
def unarchive_query(self, query_id: int) -> bool:
353+
"""
354+
https://dune.com/docs/api/api-reference/edit-queries/archive-query
355+
returns resulting value of Query.is_archived
356+
"""
357+
response_json = self._post(route=f"/query/{query_id}/unarchive")
358+
try:
359+
# No need to make a dataclass for this since it's just a boolean.
360+
return self.get_query(int(response_json["query_id"])).meta.is_archived
361+
except KeyError as err:
362+
raise DuneError(response_json, "make_private Response", err) from err
363+
364+
def make_private(self, query_id: int) -> None:
365+
"""
366+
https://dune.com/docs/api/api-reference/edit-queries/private-query
367+
returns resulting value of Query.is_private
368+
"""
369+
response_json = self._post(route=f"/query/{query_id}/private")
370+
assert self.get_query(int(response_json["query_id"])).meta.is_private
371+
372+
def make_public(self, query_id: int) -> None:
373+
"""
374+
https://dune.com/docs/api/api-reference/edit-queries/private-query
375+
returns resulting value of Query.is_private
376+
"""
377+
response_json = self._post(route=f"/query/{query_id}/unprivate")
378+
assert not self.get_query(int(response_json["query_id"])).meta.is_private

dune_client/client_async.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
ExecutionState,
2929
)
3030

31-
from dune_client.query import Query
31+
from dune_client.query import QueryBase
3232

3333

3434
# pylint: disable=duplicate-code
@@ -88,7 +88,7 @@ async def _handle_response(self, response: ClientResponse) -> Any:
8888
raise ValueError("Unreachable since previous line raises") from err
8989

9090
def _route_url(self, route: str) -> str:
91-
return f"{self.API_PATH}{route}"
91+
return f"{self.api_version}{route}"
9292

9393
async def _get(
9494
self,
@@ -122,7 +122,7 @@ async def _post(self, route: str, params: Any) -> Any:
122122
return await self._handle_response(response)
123123

124124
async def execute(
125-
self, query: Query, performance: Optional[str] = None
125+
self, query: QueryBase, performance: Optional[str] = None
126126
) -> ExecutionResponse:
127127
"""Post's to Dune API for execute `query`"""
128128
params = query.request_format()
@@ -171,15 +171,17 @@ async def get_result_csv(self, job_id: str) -> ExecutionResultCSV:
171171
response.raise_for_status()
172172
return ExecutionResultCSV(data=BytesIO(await response.content.read(-1)))
173173

174-
async def get_latest_result(self, query: Union[Query, str, int]) -> ResultsResponse:
174+
async def get_latest_result(
175+
self, query: Union[QueryBase, str, int]
176+
) -> ResultsResponse:
175177
"""
176178
GET the latest results for a query_id without having to execute the query again.
177179
178180
:param query: :class:`Query` object OR query id as string | int
179181
180182
https://dune.com/docs/api/api-reference/latest_results/
181183
"""
182-
if isinstance(query, Query):
184+
if isinstance(query, QueryBase):
183185
params = {
184186
f"params.{p.key}": p.to_dict()["value"] for p in query.parameters()
185187
}
@@ -212,7 +214,7 @@ async def cancel_execution(self, job_id: str) -> bool:
212214

213215
async def _refresh(
214216
self,
215-
query: Query,
217+
query: QueryBase,
216218
ping_frequency: int = 5,
217219
performance: Optional[str] = None,
218220
) -> str:
@@ -236,7 +238,10 @@ async def _refresh(
236238
return job_id
237239

238240
async def refresh(
239-
self, query: Query, ping_frequency: int = 5, performance: Optional[str] = None
241+
self,
242+
query: QueryBase,
243+
ping_frequency: int = 5,
244+
performance: Optional[str] = None,
240245
) -> ResultsResponse:
241246
"""
242247
Executes a Dune `query`, waits until execution completes,
@@ -249,7 +254,10 @@ async def refresh(
249254
return await self.get_result(job_id)
250255

251256
async def refresh_csv(
252-
self, query: Query, ping_frequency: int = 5, performance: Optional[str] = None
257+
self,
258+
query: QueryBase,
259+
ping_frequency: int = 5,
260+
performance: Optional[str] = None,
253261
) -> ExecutionResultCSV:
254262
"""
255263
Executes a Dune query, waits till execution completes,
@@ -262,7 +270,7 @@ async def refresh_csv(
262270
return await self.get_result_csv(job_id)
263271

264272
async def refresh_into_dataframe(
265-
self, query: Query, performance: Optional[str] = None
273+
self, query: QueryBase, performance: Optional[str] = None
266274
) -> Any:
267275
"""
268276
Execute a Dune Query, waits till execution completes,

0 commit comments

Comments
 (0)