Skip to content

Commit 427c5ba

Browse files
committed
Add SQL execution and table listing features to Dune client
- Implemented `execute_sql` method in `ExecutionAPI` to allow direct SQL execution via the API. - Updated `ExtendedAPI` to utilize the new SQL execution method for running queries. - Added `list_tables` method in `TableAPI` to retrieve a paginated list of tables. - Introduced `UsageResponse`, `TableInfo`, and `ListTablesResponse` models for handling API responses. - Enhanced unit tests to cover new features and response parsing for usage and table information.
1 parent 90975e6 commit 427c5ba

File tree

7 files changed

+349
-25
lines changed

7 files changed

+349
-25
lines changed

dune_client/api/execution.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
get results: https://docs.dune.com/api-reference/executions/endpoint/get-execution-result
77
"""
88

9+
from __future__ import annotations
10+
911
from io import BytesIO
10-
from typing import Any
12+
from typing import TYPE_CHECKING, Any
1113

1214
from deprecated import deprecated
1315

@@ -26,6 +28,9 @@
2628
)
2729
from dune_client.query import QueryBase # noqa: TC001
2830

31+
if TYPE_CHECKING:
32+
from dune_client.types import QueryParameter
33+
2934

3035
class ExecutionAPI(BaseRouter):
3136
"""
@@ -47,6 +52,41 @@ def execute_query(self, query: QueryBase, performance: str | None = None) -> Exe
4752
except KeyError as err:
4853
raise DuneError(response_json, "ExecutionResponse", err) from err
4954

