spice-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of spice-mcp might be problematic. Click here for more details.

Files changed (39) hide show
  1. spice_mcp/__init__.py +2 -0
  2. spice_mcp/adapters/__init__.py +0 -0
  3. spice_mcp/adapters/dune/__init__.py +10 -0
  4. spice_mcp/adapters/dune/admin.py +94 -0
  5. spice_mcp/adapters/dune/cache.py +185 -0
  6. spice_mcp/adapters/dune/client.py +255 -0
  7. spice_mcp/adapters/dune/extract.py +1461 -0
  8. spice_mcp/adapters/dune/helpers.py +11 -0
  9. spice_mcp/adapters/dune/transport.py +70 -0
  10. spice_mcp/adapters/dune/types.py +52 -0
  11. spice_mcp/adapters/dune/typing_utils.py +10 -0
  12. spice_mcp/adapters/dune/urls.py +126 -0
  13. spice_mcp/adapters/http_client.py +156 -0
  14. spice_mcp/config.py +81 -0
  15. spice_mcp/core/__init__.py +0 -0
  16. spice_mcp/core/errors.py +101 -0
  17. spice_mcp/core/models.py +88 -0
  18. spice_mcp/core/ports.py +69 -0
  19. spice_mcp/logging/query_history.py +131 -0
  20. spice_mcp/mcp/__init__.py +1 -0
  21. spice_mcp/mcp/server.py +546 -0
  22. spice_mcp/mcp/tools/__init__.py +1 -0
  23. spice_mcp/mcp/tools/base.py +41 -0
  24. spice_mcp/mcp/tools/execute_query.py +425 -0
  25. spice_mcp/mcp/tools/sui_package_overview.py +56 -0
  26. spice_mcp/observability/__init__.py +0 -0
  27. spice_mcp/observability/logging.py +18 -0
  28. spice_mcp/polars_utils.py +15 -0
  29. spice_mcp/py.typed +1 -0
  30. spice_mcp/service_layer/__init__.py +0 -0
  31. spice_mcp/service_layer/discovery_service.py +20 -0
  32. spice_mcp/service_layer/query_admin_service.py +26 -0
  33. spice_mcp/service_layer/query_service.py +118 -0
  34. spice_mcp/service_layer/sui_service.py +131 -0
  35. spice_mcp-0.1.0.dist-info/METADATA +133 -0
  36. spice_mcp-0.1.0.dist-info/RECORD +39 -0
  37. spice_mcp-0.1.0.dist-info/WHEEL +4 -0
  38. spice_mcp-0.1.0.dist-info/entry_points.txt +2 -0
  39. spice_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1461 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import time
5
+ from typing import TYPE_CHECKING, overload
6
+
7
+ from ..http_client import HttpClient
8
+ from . import cache as _cache
9
+ from . import urls as _urls
10
+ from .transport import (
11
+ current_http_client,
12
+ use_http_client,
13
+ )
14
+ from .transport import (
15
+ get as _transport_get,
16
+ )
17
+ from .transport import (
18
+ post as _transport_post,
19
+ )
20
+ from .types import (
21
+ ExecuteKwargs,
22
+ Execution,
23
+ OutputKwargs,
24
+ Performance,
25
+ PollKwargs,
26
+ Query,
27
+ RetrievalKwargs,
28
+ )
29
+ from .typing_utils import resolve_raw_sql_template_id
30
+
31
+ # Keep local helper implementations for compatibility with tests
32
+
33
+ ADAPTER_VERSION = "spice-mcp-adapter"
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Mapping, Sequence
37
+ from typing import Any, Literal
38
+
39
+ import polars as pl
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Back-compat helpers expected by tests and adapter code
44
+
45
+ def _is_sql(query: int | str) -> bool:
46
+ if isinstance(query, int):
47
+ return False
48
+ if isinstance(query, str):
49
+ if query.startswith('https://') or query.startswith('api.dune.com') or query.startswith('dune.com/queries'):
50
+ return False
51
+ try:
52
+ int(query)
53
+ return False
54
+ except ValueError:
55
+ return True
56
+ raise Exception('invalid format for query: ' + str(type(query)))
57
+
58
+
59
+ def _determine_input_type(
60
+ query_or_execution: Query | Execution,
61
+ parameters: Mapping[str, Any] | None = None,
62
+ ) -> tuple[int | None, Execution | None, Mapping[str, Any] | None]:
63
+ if isinstance(query_or_execution, str) and query_or_execution == '':
64
+ raise Exception('empty query')
65
+ if isinstance(query_or_execution, int | str):
66
+ if _is_sql(query_or_execution):
67
+ query_id = resolve_raw_sql_template_id()
68
+ execution = None
69
+ new_params = dict(parameters or {})
70
+ new_params.update({'query': query_or_execution})
71
+ parameters = new_params
72
+ else:
73
+ query_id = _urls.get_query_id(query_or_execution)
74
+ execution = None
75
+ elif isinstance(query_or_execution, dict) and 'execution_id' in query_or_execution:
76
+ query_id = None
77
+ execution = query_or_execution # type: ignore[assignment]
78
+ else:
79
+ raise Exception('input must be a query id, query url, or execution id')
80
+ return query_id, execution, parameters
81
+
82
+
83
+ def _http_get(url: str, *, headers: Mapping[str, str], timeout: float):
84
+ import requests
85
+
86
+ return requests.get(url, headers=headers, timeout=timeout)
87
+
88
+
89
+ @overload
90
+ def query(
91
+ query_or_execution: Query | Execution,
92
+ *,
93
+ verbose: bool = True,
94
+ refresh: bool = False,
95
+ max_age: float | None = None,
96
+ parameters: Mapping[str, Any] | None = None,
97
+ api_key: str | None = None,
98
+ performance: Performance = 'medium',
99
+ poll: Literal[False],
100
+ poll_interval: float = 1.0,
101
+ timeout_seconds: float | None = None,
102
+ limit: int | None = None,
103
+ offset: int | None = None,
104
+ sample_count: int | None = None,
105
+ sort_by: str | None = None,
106
+ columns: Sequence[str] | None = None,
107
+ extras: Mapping[str, Any] | None = None,
108
+ types: Sequence[type[pl.DataType]]
109
+ | Mapping[str, type[pl.DataType]]
110
+ | None = None,
111
+ all_types: Sequence[type[pl.DataType]]
112
+ | Mapping[str, type[pl.DataType]]
113
+ | None = None,
114
+ cache: bool = True,
115
+ cache_dir: str | None = None,
116
+ save_to_cache: bool = True,
117
+ load_from_cache: bool = True,
118
+ include_execution: bool = False,
119
+ ) -> Execution: ...
120
+
121
+
122
+ @overload
123
+ def query(
124
+ query_or_execution: Query | Execution,
125
+ *,
126
+ verbose: bool = True,
127
+ refresh: bool = False,
128
+ max_age: float | None = None,
129
+ parameters: Mapping[str, Any] | None = None,
130
+ api_key: str | None = None,
131
+ performance: Performance = 'medium',
132
+ poll: Literal[True] = True,
133
+ poll_interval: float = 1.0,
134
+ limit: int | None = None,
135
+ offset: int | None = None,
136
+ sample_count: int | None = None,
137
+ sort_by: str | None = None,
138
+ columns: Sequence[str] | None = None,
139
+ extras: Mapping[str, Any] | None = None,
140
+ types: Sequence[type[pl.DataType]]
141
+ | Mapping[str, type[pl.DataType]]
142
+ | None = None,
143
+ all_types: Sequence[type[pl.DataType]]
144
+ | Mapping[str, type[pl.DataType]]
145
+ | None = None,
146
+ cache: bool = True,
147
+ cache_dir: str | None = None,
148
+ save_to_cache: bool = True,
149
+ load_from_cache: bool = True,
150
+ include_execution: Literal[False] = False,
151
+ ) -> pl.DataFrame: ...
152
+
153
+
154
+ @overload
155
+ def query(
156
+ query_or_execution: Query | Execution,
157
+ *,
158
+ verbose: bool = True,
159
+ refresh: bool = False,
160
+ max_age: float | None = None,
161
+ parameters: Mapping[str, Any] | None = None,
162
+ api_key: str | None = None,
163
+ performance: Performance = 'medium',
164
+ poll: Literal[True] = True,
165
+ poll_interval: float = 1.0,
166
+ limit: int | None = None,
167
+ offset: int | None = None,
168
+ sample_count: int | None = None,
169
+ sort_by: str | None = None,
170
+ columns: Sequence[str] | None = None,
171
+ extras: Mapping[str, Any] | None = None,
172
+ types: Sequence[type[pl.DataType]]
173
+ | Mapping[str, type[pl.DataType]]
174
+ | None = None,
175
+ all_types: Sequence[type[pl.DataType]]
176
+ | Mapping[str, type[pl.DataType]]
177
+ | None = None,
178
+ cache: bool = True,
179
+ cache_dir: str | None = None,
180
+ save_to_cache: bool = True,
181
+ load_from_cache: bool = True,
182
+ include_execution: Literal[True],
183
+ ) -> tuple[pl.DataFrame, Execution]: ...
184
+
185
+
186
+ def query(
187
+ query_or_execution: Query | Execution,
188
+ *,
189
+ verbose: bool = True,
190
+ refresh: bool = False,
191
+ max_age: float | None = None,
192
+ parameters: Mapping[str, Any] | None = None,
193
+ api_key: str | None = None,
194
+ performance: Performance = 'medium',
195
+ poll: bool = True,
196
+ poll_interval: float = 1.0,
197
+ timeout_seconds: float | None = None,
198
+ limit: int | None = None,
199
+ offset: int | None = None,
200
+ sample_count: int | None = None,
201
+ sort_by: str | None = None,
202
+ columns: Sequence[str] | None = None,
203
+ extras: Mapping[str, Any] | None = None,
204
+ types: Sequence[type[pl.DataType]]
205
+ | Mapping[str, type[pl.DataType]]
206
+ | None = None,
207
+ all_types: Sequence[type[pl.DataType]]
208
+ | Mapping[str, type[pl.DataType]]
209
+ | None = None,
210
+ cache: bool = True,
211
+ cache_dir: str | None = None,
212
+ save_to_cache: bool = True,
213
+ load_from_cache: bool = True,
214
+ include_execution: bool = False,
215
+ http_client: HttpClient | None = None,
216
+ ) -> pl.DataFrame | Execution | tuple[pl.DataFrame, Execution]:
217
+ """get results of query as dataframe
218
+
219
+ # Parameters
220
+ - query: query or execution to retrieve results of
221
+ - verbose: whether to print verbose info
222
+ - refresh: trigger a new execution instead of using most recent execution
223
+ - max_age: max age of last execution in seconds, or trigger a new execution
224
+ - parameters: dict of query parameters
225
+ - api_key: dune api key, otherwise use DUNE_API_KEY env var
226
+ - performance: performance level
227
+ - poll: wait for result as DataFrame, or just return Execution handle
228
+ - poll_interval: polling interval in seconds
229
+ - limit: number of rows to query in result
230
+ - offset: row number to start returning results from
231
+ - sample_count: number of random samples from query to return
232
+ - sort_by: an ORDER BY clause to sort data by
233
+ - columns: columns to retrieve, by default retrieve all columns
234
+ - extras: extra parameters used for fetching execution result
235
+ - examples: ignore_max_datapoints_per_request, allow_partial_results
236
+ - types: column types to use in output polars dataframe
237
+ - all_types: like types, but must strictly include all columns in data
238
+ - cache: whether to use cache for saving or loading
239
+ - cache_dir: directory to use for cached data (create tmp_dir if None)
240
+ - save_to_cache: whether to save to cache, set false to load only
241
+ - load_from_cache: whether to load from cache, set false to save only
242
+ - include_execution: return Execution metadata alongside query result
243
+ """
244
+
245
+ with use_http_client(http_client):
246
+ # determine whether target is a query or an execution
247
+ query_id, execution, parameters = _determine_input_type(
248
+ query_or_execution,
249
+ parameters,
250
+ )
251
+
252
+ # gather arguments
253
+ execute_kwargs: ExecuteKwargs = {
254
+ 'query_id': query_id,
255
+ 'api_key': api_key,
256
+ 'parameters': parameters,
257
+ 'performance': performance,
258
+ }
259
+ poll_kwargs: PollKwargs = {
260
+ 'poll_interval': poll_interval,
261
+ 'api_key': api_key,
262
+ 'verbose': verbose,
263
+ 'timeout_seconds': timeout_seconds,
264
+ }
265
+ result_kwargs: RetrievalKwargs = {
266
+ 'limit': limit,
267
+ 'offset': offset,
268
+ 'sample_count': sample_count,
269
+ 'sort_by': sort_by,
270
+ 'columns': columns,
271
+ 'extras': extras,
272
+ 'types': types,
273
+ 'all_types': all_types,
274
+ 'verbose': verbose,
275
+ }
276
+ output_kwargs: OutputKwargs = {
277
+ 'execute_kwargs': execute_kwargs,
278
+ 'result_kwargs': result_kwargs,
279
+ 'cache': cache,
280
+ 'save_to_cache': save_to_cache,
281
+ 'cache_dir': cache_dir,
282
+ 'include_execution': include_execution,
283
+ }
284
+
285
+ # execute or retrieve query
286
+ if query_id:
287
+ if cache and load_from_cache and not refresh:
288
+ cache_result, cache_execution = _cache.load_from_cache(
289
+ execute_kwargs, result_kwargs, output_kwargs
290
+ )
291
+ if cache_result is not None:
292
+ return cache_result
293
+ if execution is None and cache_execution is not None:
294
+ execution = cache_execution
295
+ if max_age is not None and not refresh:
296
+ age = get_query_latest_age(**execute_kwargs, verbose=verbose) # type: ignore
297
+ if age is None or age > max_age:
298
+ refresh = True
299
+ if not refresh:
300
+ df = get_results(**execute_kwargs, **result_kwargs)
301
+ if df is not None:
302
+ return process_result(df, execution, **output_kwargs)
303
+ execution = execute_query(**execute_kwargs, verbose=verbose)
304
+
305
+ # await execution completion
306
+ if execution is None:
307
+ raise Exception('could not determine execution')
308
+ if poll:
309
+ poll_execution(execution, **poll_kwargs)
310
+ df = get_results(execution, api_key, **result_kwargs)
311
+ if df is not None:
312
+ return process_result(df, execution, **output_kwargs)
313
+ else:
314
+ raise Exception('no successful execution for query')
315
+ else:
316
+ return execution
317
+
318
+
319
+ @overload
320
+ async def async_query(
321
+ query_or_execution: Query | Execution,
322
+ *,
323
+ verbose: bool = True,
324
+ refresh: bool = False,
325
+ max_age: float | None = None,
326
+ parameters: Mapping[str, Any] | None = None,
327
+ api_key: str | None = None,
328
+ performance: Performance = 'medium',
329
+ poll: Literal[False],
330
+ poll_interval: float = 1.0,
331
+ timeout_seconds: float | None = None,
332
+ limit: int | None = None,
333
+ offset: int | None = None,
334
+ sample_count: int | None = None,
335
+ sort_by: str | None = None,
336
+ columns: Sequence[str] | None = None,
337
+ extras: Mapping[str, Any] | None = None,
338
+ types: Sequence[type[pl.DataType]]
339
+ | Mapping[str, type[pl.DataType]]
340
+ | None = None,
341
+ all_types: Sequence[type[pl.DataType]]
342
+ | Mapping[str, type[pl.DataType]]
343
+ | None = None,
344
+ cache: bool = True,
345
+ cache_dir: str | None = None,
346
+ save_to_cache: bool = True,
347
+ load_from_cache: bool = True,
348
+ include_execution: bool = False,
349
+ ) -> Execution: ...
350
+
351
+
352
+ @overload
353
+ async def async_query(
354
+ query_or_execution: Query | Execution,
355
+ *,
356
+ verbose: bool = True,
357
+ refresh: bool = False,
358
+ max_age: float | None = None,
359
+ parameters: Mapping[str, Any] | None = None,
360
+ api_key: str | None = None,
361
+ performance: Performance = 'medium',
362
+ poll: Literal[True] = True,
363
+ poll_interval: float = 1.0,
364
+ limit: int | None = None,
365
+ offset: int | None = None,
366
+ sample_count: int | None = None,
367
+ sort_by: str | None = None,
368
+ columns: Sequence[str] | None = None,
369
+ extras: Mapping[str, Any] | None = None,
370
+ types: Sequence[type[pl.DataType]]
371
+ | Mapping[str, type[pl.DataType]]
372
+ | None = None,
373
+ all_types: Sequence[type[pl.DataType]]
374
+ | Mapping[str, type[pl.DataType]]
375
+ | None = None,
376
+ cache: bool = True,
377
+ cache_dir: str | None = None,
378
+ save_to_cache: bool = True,
379
+ load_from_cache: bool = True,
380
+ include_execution: Literal[False] = False,
381
+ ) -> pl.DataFrame: ...
382
+
383
+
384
+ @overload
385
+ async def async_query(
386
+ query_or_execution: Query | Execution,
387
+ *,
388
+ verbose: bool = True,
389
+ refresh: bool = False,
390
+ max_age: float | None = None,
391
+ parameters: Mapping[str, Any] | None = None,
392
+ api_key: str | None = None,
393
+ performance: Performance = 'medium',
394
+ poll: Literal[True] = True,
395
+ poll_interval: float = 1.0,
396
+ limit: int | None = None,
397
+ offset: int | None = None,
398
+ sample_count: int | None = None,
399
+ sort_by: str | None = None,
400
+ columns: Sequence[str] | None = None,
401
+ extras: Mapping[str, Any] | None = None,
402
+ types: Sequence[type[pl.DataType]]
403
+ | Mapping[str, type[pl.DataType]]
404
+ | None = None,
405
+ all_types: Sequence[type[pl.DataType]]
406
+ | Mapping[str, type[pl.DataType]]
407
+ | None = None,
408
+ cache: bool = True,
409
+ cache_dir: str | None = None,
410
+ save_to_cache: bool = True,
411
+ load_from_cache: bool = True,
412
+ include_execution: Literal[True],
413
+ ) -> tuple[pl.DataFrame, Execution]: ...
414
+
415
+
416
+ async def async_query(
417
+ query_or_execution: Query | Execution,
418
+ *,
419
+ verbose: bool = True,
420
+ refresh: bool = False,
421
+ max_age: float | None = None,
422
+ parameters: Mapping[str, Any] | None = None,
423
+ api_key: str | None = None,
424
+ performance: Performance = 'medium',
425
+ poll: bool = True,
426
+ poll_interval: float = 1.0,
427
+ timeout_seconds: float | None = None,
428
+ limit: int | None = None,
429
+ offset: int | None = None,
430
+ sample_count: int | None = None,
431
+ sort_by: str | None = None,
432
+ columns: Sequence[str] | None = None,
433
+ extras: Mapping[str, Any] | None = None,
434
+ types: Sequence[type[pl.DataType]]
435
+ | Mapping[str, type[pl.DataType]]
436
+ | None = None,
437
+ all_types: Sequence[type[pl.DataType]]
438
+ | Mapping[str, type[pl.DataType]]
439
+ | None = None,
440
+ cache: bool = True,
441
+ cache_dir: str | None = None,
442
+ save_to_cache: bool = True,
443
+ load_from_cache: bool = True,
444
+ include_execution: bool = False,
445
+ ):
446
+
447
+ # determine whether target is a query or an execution
448
+ query_id, execution, parameters = _determine_input_type(
449
+ query_or_execution,
450
+ parameters,
451
+ )
452
+
453
+ # gather arguments
454
+ execute_kwargs: ExecuteKwargs = {
455
+ 'query_id': query_id,
456
+ 'api_key': api_key,
457
+ 'parameters': parameters,
458
+ 'performance': performance,
459
+ }
460
+ poll_kwargs: PollKwargs = {
461
+ 'poll_interval': poll_interval,
462
+ 'api_key': api_key,
463
+ 'verbose': verbose,
464
+ 'timeout_seconds': timeout_seconds,
465
+ }
466
+ result_kwargs: RetrievalKwargs = {
467
+ 'limit': limit,
468
+ 'offset': offset,
469
+ 'sample_count': sample_count,
470
+ 'sort_by': sort_by,
471
+ 'columns': columns,
472
+ 'extras': extras,
473
+ 'types': types,
474
+ 'all_types': all_types,
475
+ 'verbose': verbose,
476
+ }
477
+ output_kwargs: OutputKwargs = {
478
+ 'execute_kwargs': execute_kwargs,
479
+ 'result_kwargs': result_kwargs,
480
+ 'cache': cache,
481
+ 'save_to_cache': save_to_cache,
482
+ 'cache_dir': cache_dir,
483
+ 'include_execution': include_execution,
484
+ }
485
+
486
+ # execute or retrieve query
487
+ if query_id:
488
+ if cache and cache_dir is not None and not refresh:
489
+ cache_result, cache_execution = await _cache.async_load_from_cache(
490
+ execute_kwargs, result_kwargs, output_kwargs
491
+ )
492
+ if cache_result is not None:
493
+ return cache_result
494
+ if execution is None and cache_execution is not None:
495
+ execution = cache_execution
496
+ if max_age is not None and not refresh:
497
+ age = await async_get_query_latest_age(**execute_kwargs, verbose=verbose) # type: ignore
498
+ if age is None or age > max_age:
499
+ refresh = True
500
+ if not refresh:
501
+ df = await async_get_results(**execute_kwargs, **result_kwargs)
502
+ if df is not None:
503
+ return await async_process_result(df, execution, **output_kwargs)
504
+ execution = await async_execute_query(**execute_kwargs, verbose=verbose)
505
+
506
+ # await execution completion
507
+ if execution is None:
508
+ raise Exception('could not determine execution')
509
+ if poll:
510
+ await async_poll_execution(execution, **poll_kwargs)
511
+ df = await async_get_results(execution, api_key, **result_kwargs)
512
+ if df is not None:
513
+ return await async_process_result(df, execution, **output_kwargs)
514
+ else:
515
+ raise Exception('no successful execution for query')
516
+ else:
517
+ return execution
518
+
519
+
520
+ def _process_result(
521
+ df: pl.DataFrame,
522
+ execution: Execution | None,
523
+ execute_kwargs: ExecuteKwargs,
524
+ result_kwargs: RetrievalKwargs,
525
+ cache: bool,
526
+ save_to_cache: bool,
527
+ cache_dir: str | None,
528
+ include_execution: bool,
529
+ ) -> pl.DataFrame | tuple[pl.DataFrame, Execution]:
530
+ if cache and save_to_cache and execute_kwargs['query_id'] is not None:
531
+ if execution is None:
532
+ execution = get_latest_execution(execute_kwargs)
533
+ if execution is None:
534
+ raise Exception('could not get execution')
535
+ _cache.save_to_cache(
536
+ df, execution, execute_kwargs, result_kwargs, cache_dir
537
+ )
538
+
539
+ if include_execution:
540
+ if execution is None:
541
+ execution = get_latest_execution(execute_kwargs)
542
+ if execution is None:
543
+ raise Exception('could not get execution')
544
+ return df, execution
545
+ else:
546
+ return df
547
+
548
+
549
+ async def _async_process_result(
550
+ df: pl.DataFrame,
551
+ execution: Execution | None,
552
+ execute_kwargs: ExecuteKwargs,
553
+ result_kwargs: RetrievalKwargs,
554
+ cache: bool,
555
+ save_to_cache: bool,
556
+ cache_dir: str | None,
557
+ include_execution: bool,
558
+ ) -> pl.DataFrame | tuple[pl.DataFrame, Execution]:
559
+ if cache and save_to_cache and execute_kwargs['query_id'] is not None:
560
+ if execution is None:
561
+ execution = await async_get_latest_execution(execute_kwargs)
562
+ if execution is None:
563
+ raise Exception('could not get execution')
564
+ _cache.save_to_cache(
565
+ df, execution, execute_kwargs, result_kwargs, cache_dir
566
+ )
567
+
568
+ if include_execution:
569
+ if execution is None:
570
+ execution = await async_get_latest_execution(execute_kwargs)
571
+ if execution is None:
572
+ raise Exception('could not get execution')
573
+ return df, execution
574
+ else:
575
+ return df
576
+
577
+
578
+ def _get_query_latest_age(
579
+ query_id: int,
580
+ *,
581
+ verbose: bool = True,
582
+ parameters: Mapping[str, Any] | None = None,
583
+ performance: Performance = 'medium',
584
+ api_key: str | None = None,
585
+ ) -> float | None:
586
+ import datetime
587
+ import json
588
+
589
+ # process inputs
590
+ if api_key is None:
591
+ api_key = _urls.get_api_key()
592
+ headers = {'X-Dune-API-Key': api_key, 'User-Agent': get_user_agent()}
593
+ data = {}
594
+ if parameters is not None:
595
+ data['query_parameters'] = parameters
596
+ url = _urls.get_query_results_url(query_id, parameters=data, csv=False)
597
+
598
+ # print summary
599
+ if verbose:
600
+ print('checking age of last execution, query_id = ' + str(query_id))
601
+
602
+ response = _transport_get(url, headers=headers, timeout=15.0)
603
+
604
+ # check if result is error
605
+ result = response.json()
606
+ try:
607
+ if 'error' in result:
608
+ if (
609
+ result['error']
610
+ == 'not found: No execution found for the latest version of the given query'
611
+ ):
612
+ if verbose:
613
+ print(
614
+ 'no age for query, because no previous executions exist'
615
+ )
616
+ return None
617
+ raise Exception(result['error'])
618
+ except json.JSONDecodeError:
619
+ pass
620
+
621
+ # process result
622
+ if 'execution_started_at' in result:
623
+ now = datetime.datetime.now(datetime.UTC).timestamp()
624
+ started = _parse_timestamp(result['execution_started_at'])
625
+ age = now - started
626
+
627
+ if verbose:
628
+ print('latest result age:', age)
629
+
630
+ return age
631
+ else:
632
+ if verbose:
633
+ print('no age for query, because no previous executions exist')
634
+ return None
635
+
636
+
637
+ def _parse_timestamp(timestamp: str) -> int:
638
+ import datetime
639
+
640
+ # reduce number of decimals in
641
+ if len(timestamp) > 27 and timestamp[-1] == 'Z':
642
+ timestamp = timestamp[:26] + 'Z'
643
+
644
+ timestamp_float = (
645
+ datetime.datetime.strptime(timestamp, '%Y-%m-%dT%H:%M:%S.%fZ')
646
+ .replace(tzinfo=datetime.UTC)
647
+ .timestamp()
648
+ )
649
+ return int(timestamp_float)
650
+
651
+
652
+ async def _async_get_query_latest_age(
653
+ query_id: int,
654
+ *,
655
+ verbose: bool = True,
656
+ parameters: Mapping[str, Any] | None = None,
657
+ performance: Performance = 'medium',
658
+ api_key: str | None = None,
659
+ ) -> float | None:
660
+ import datetime
661
+ import json
662
+
663
+ import aiohttp
664
+
665
+ # process inputs
666
+ if api_key is None:
667
+ api_key = _urls.get_api_key()
668
+ headers = {'X-Dune-API-Key': api_key, 'User-Agent': get_user_agent()}
669
+ data = {}
670
+ if parameters is not None:
671
+ data['query_parameters'] = parameters
672
+ url = _urls.get_query_results_url(query_id, parameters=data, csv=False)
673
+
674
+ # print summary
675
+ if verbose:
676
+ print('checking age of last execution, query_id = ' + str(query_id))
677
+
678
+ # perform request with retry/backoff for 429/502
679
+ timeout = aiohttp.ClientTimeout(total=30)
680
+ async with aiohttp.ClientSession(timeout=timeout) as session:
681
+ attempts = 0
682
+ backoff = 0.5
683
+ while True:
684
+ async with session.get(url, headers=headers) as response:
685
+ if response.status in (429, 502):
686
+ attempts += 1
687
+ if attempts >= 3:
688
+ break
689
+ import asyncio
690
+ import random
691
+ await asyncio.sleep(backoff * random.uniform(1.5, 2.5))
692
+ backoff = min(5.0, backoff * 2)
693
+ continue
694
+ result: Mapping[str, Any] = await response.json()
695
+ break
696
+
697
+ # check if result is error
698
+ try:
699
+ if 'error' in result:
700
+ if (
701
+ result['error']
702
+ == 'not found: No execution found for the latest version of the given query'
703
+ ):
704
+ if verbose:
705
+ print(
706
+ 'no age for query, because no previous executions exist'
707
+ )
708
+ return None
709
+ raise Exception(result['error'])
710
+ except json.JSONDecodeError:
711
+ pass
712
+
713
+ # process result
714
+ if 'execution_started_at' in result:
715
+ now = datetime.datetime.now(datetime.UTC).timestamp()
716
+ started = _parse_timestamp(result['execution_started_at'])
717
+ age = now - started
718
+
719
+ if verbose:
720
+ print('latest result age:', age)
721
+
722
+ return age
723
+ else:
724
+ if verbose:
725
+ print('no age for query, because no previous executions exist')
726
+ return None
727
+
728
+
729
+ def _execute(
730
+ query_id: int | str,
731
+ *,
732
+ parameters: Mapping[str, Any] | None = None,
733
+ performance: Performance = 'medium',
734
+ api_key: str | None = None,
735
+ verbose: bool = True,
736
+ ) -> Execution:
737
+ # process inputs
738
+ url = _urls.get_query_execute_url(query_id)
739
+ if api_key is None:
740
+ api_key = _urls.get_api_key()
741
+ headers = {'X-Dune-API-Key': api_key, 'User-Agent': get_user_agent()}
742
+ data = {}
743
+ if parameters is not None:
744
+ data['query_parameters'] = parameters
745
+ data['performance'] = performance
746
+
747
+ # print summary
748
+ if verbose:
749
+ print('executing query, query_id = ' + str(query_id))
750
+
751
+ # perform request
752
+ response = _transport_post(url, headers=headers, json=data, timeout=15.0)
753
+ result: Mapping[str, Any] = response.json()
754
+
755
+ # check for errors
756
+ if 'execution_id' not in result:
757
+ raise Exception(result['error'])
758
+
759
+ # process result
760
+ execution_id = result['execution_id']
761
+ return {'execution_id': execution_id, 'timestamp': None}
762
+
763
+
764
+ async def _async_execute(
765
+ query_id: int | str,
766
+ *,
767
+ parameters: Mapping[str, Any] | None = None,
768
+ performance: Performance = 'medium',
769
+ api_key: str | None = None,
770
+ verbose: bool = True,
771
+ ) -> Execution:
772
+ import aiohttp
773
+
774
+ # process inputs
775
+ url = _urls.get_query_execute_url(query_id)
776
+ if api_key is None:
777
+ api_key = _urls.get_api_key()
778
+ headers = {'X-Dune-API-Key': api_key, 'User-Agent': get_user_agent()}
779
+ data = {}
780
+ if parameters is not None:
781
+ data['query_parameters'] = parameters
782
+ data['performance'] = performance
783
+
784
+ # print summary
785
+ if verbose:
786
+ print('executing query, query_id = ' + str(query_id))
787
+
788
+ # perform request
789
+ timeout = aiohttp.ClientTimeout(total=30)
790
+ async with aiohttp.ClientSession(timeout=timeout) as session:
791
+ async with session.post(url, headers=headers, json=data) as response:
792
+ result: Mapping[str, Any] = await response.json()
793
+
794
+ # check for errors
795
+ if 'execution_id' not in result:
796
+ raise Exception(result['error'])
797
+
798
+ # process result
799
+ execution_id = result['execution_id']
800
+ return {'execution_id': execution_id, 'timestamp': None}
801
+
802
+
803
+ def _get_results(
804
+ execution: Execution | None = None,
805
+ api_key: str | None = None,
806
+ *,
807
+ query_id: int | None = None,
808
+ parameters: Mapping[str, Any] | None = None,
809
+ performance: Performance = 'medium',
810
+ limit: int | None = None,
811
+ offset: int | None = None,
812
+ sample_count: int | None = None,
813
+ sort_by: str | None = None,
814
+ columns: Sequence[str] | None = None,
815
+ extras: Mapping[str, Any] | None = None,
816
+ types: Sequence[type[pl.DataType]]
817
+ | Mapping[str, type[pl.DataType]]
818
+ | None = None,
819
+ all_types: Sequence[type[pl.DataType]]
820
+ | Mapping[str, type[pl.DataType]]
821
+ | None = None,
822
+ verbose: bool = True,
823
+ ) -> pl.DataFrame | None:
824
+ import random
825
+ import time as _time
826
+
827
+ import polars as pl
828
+
829
+ # process inputs similar to upstream
830
+ if api_key is None:
831
+ api_key = _urls.get_api_key()
832
+ headers = {'X-Dune-API-Key': api_key, 'User-Agent': get_user_agent()}
833
+ params: dict[str, Any] = {
834
+ 'limit': limit,
835
+ 'offset': offset,
836
+ 'sample_count': sample_count,
837
+ 'sort_by': sort_by,
838
+ 'columns': columns,
839
+ }
840
+ if extras is not None:
841
+ params.update(extras)
842
+ if performance is not None:
843
+ params['performance'] = performance
844
+ if parameters is not None:
845
+ params['query_parameters'] = parameters
846
+ if query_id is not None:
847
+ url = _urls.get_query_results_url(query_id, parameters=params)
848
+ elif execution is not None:
849
+ url = _urls.get_execution_results_url(execution['execution_id'], params)
850
+ else:
851
+ raise Exception('must specify query_id or execution')
852
+
853
+ # print summary
854
+ if verbose:
855
+ if query_id is not None:
856
+ print('getting results, query_id = ' + str(query_id))
857
+ elif execution is not None:
858
+ print('getting results, execution_id = ' + str(execution['execution_id']))
859
+
860
+ # perform request
861
+ # GET with simple retry/backoff for 429/502
862
+ def _get_with_retries(u: str):
863
+ client = current_http_client()
864
+ if client is not None:
865
+ return client.request("GET", u, headers=headers, timeout=30.0)
866
+
867
+ attempts = 0
868
+ backoff = 0.5
869
+ while True:
870
+ resp = _transport_get(u, headers=headers, timeout=30.0)
871
+ if resp.status_code in (429, 502):
872
+ attempts += 1
873
+ if attempts >= 3:
874
+ return resp
875
+ sleep_for = backoff * random.uniform(1.5, 2.5)
876
+ _time.sleep(sleep_for)
877
+ backoff = min(5.0, backoff * 2)
878
+ continue
879
+ return resp
880
+
881
+ response = _get_with_retries(url)
882
+ if response.status_code == 404:
883
+ return None
884
+ result = response.text
885
+
886
+ # process result
887
+ df = _process_raw_table(result, types=types, all_types=all_types)
888
+
889
+ # support pagination when using limit
890
+ response_headers = response.headers
891
+ if limit is not None:
892
+ n_rows = len(df)
893
+ pages = []
894
+ while 'x-dune-next-uri' in response_headers and n_rows < limit:
895
+ if verbose:
896
+ offset_hdr = response.headers.get('x-dune-next-offset', 'unknown')
897
+ print('gathering additional page, offset = ' + str(offset_hdr))
898
+ next_url = response_headers['x-dune-next-uri']
899
+ response = _get_with_retries(next_url)
900
+ result = response.text
901
+ response_headers = response.headers
902
+ page = _process_raw_table(result, types=types, all_types=all_types)
903
+ n_rows += len(page)
904
+ pages.append(page)
905
+ df = pl.concat([df, *pages]).limit(limit)
906
+
907
+ return df
908
+
909
+
910
+ async def _async_get_results(
911
+ execution: Execution | None = None,
912
+ api_key: str | None = None,
913
+ *,
914
+ query_id: int | None = None,
915
+ parameters: Mapping[str, Any] | None = None,
916
+ performance: Performance = 'medium',
917
+ limit: int | None = None,
918
+ offset: int | None = None,
919
+ sample_count: int | None = None,
920
+ sort_by: str | None = None,
921
+ columns: Sequence[str] | None = None,
922
+ extras: Mapping[str, Any] | None = None,
923
+ types: Sequence[type[pl.DataType]]
924
+ | Mapping[str, type[pl.DataType]]
925
+ | None = None,
926
+ all_types: Sequence[type[pl.DataType]]
927
+ | Mapping[str, type[pl.DataType]]
928
+ | None = None,
929
+ verbose: bool = True,
930
+ ) -> pl.DataFrame | None:
931
+ import asyncio
932
+ import random
933
+
934
+ import aiohttp
935
+ import polars as pl
936
+
937
+ if api_key is None:
938
+ api_key = _urls.get_api_key()
939
+ headers = {'X-Dune-API-Key': api_key, 'User-Agent': get_user_agent()}
940
+ params: dict[str, Any] = {
941
+ 'limit': limit,
942
+ 'offset': offset,
943
+ 'sample_count': sample_count,
944
+ 'sort_by': sort_by,
945
+ 'columns': columns,
946
+ }
947
+ if extras is not None:
948
+ params.update(extras)
949
+ if parameters is not None:
950
+ params['query_parameters'] = parameters
951
+ if query_id is not None:
952
+ url = _urls.get_query_results_url(query_id, parameters=params)
953
+ elif execution is not None:
954
+ url = _urls.get_execution_results_url(execution['execution_id'], params)
955
+ else:
956
+ raise Exception('must specify query_id or execution')
957
+
958
+ # print summary
959
+ if verbose:
960
+ if query_id is not None:
961
+ print('getting results, query_id = ' + str(query_id))
962
+ elif execution is not None:
963
+ print('getting results, execution_id = ' + str(execution['execution_id']))
964
+
965
+ # perform request
966
+ timeout = aiohttp.ClientTimeout(total=30)
967
+ async with aiohttp.ClientSession(timeout=timeout) as session:
968
+ # GET with simple retry/backoff for 429/502
969
+ attempts = 0
970
+ backoff = 0.5
971
+ while True:
972
+ async with session.get(url, headers=headers) as response:
973
+ if response.status in (429, 502):
974
+ attempts += 1
975
+ if attempts >= 3:
976
+ break
977
+ await asyncio.sleep(backoff * random.uniform(1.5, 2.5))
978
+ backoff = min(5.0, backoff * 2)
979
+ continue
980
+ if response.status == 404:
981
+ return None
982
+ result = await response.text()
983
+ response_headers = response.headers
984
+ break
985
+
986
+ # process result
987
+ df = _process_raw_table(result, types=types, all_types=all_types)
988
+
989
+ # support pagination when using limit
990
+ if limit is not None:
991
+ import polars as pl
992
+
993
+ n_rows = len(df)
994
+ pages = []
995
+ timeout = aiohttp.ClientTimeout(total=30)
996
+ async with aiohttp.ClientSession(timeout=timeout) as session:
997
+ while 'x-dune-next-uri' in response_headers and n_rows < limit:
998
+ if verbose:
999
+ off = response.headers.get('x-dune-next-offset', 'unknown')
1000
+ print('gathering additional page, offset = ' + str(off))
1001
+ next_url = response_headers['x-dune-next-uri']
1002
+ # Pager GET with retry/backoff
1003
+ attempts = 0
1004
+ backoff = 0.5
1005
+ while True:
1006
+ async with session.get(next_url, headers=headers) as response:
1007
+ if response.status in (429, 502):
1008
+ attempts += 1
1009
+ if attempts >= 3:
1010
+ break
1011
+ await asyncio.sleep(backoff * random.uniform(1.5, 2.5))
1012
+ backoff = min(5.0, backoff * 2)
1013
+ continue
1014
+ result = await response.text()
1015
+ response_headers = response.headers
1016
+ break
1017
+ page = _process_raw_table(
1018
+ result, types=types, all_types=all_types
1019
+ )
1020
+ n_rows += len(page)
1021
+ pages.append(page)
1022
+
1023
+ df = pl.concat([df, *pages]).limit(limit)
1024
+
1025
+ return df
1026
+
1027
+
1028
+ def _process_raw_table(
1029
+ raw_csv: str,
1030
+ types: Sequence[type[pl.DataType] | None]
1031
+ | Mapping[str, type[pl.DataType] | None]
1032
+ | None,
1033
+ all_types: Sequence[type[pl.DataType]]
1034
+ | Mapping[str, type[pl.DataType]]
1035
+ | None = None,
1036
+ ) -> pl.DataFrame:
1037
+ import polars as pl
1038
+
1039
+ # convert from map to sequence
1040
+ first_line = raw_csv.split('\n', maxsplit=1)[0]
1041
+ column_order = first_line.split(',')
1042
+
1043
+ # parse data as csv
1044
+ df = pl.read_csv(
1045
+ io.StringIO(raw_csv),
1046
+ infer_schema_length=len(raw_csv),
1047
+ null_values='<nil>',
1048
+ truncate_ragged_lines=True,
1049
+ schema_overrides=[pl.String for column in column_order],
1050
+ )
1051
+
1052
+ # check if using all_types
1053
+ if all_types is not None and types is not None:
1054
+ raise Exception('cannot specify both types and all_types')
1055
+ elif all_types is not None:
1056
+ types = all_types
1057
+
1058
+ # cast types
1059
+ new_types = []
1060
+ for c, column in enumerate(df.columns):
1061
+ new_type = None
1062
+ if types is not None:
1063
+ if isinstance(types, list):
1064
+ if len(types) > c and types[c] is not None:
1065
+ new_type = types[c]
1066
+ elif isinstance(types, dict):
1067
+ if column in types and types[column] is not None:
1068
+ new_type = types[column]
1069
+ else:
1070
+ raise Exception('invalid format for types')
1071
+
1072
+ if new_type is None:
1073
+ new_type = infer_type(df[column])
1074
+
1075
+ if new_type == pl.Datetime or isinstance(new_type, pl.Datetime):
1076
+ time_format = '%Y-%m-%d %H:%M:%S%.3f %Z'
1077
+ df = df.with_columns(pl.col(column).str.to_datetime(time_format))
1078
+ new_type = None
1079
+
1080
+ new_types.append(new_type)
1081
+
1082
+ # check that all types were used
1083
+ if isinstance(types, dict):
1084
+ missing_columns = [
1085
+ name for name in types.keys() if name not in df.columns
1086
+ ]
1087
+ if len(missing_columns) > 0:
1088
+ raise Exception(
1089
+ 'types specified for missing columns: ' + str(missing_columns)
1090
+ )
1091
+ if all_types is not None:
1092
+ missing_columns = [name for name in df.columns if name not in all_types]
1093
+ if len(missing_columns) > 0:
1094
+ raise Exception(
1095
+ 'types not specified for columns: ' + str(missing_columns)
1096
+ )
1097
+
1098
+ new_columns = []
1099
+ for column, type in zip(df.columns, new_types):
1100
+ if type is not None:
1101
+ if type == pl.Boolean:
1102
+ new_column = pl.col(column) == 'true'
1103
+ else:
1104
+ new_column = pl.col(column).cast(type)
1105
+ new_columns.append(new_column)
1106
+ df = df.with_columns(*new_columns)
1107
+
1108
+ return df
1109
+
1110
+
1111
+ def infer_type(s: pl.Series) -> pl.DataType:
1112
+ import re
1113
+
1114
+ import polars as pl
1115
+
1116
+ # Heuristic: detect common UTC timestamp format used by Dune exports
1117
+ try:
1118
+ non_null = [v for v in s.to_list() if v is not None and v != '<nil>']
1119
+ if non_null and all(
1120
+ isinstance(v, str)
1121
+ and re.match(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC$", v)
1122
+ for v in non_null
1123
+ ):
1124
+ return pl.Datetime
1125
+ except Exception:
1126
+ pass
1127
+
1128
+ try:
1129
+ as_str = pl.DataFrame(s).write_csv(None)
1130
+ return pl.read_csv(io.StringIO(as_str))[s.name].dtype
1131
+ except Exception:
1132
+ return pl.String()
1133
+
1134
+
1135
+
1136
+ def _poll_execution(
1137
+ execution: Execution,
1138
+ *,
1139
+ api_key: str | None,
1140
+ poll_interval: float,
1141
+ verbose: bool,
1142
+ timeout_seconds: float | None,
1143
+ ) -> None:
1144
+ import random
1145
+ import time
1146
+
1147
+ # process inputs
1148
+ url = _urls.get_execution_status_url(execution['execution_id'])
1149
+ execution_id = execution['execution_id']
1150
+ if api_key is None:
1151
+ api_key = _urls.get_api_key()
1152
+ headers = {'X-Dune-API-Key': api_key, 'User-Agent': get_user_agent()}
1153
+
1154
+ # print summary
1155
+ t_start = time.time()
1156
+
1157
+ # poll until completion
1158
+ sleep_amount = poll_interval
1159
+ while True:
1160
+ t_poll = time.time()
1161
+
1162
+ # print summary
1163
+ if verbose:
1164
+ print(
1165
+ 'waiting for results, execution_id = '
1166
+ + str(execution['execution_id'])
1167
+ + ', t = '
1168
+ + '%.02f' % (t_poll - t_start)
1169
+ )
1170
+
1171
+ # poll
1172
+ response = _http_get(url, headers=headers, timeout=15.0)
1173
+ result = response.json()
1174
+ if (
1175
+ 'is_execution_finished' not in result
1176
+ and response.status_code == 429
1177
+ ):
1178
+ sleep_amount = sleep_amount * random.uniform(1, 2)
1179
+ time.sleep(sleep_amount)
1180
+ continue
1181
+ if result['is_execution_finished']:
1182
+ if result['state'] == 'QUERY_STATE_FAILED':
1183
+ # Enrich error message with state and any error details if present
1184
+ err_detail = ''
1185
+ try:
1186
+ if 'error' in result and result['error']:
1187
+ err_detail = f", error={result['error']}"
1188
+ except Exception:
1189
+ pass
1190
+ raise Exception(
1191
+ f"QUERY FAILED execution_id={execution_id} state={result.get('state')}{err_detail}"
1192
+ )
1193
+ execution['timestamp'] = _parse_timestamp(
1194
+ result['execution_started_at']
1195
+ )
1196
+ break
1197
+
1198
+ # timeout check
1199
+ if timeout_seconds is not None and (t_poll - t_start) > timeout_seconds:
1200
+ raise TimeoutError(
1201
+ f'query polling timed out after {timeout_seconds} seconds'
1202
+ )
1203
+
1204
+ # wait until polling interval
1205
+ t_wait = time.time() - t_poll
1206
+ if t_wait < poll_interval:
1207
+ time.sleep(poll_interval - t_wait)
1208
+
1209
+ # check for errors
1210
+ if result['state'] == 'QUERY_STATE_FAILED':
1211
+ err_detail = ''
1212
+ try:
1213
+ if 'error' in result and result['error']:
1214
+ err_detail = f", error={result['error']}"
1215
+ except Exception:
1216
+ pass
1217
+ raise Exception(
1218
+ f"QUERY FAILED execution_id={execution_id} state={result.get('state')}{err_detail}"
1219
+ )
1220
+
1221
+
1222
+ async def _async_poll_execution(
1223
+ execution: Execution,
1224
+ *,
1225
+ api_key: str | None,
1226
+ poll_interval: float,
1227
+ verbose: bool,
1228
+ timeout_seconds: float | None,
1229
+ ) -> None:
1230
+ import asyncio
1231
+ import random
1232
+
1233
+ import aiohttp
1234
+
1235
+ # process inputs
1236
+ url = _urls.get_execution_status_url(execution['execution_id'])
1237
+ execution_id = execution['execution_id']
1238
+ if api_key is None:
1239
+ api_key = _urls.get_api_key()
1240
+ headers = {'X-Dune-API-Key': api_key, 'User-Agent': get_user_agent()}
1241
+
1242
+ # print summary
1243
+ t_start = time.time()
1244
+
1245
+ # poll until completion
1246
+ timeout = aiohttp.ClientTimeout(total=30)
1247
+ async with aiohttp.ClientSession(timeout=timeout) as session:
1248
+ sleep_amount = poll_interval
1249
+ while True:
1250
+ t_poll = time.time()
1251
+
1252
+ # print summary
1253
+ if verbose:
1254
+ print(
1255
+ 'waiting for results, execution_id = '
1256
+ + str(execution['execution_id'])
1257
+ + ', t = '
1258
+ + str(t_poll - t_start)
1259
+ )
1260
+
1261
+ # poll
1262
+ async with session.get(url, headers=headers) as response:
1263
+ result = await response.json()
1264
+ if (
1265
+ 'is_execution_finished' not in result
1266
+ and response.status == 429
1267
+ ):
1268
+ sleep_amount = sleep_amount * random.uniform(1, 2)
1269
+ await asyncio.sleep(sleep_amount)
1270
+ continue
1271
+ if result['is_execution_finished']:
1272
+ if result['state'] == 'QUERY_STATE_FAILED':
1273
+ err_detail = ''
1274
+ try:
1275
+ if 'error' in result and result['error']:
1276
+ err_detail = f", error={result['error']}"
1277
+ except Exception:
1278
+ pass
1279
+ raise Exception(
1280
+ f"QUERY FAILED execution_id={execution_id} state={result.get('state')}{err_detail}"
1281
+ )
1282
+ execution['timestamp'] = _parse_timestamp(
1283
+ result['execution_started_at']
1284
+ )
1285
+ break
1286
+
1287
+ # timeout check
1288
+ if timeout_seconds is not None and (t_poll - t_start) > timeout_seconds:
1289
+ raise TimeoutError(
1290
+ f'query polling timed out after {timeout_seconds} seconds'
1291
+ )
1292
+
1293
+ # wait until polling interval
1294
+ t_wait = time.time() - t_poll
1295
+ if t_wait < poll_interval:
1296
+ import asyncio
1297
+
1298
+ await asyncio.sleep(poll_interval - t_wait)
1299
+
1300
+ # check for errors
1301
+ if result['state'] == 'QUERY_STATE_FAILED':
1302
+ err_detail = ''
1303
+ try:
1304
+ if 'error' in result and result['error']:
1305
+ err_detail = f", error={result['error']}"
1306
+ except Exception:
1307
+ pass
1308
+ raise Exception(
1309
+ f"QUERY FAILED execution_id={execution_id} state={result.get('state')}{err_detail}"
1310
+ )
1311
+
1312
+
1313
+ def get_latest_execution(
1314
+ execute_kwargs: ExecuteKwargs,
1315
+ *,
1316
+ allow_unfinished: bool = False,
1317
+ ) -> Execution | None:
1318
+ import json
1319
+ import random
1320
+
1321
+
1322
+ query_id = execute_kwargs['query_id']
1323
+ api_key = execute_kwargs['api_key']
1324
+ parameters = execute_kwargs['parameters']
1325
+ if query_id is None:
1326
+ raise Exception('query_id required for get_latest_execution')
1327
+
1328
+ # process inputs
1329
+ if api_key is None:
1330
+ api_key = _urls.get_api_key()
1331
+ headers = {'X-Dune-API-Key': api_key, 'User-Agent': get_user_agent()}
1332
+ data: dict[str, Any] = {}
1333
+ if parameters is not None:
1334
+ data['query_parameters'] = parameters
1335
+ data['limit'] = 0
1336
+ url = _urls.get_query_results_url(query_id, parameters=data, csv=False)
1337
+
1338
+ sleep_amount = 1.0
1339
+ while True:
1340
+ # perform request
1341
+ response = _http_get(url, headers=headers, timeout=15.0)
1342
+ if response.status_code in (429, 502):
1343
+ sleep_amount = sleep_amount * random.uniform(1, 2)
1344
+ time.sleep(sleep_amount)
1345
+ continue
1346
+
1347
+ # check if result is error
1348
+ result = response.json()
1349
+ try:
1350
+ if 'error' in result:
1351
+ if (
1352
+ result['error']
1353
+ == 'not found: No execution found for the latest version of the given query'
1354
+ ):
1355
+ return None
1356
+ if response.status_code == 429:
1357
+ sleep_amount = sleep_amount * random.uniform(1, 2)
1358
+ time.sleep(sleep_amount)
1359
+ raise Exception(result['error'])
1360
+ except json.JSONDecodeError:
1361
+ pass
1362
+
1363
+ break
1364
+
1365
+ # process result
1366
+ if not result['is_execution_finished'] and not allow_unfinished:
1367
+ return None
1368
+ execution: Execution = {'execution_id': result['execution_id']}
1369
+ if 'execution_started_at' in result:
1370
+ execution['timestamp'] = int(
1371
+ _parse_timestamp(result['execution_started_at'])
1372
+ )
1373
+ return execution
1374
+
1375
+
1376
+ async def async_get_latest_execution(
1377
+ execute_kwargs: ExecuteKwargs,
1378
+ *,
1379
+ allow_unfinished: bool = False,
1380
+ ) -> Execution | None:
1381
+ import json
1382
+ import random
1383
+
1384
+ import aiohttp
1385
+
1386
+ query_id = execute_kwargs['query_id']
1387
+ api_key = execute_kwargs['api_key']
1388
+ parameters = execute_kwargs['parameters']
1389
+ if query_id is None:
1390
+ raise Exception('query_id required for async_get_latest_execution')
1391
+
1392
+ # process inputs
1393
+ if api_key is None:
1394
+ api_key = _urls.get_api_key()
1395
+ headers = {'X-Dune-API-Key': api_key, 'User-Agent': get_user_agent()}
1396
+ data: dict[str, Any] = {}
1397
+ if parameters is not None:
1398
+ data['query_parameters'] = parameters
1399
+ data['limit'] = 0
1400
+ url = _urls.get_query_results_url(query_id, parameters=data, csv=False)
1401
+
1402
+ # perform request
1403
+ timeout = aiohttp.ClientTimeout(total=30)
1404
+ async with aiohttp.ClientSession(timeout=timeout) as session:
1405
+ sleep_amount = 1.0
1406
+ while True:
1407
+ async with session.get(url, headers=headers) as response:
1408
+ if response.status in (429, 502):
1409
+ sleep_amount = sleep_amount * random.uniform(1, 2)
1410
+ import asyncio
1411
+ await asyncio.sleep(sleep_amount)
1412
+ continue
1413
+ result: Mapping[str, Any] = await response.json()
1414
+
1415
+ # check if result is error
1416
+ try:
1417
+ if 'error' in result:
1418
+ if (
1419
+ result['error']
1420
+ == 'not found: No execution found for the latest version of the given query'
1421
+ ):
1422
+ return None
1423
+ if response.status == 429:
1424
+ import asyncio
1425
+
1426
+ sleep_amount = sleep_amount * random.uniform(1, 2)
1427
+ await asyncio.sleep(sleep_amount)
1428
+ raise Exception(result['error'])
1429
+ except json.JSONDecodeError:
1430
+ pass
1431
+ break
1432
+
1433
+ # process result
1434
+ if not result['is_execution_finished'] and not allow_unfinished:
1435
+ return None
1436
+ execution: Execution = {'execution_id': result['execution_id']}
1437
+ if 'execution_started_at' in result:
1438
+ execution['timestamp'] = int(
1439
+ _parse_timestamp(result['execution_started_at'])
1440
+ )
1441
+ return execution
1442
+
1443
+
1444
+ def get_user_agent() -> str:
1445
+ # Identify as spice-mcp vendored spice client
1446
+ return 'spice-mcp/' + ADAPTER_VERSION
1447
+
1448
+ # ---------------------------------------------------------------------------
1449
+ # Public aliases expected by adapter/tests (non-underscored names)
1450
+
1451
+ determine_input_type = _determine_input_type
1452
+ get_query_latest_age = _get_query_latest_age
1453
+ async_get_query_latest_age = _async_get_query_latest_age
1454
+ execute_query = _execute
1455
+ async_execute_query = _async_execute
1456
+ get_results = _get_results
1457
+ async_get_results = _async_get_results
1458
+ process_result = _process_result
1459
+ async_process_result = _async_process_result
1460
+ poll_execution = _poll_execution
1461
+ async_poll_execution = _async_poll_execution