55+
def execute_sql(
56+
self,
57+
query_sql: str,
58+
params: list[QueryParameter] | None = None,
59+
performance: str | None = None,
60+
) -> ExecutionResponse:
61+
"""
62+
Execute arbitrary SQL directly via the API without creating a saved query.
63+
https://docs.dune.com/api-reference/executions/endpoint/execute-query
64+
65+
Args:
66+
query_sql: The SQL query string to execute
67+
params: Optional list of query parameters
68+
performance: Optional performance tier ("medium" or "large")
69+
70+
Returns:
71+
ExecutionResponse with execution_id and state
72+
"""
73+
payload: dict[str, Any] = {"query_sql": query_sql}
74+
75+
if params:
76+
payload["query_parameters"] = {p.key: p.to_dict()["value"] for p in params}
77+
78+
payload["performance"] = performance or self.performance
79+
80+
self.logger.info(f"executing SQL on {performance or self.performance} cluster")
81+
response_json = self._post(
82+
route="/sql/execute",
83+
params=payload,
84+
)
85+
try:
86+
return ExecutionResponse.from_dict(response_json)
87+
except KeyError as err:
88+
raise DuneError(response_json, "ExecutionResponse", err) from err
89+
5090
def cancel_execution(self, job_id: str) -> bool:
5191
"""POST Execution Cancellation to Dune API for `job_id` (aka `execution_id`)"""
5292
response_json = self._post(

dune_client/api/extensions.py

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from dune_client.api.execution import ExecutionAPI
2020
from dune_client.api.query import QueryAPI
2121
from dune_client.api.table import TableAPI
22+
from dune_client.api.usage import UsageAPI
2223
from dune_client.models import (
2324
DuneError,
2425
ExecutionResultCSV,
@@ -38,7 +39,7 @@
3839
POLL_FREQUENCY_SECONDS = 1
3940

4041

41-
class ExtendedAPI(ExecutionAPI, QueryAPI, TableAPI, CustomEndpointAPI):
42+
class ExtendedAPI(ExecutionAPI, QueryAPI, TableAPI, UsageAPI, CustomEndpointAPI):
4243
"""
4344
Provides higher level helper methods for faster
4445
and easier development on top of the base ExecutionAPI.
@@ -321,20 +322,49 @@ def run_sql(
321322
name: str = "API Query",
322323
) -> ResultsResponse:
323324
"""
324-
Allows user to provide execute raw_sql via the CRUD interface
325-
- create, run, get results with optional archive/delete.
326-
- Query is by default made private and archived after execution.
325+
Execute arbitrary SQL directly via the API and return results.
326+
Uses the /sql/execute endpoint introduced in the Dune API.
327+
https://docs.dune.com/api-reference/executions/endpoint/execute-query
328+
329+
Note: The `name`, `is_private`, and `archive_after` parameters are kept for
330+
backward compatibility but are ignored when using the direct SQL execution endpoint.
331+
332+
Args:
333+
query_sql: The SQL query string to execute
334+
params: Optional list of query parameters
335+
is_private: (Ignored) Kept for backward compatibility
336+
archive_after: (Ignored) Kept for backward compatibility
337+
performance: Optional performance tier ("medium" or "large")
338+
ping_frequency: Seconds between status checks while polling
339+
name: (Ignored) Kept for backward compatibility
340+
341+
Returns:
342+
ResultsResponse with the query execution results
343+
327344
Requires Plus subscription!
328345
"""
329-
query = self.create_query(name, query_sql, params, is_private)
330-
try:
331-
results = self.run_query(
332-
query=query.base, performance=performance, ping_frequency=ping_frequency
333-
)
334-
finally:
335-
if archive_after:
336-
self.archive_query(query.base.query_id)
337-
return results
346+
# Execute SQL directly using the new endpoint
347+
job_id = self.execute_sql(
348+
query_sql=query_sql,
349+
params=params,
350+
performance=performance,
351+
).execution_id
352+
353+
# Poll for completion
354+
status = self.get_execution_status(job_id)
355+
while status.state not in ExecutionState.terminal_states():
356+
self.logger.info(f"waiting for query execution {job_id} to complete: {status}")
357+
time.sleep(ping_frequency)
358+
status = self.get_execution_status(job_id)
359+
360+
if status.state == ExecutionState.FAILED:
361+
self.logger.error(status)
362+
raise QueryFailedError(f"Error data: {status.error}")
363+
364+
# Fetch and return results
365+
return self._fetch_entire_result(
366+
self.get_execution_results(job_id)
367+
)
338368

339369
######################
340370
# Deprecated Functions

dune_client/api/table.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
DeleteTableResult,
1515
DuneError,
1616
InsertTableResult,
17+
ListTablesResponse,
1718
)
1819

1920

@@ -137,3 +138,34 @@ def delete_table(self, namespace: str, table_name: str) -> DeleteTableResult:
137138
return DeleteTableResult.from_dict(response_json)
138139
except KeyError as err:
139140
raise DuneError(response_json, "DeleteTableResult", err) from err
141+
142+
def list_tables(
143+
self,
144+
limit: int | None = None,
145+
offset: int | None = None,
146+
) -> ListTablesResponse:
147+
"""
148+
https://docs.dune.com/api-reference/tables/endpoint/list
149+
Get a paginated list of tables uploaded to Dune.
150+
151+
Args:
152+
limit: Maximum number of tables to return (optional)
153+
offset: Offset for pagination (optional)
154+
155+
Returns:
156+
ListTablesResponse containing list of tables and pagination info
157+
"""
158+
params = {}
159+
if limit is not None:
160+
params["limit"] = limit
161+
if offset is not None:
162+
params["offset"] = offset
163+
164+
response_json = self._get(
165+
route="/tables",
166+
params=params if params else None,
167+
)
168+
try:
169+
return ListTablesResponse.from_dict(response_json)
170+
except KeyError as err:
171+
raise DuneError(response_json, "ListTablesResponse", err) from err

dune_client/api/usage.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""
2+
Usage API endpoints enable users to retrieve usage and billing information.
3+
https://docs.dune.com/api-reference/usage/endpoint/get-usage
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from dune_client.api.base import BaseRouter
9+
from dune_client.models import DuneError, UsageResponse
10+
11+
12+
class UsageAPI(BaseRouter):
13+
"""
14+
Implementation of Usage endpoints - Plus subscription only
15+
https://docs.dune.com/api-reference/usage/
16+
"""
17+
18+
def get_usage(
19+
self,
20+
start_date: str,
21+
end_date: str,
22+
) -> UsageResponse:
23+
"""
24+
Get credits usage data, overage, number of private queries run, storage, etc.
25+
over a specific timeframe.
26+
https://docs.dune.com/api-reference/usage/endpoint/get-usage
27+
28+
Args:
29+
start_date: Start date for the usage period (ISO format: YYYY-MM-DD)
30+
end_date: End date for the usage period (ISO format: YYYY-MM-DD)
31+
32+
Returns:
33+
UsageResponse containing usage statistics
34+
35+
Requires Plus subscription!
36+
"""
37+
params = {
38+
"start_date": start_date,
39+
"end_date": end_date,
40+
}
41+
response_json = self._get(
42+
route="/usage",
43+
params=params,
44+
)
45+
try:
46+
return UsageResponse.from_dict(response_json)
47+
except KeyError as err:
48+
raise DuneError(response_json, "UsageResponse", err) from err
49+

dune_client/models.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,3 +396,68 @@ class ClearTableResult(DataClassJsonMixin):
396396
"""
397397

398398
message: str
399+
400+
401+
@dataclass
402+
class UsageResponse:
403+
"""
404+
Representation of Response from Dune's [GET] Usage endpoint
405+
https://docs.dune.com/api-reference/usage/endpoint/get-usage
406+
"""
407+
408+
credits_used: int
409+
overage_credits: int
410+
private_query_executions: int
411+
storage_bytes: int
412+
413+
@classmethod
414+
def from_dict(cls, data: dict[str, Any]) -> UsageResponse:
415+
"""Constructor from dictionary."""
416+
return cls(
417+
credits_used=int(data.get("credits_used", 0)),
418+
overage_credits=int(data.get("overage_credits", 0)),
419+
private_query_executions=int(data.get("private_query_executions", 0)),
420+
storage_bytes=int(data.get("storage_bytes", 0)),
421+
)
422+
423+
424+
@dataclass
425+
class TableInfo:
426+
"""Information about a single table"""
427+
428+
namespace: str
429+
table_name: str
430+
full_name: str
431+
created_at: str
432+
is_private: bool
433+
434+
@classmethod
435+
def from_dict(cls, data: dict[str, Any]) -> TableInfo:
436+
"""Constructor from dictionary."""
437+
return cls(
438+
namespace=data["namespace"],
439+
table_name=data["table_name"],
440+
full_name=data["full_name"],
441+
created_at=data["created_at"],
442+
is_private=data["is_private"],
443+
)
444+
445+
446+
@dataclass
447+
class ListTablesResponse:
448+
"""
449+
Representation of Response from Dune's [GET] Tables List endpoint
450+
https://docs.dune.com/api-reference/tables/endpoint/list
451+
"""
452+
453+
tables: list[TableInfo]
454+
next_offset: int | None
455+
456+
@classmethod
457+
def from_dict(cls, data: dict[str, Any]) -> ListTablesResponse:
458+
"""Constructor from dictionary."""
459+
tables_data = data.get("tables", [])
460+
return cls(
461+
tables=[TableInfo.from_dict(table) for table in tables_data],
462+
next_offset=data.get("next_offset"),
463+
)

tests/e2e/test_client.py

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,49 @@ def test_download_csv_success_with_params(self):
385385
}
386386
]
387387

388+
def test_run_sql(self):
389+
"""Test the run_sql method that uses /sql/execute endpoint"""
390+
dune = DuneClient()
391+
query_sql = "select 85 as result"
392+
results = dune.run_sql(query_sql)
393+
assert results.get_rows() == [{"result": 85}]
394+
# Note: With the new /sql/execute endpoint, no saved query is created,
395+
# so this is purely an execution operation, not a CRUD operation.
396+
397+
@unittest.skip("Requires Plus subscription and valid data")
398+
def test_get_usage(self):
399+
"""Test the get_usage endpoint"""
400+
dune = DuneClient()
401+
usage = dune.get_usage("2024-01-01", "2024-01-31")
402+
# Verify response structure
403+
assert hasattr(usage, "credits_used")
404+
assert hasattr(usage, "overage_credits")
405+
assert hasattr(usage, "private_query_executions")
406+
assert hasattr(usage, "storage_bytes")
407+
# All should be integers
408+
assert isinstance(usage.credits_used, int)
409+
assert isinstance(usage.overage_credits, int)
410+
assert isinstance(usage.private_query_executions, int)
411+
assert isinstance(usage.storage_bytes, int)
412+
413+
@unittest.skip("Requires Plus subscription and uploaded tables")
414+
def test_list_tables(self):
415+
"""Test the list_tables endpoint"""
416+
dune = DuneClient()
417+
tables_response = dune.list_tables(limit=10)
418+
# Verify response structure
419+
assert hasattr(tables_response, "tables")
420+
assert hasattr(tables_response, "next_offset")
421+
assert isinstance(tables_response.tables, list)
422+
# If there are tables, verify their structure
423+
if len(tables_response.tables) > 0:
424+
table = tables_response.tables[0]
425+
assert hasattr(table, "namespace")
426+
assert hasattr(table, "table_name")
427+
assert hasattr(table, "full_name")
428+
assert hasattr(table, "created_at")
429+
assert hasattr(table, "is_private")
430+
388431

389432
@unittest.skip("This is an enterprise only endpoint that can no longer be tested.")
390433
class TestCRUDOps(unittest.TestCase):
@@ -421,17 +464,6 @@ def test_archive(self):
421464
assert self.client.archive_query(self.existing_query_id)
422465
assert not self.client.unarchive_query(self.existing_query_id)
423466

424-
@unittest.skip("Works fine, but creates too many queries!")
425-
def test_run_sql(self):
426-
query_sql = "select 85"
427-
results = self.client.run_sql(query_sql)
428-
assert results.get_rows() == [{"_col0": 85}]
429-
430-
# The default functionality is meant to create a private query and then archive it.
431-
query = self.client.get_query(results.query_id)
432-
assert query.meta.is_archived
433-
assert query.meta.is_private
434-
435467

436468
if __name__ == "__main__":
437469
unittest.main()

0 commit comments

Comments
 (0)