Skip to content

Commit 1bf6e39

Browse files
Merge pull request #724 from pyathena-dev/feature/on-poll-callback-723
2 parents 88a4576 + 9734053 commit 1bf6e39

14 files changed

Lines changed: 219 additions & 15 deletions

File tree

docs/usage.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,86 @@ The `on_start_query_execution` callback is supported by the following cursor typ
427427
Note: `AsyncCursor` and its variants do not support this callback as they already
428428
return the query ID immediately through their different execution model.
429429

430+
## Query polling callback
431+
432+
PyAthena provides an `on_poll` callback that is invoked once per poll iteration with the
433+
current query execution object, while PyAthena waits for the query to finish. This is useful
434+
for rendering live query progress (state, elapsed time, data scanned) in interactive
435+
environments such as Jupyter notebooks.
436+
437+
The callback is optional (`None` by default), so there is no impact on existing behaviour or
438+
performance when it is not used. It must be a **synchronous** function with the signature
439+
`Callable[[AthenaQueryExecution], None]` (for Spark calculations it receives an
440+
`AthenaCalculationExecutionStatus`). It is invoked on every poll, including the final
441+
iteration that observes the terminal state (`SUCCEEDED`, `FAILED`, or `CANCELLED`).
442+
443+
Unlike `on_start_query_execution`, `on_poll` is configured at the connection or cursor level
444+
only (there is no execute-level override).
445+
446+
### Connection-level callback
447+
448+
```python
449+
from pyathena import connect
450+
451+
def on_poll(query_execution):
452+
print(
453+
f"State: {query_execution.state}, "
454+
f"scanned: {query_execution.data_scanned_in_bytes} bytes"
455+
)
456+
457+
cursor = connect(
458+
s3_staging_dir="s3://YOUR_S3_BUCKET/path/to/",
459+
region_name="us-west-2",
460+
on_poll=on_poll,
461+
).cursor()
462+
463+
cursor.execute("SELECT * FROM many_rows") # on_poll is invoked on each poll
464+
```
465+
466+
### Cursor-level callback
467+
468+
```python
469+
from pyathena import connect
470+
471+
def on_poll(query_execution):
472+
print(f"State: {query_execution.state}")
473+
474+
conn = connect(
475+
s3_staging_dir="s3://YOUR_S3_BUCKET/path/to/",
476+
region_name="us-west-2",
477+
)
478+
cursor = conn.cursor(on_poll=on_poll)
479+
cursor.execute("SELECT * FROM many_rows")
480+
```
481+
482+
### Asynchronous cursors
483+
484+
`on_poll` also works with the asynchronous cursors. Because polling runs in a background
485+
thread (or event loop), the callback runs there too, so keep it lightweight and thread-safe:
486+
487+
```python
488+
from pyathena import connect
489+
from pyathena.pandas.async_cursor import AsyncPandasCursor
490+
491+
def on_poll(query_execution):
492+
print(f"State: {query_execution.state}")
493+
494+
conn = connect(
495+
s3_staging_dir="s3://YOUR_S3_BUCKET/path/to/",
496+
region_name="us-west-2",
497+
)
498+
cursor = conn.cursor(AsyncPandasCursor, on_poll=on_poll)
499+
query_id, future = cursor.execute("SELECT * FROM many_rows")
500+
result = future.result()
501+
```
502+
503+
### Supported cursor types
504+
505+
`on_poll` is supported by **all** cursor types, since polling is shared by the base cursor:
506+
the synchronous cursors, the `Async*` cursors, the native-async `Aio*` cursors, and the Spark
507+
cursors. For Spark cursors the callback receives the per-poll
508+
`AthenaCalculationExecutionStatus` rather than an `AthenaQueryExecution`.
509+
430510
## Type hints for complex types
431511

432512
*New in version 3.30.0.*

pyathena/aio/common.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ async def _get_query_execution(self, query_id: str) -> AthenaQueryExecution: #
8383
async def __poll(self, query_id: str) -> AthenaQueryExecution:
8484
while True:
8585
query_execution = await self._get_query_execution(query_id)
86+
if self._on_poll:
87+
self._on_poll(query_execution)
8688
if query_execution.state in [
8789
AthenaQueryExecution.STATE_SUCCEEDED,
8890
AthenaQueryExecution.STATE_FAILED,

pyathena/aio/spark/cursor.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ async def _calculate( # type: ignore[override]
128128
async def __poll(self, query_id: str) -> AthenaQueryExecution | AthenaCalculationExecution:
129129
while True:
130130
calculation_status = await self._get_calculation_execution_status(query_id)
131+
if self._on_poll:
132+
self._on_poll(calculation_status)
131133
if calculation_status.state in [
132134
AthenaCalculationExecutionStatus.STATE_COMPLETED,
133135
AthenaCalculationExecutionStatus.STATE_FAILED,

pyathena/arrow/cursor.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ def __init__(
6363
unload: bool = False,
6464
result_reuse_enable: bool = False,
6565
result_reuse_minutes: int = CursorIterator.DEFAULT_RESULT_REUSE_MINUTES,
66-
on_start_query_execution: Callable[[str], None] | None = None,
6766
connect_timeout: float | None = None,
6867
request_timeout: float | None = None,
6968
**kwargs,
@@ -82,7 +81,6 @@ def __init__(
8281
unload: Enable UNLOAD for high-performance Parquet output.
8382
result_reuse_enable: Enable Athena query result reuse.
8483
result_reuse_minutes: Minutes to reuse cached results.
85-
on_start_query_execution: Callback invoked when query starts.
8684
connect_timeout: Socket connection timeout in seconds for S3 operations.
8785
Defaults to AWS SDK default (typically 1 second) if not specified.
8886
request_timeout: Request timeout in seconds for S3 operations.
@@ -113,7 +111,6 @@ def __init__(
113111
**kwargs,
114112
)
115113
self._unload = unload
116-
self._on_start_query_execution = on_start_query_execution
117114
self._connect_timeout = connect_timeout
118115
self._request_timeout = request_timeout
119116

pyathena/common.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import sys
55
import time
66
from abc import ABCMeta, abstractmethod
7+
from collections.abc import Callable
78
from datetime import datetime, timedelta, timezone
89
from typing import TYPE_CHECKING, Any, cast
910

@@ -27,6 +28,14 @@
2728

2829
_logger = logging.getLogger(__name__)
2930

31+
OnPollCallback = Callable[[AthenaQueryExecution | AthenaCalculationExecutionStatus], None]
32+
"""Type of the optional ``on_poll`` callback.
33+
34+
Invoked once per poll iteration with the current execution object: an
35+
:class:`~pyathena.model.AthenaQueryExecution` for SQL queries, or an
36+
:class:`~pyathena.model.AthenaCalculationExecutionStatus` for Spark calculations.
37+
"""
38+
3039

3140
class CursorIterator(metaclass=ABCMeta):
3241
"""Abstract base class providing iteration and result fetching capabilities for cursors.
@@ -164,6 +173,8 @@ def __init__(
164173
kill_on_interrupt: bool,
165174
result_reuse_enable: bool,
166175
result_reuse_minutes: int,
176+
on_start_query_execution: Callable[[str], None] | None = None,
177+
on_poll: OnPollCallback | None = None,
167178
**kwargs,
168179
) -> None:
169180
super().__init__()
@@ -181,6 +192,11 @@ def __init__(
181192
self._kill_on_interrupt = kill_on_interrupt
182193
self._result_reuse_enable = result_reuse_enable
183194
self._result_reuse_minutes = result_reuse_minutes
195+
# ``on_start_query_execution`` is invoked only by cursors whose ``execute()``
196+
# supports it (the synchronous cursors). Async/aio/Spark cursors return the
197+
# query id immediately through their execution model and do not invoke it.
198+
self._on_start_query_execution = on_start_query_execution
199+
self._on_poll = on_poll
184200

185201
@staticmethod
186202
def get_default_converter(unload: bool = False) -> DefaultTypeConverter | Any:
@@ -558,6 +574,8 @@ def _list_query_executions(
558574
def __poll(self, query_id: str) -> AthenaQueryExecution | AthenaCalculationExecution:
559575
while True:
560576
query_execution = self._get_query_execution(query_id)
577+
if self._on_poll:
578+
self._on_poll(query_execution)
561579
if query_execution.state in [
562580
AthenaQueryExecution.STATE_SUCCEEDED,
563581
AthenaQueryExecution.STATE_FAILED,

pyathena/connection.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from botocore.config import Config
1919

2020
import pyathena
21-
from pyathena.common import BaseCursor, CursorIterator
21+
from pyathena.common import BaseCursor, CursorIterator, OnPollCallback
2222
from pyathena.converter import Converter
2323
from pyathena.cursor import Cursor
2424
from pyathena.error import NotSupportedError, ProgrammingError
@@ -127,6 +127,7 @@ def __init__(
127127
result_reuse_enable: bool = ...,
128128
result_reuse_minutes: int = ...,
129129
on_start_query_execution: Callable[[str], None] | None = ...,
130+
on_poll: OnPollCallback | None = ...,
130131
**kwargs,
131132
) -> None: ...
132133

@@ -158,6 +159,7 @@ def __init__(
158159
result_reuse_enable: bool = ...,
159160
result_reuse_minutes: int = ...,
160161
on_start_query_execution: Callable[[str], None] | None = ...,
162+
on_poll: OnPollCallback | None = ...,
161163
**kwargs,
162164
) -> None: ...
163165

@@ -188,6 +190,7 @@ def __init__(
188190
result_reuse_enable: bool = False,
189191
result_reuse_minutes: int = CursorIterator.DEFAULT_RESULT_REUSE_MINUTES,
190192
on_start_query_execution: Callable[[str], None] | None = None,
193+
on_poll: OnPollCallback | None = None,
191194
**kwargs,
192195
) -> None:
193196
"""Initialize a new Athena database connection.
@@ -224,6 +227,10 @@ def __init__(
224227
result_reuse_enable: Enable Athena query result reuse. Defaults to False.
225228
result_reuse_minutes: Minutes to reuse cached results.
226229
on_start_query_execution: Callback function called when query starts.
230+
on_poll: Callback invoked once per poll iteration with the current
231+
execution object (``AthenaQueryExecution``, or
232+
``AthenaCalculationExecutionStatus`` for Spark). Useful for
233+
monitoring live query progress. Defaults to None.
227234
**kwargs: Additional arguments passed to boto3 Session and client.
228235
229236
Raises:
@@ -331,6 +338,7 @@ def __init__(
331338
self.result_reuse_enable = result_reuse_enable
332339
self.result_reuse_minutes = result_reuse_minutes
333340
self.on_start_query_execution = on_start_query_execution
341+
self.on_poll = on_poll
334342

335343
def _assume_role(
336344
self,
@@ -555,6 +563,7 @@ def cursor(
555563
on_start_query_execution=kwargs.pop(
556564
"on_start_query_execution", self.on_start_query_execution
557565
),
566+
on_poll=kwargs.pop("on_poll", self.on_poll),
558567
**kwargs,
559568
)
560569

pyathena/cursor.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ def __init__(
5252
kill_on_interrupt: bool = True,
5353
result_reuse_enable: bool = False,
5454
result_reuse_minutes: int = CursorIterator.DEFAULT_RESULT_REUSE_MINUTES,
55-
on_start_query_execution: Callable[[str], None] | None = None,
5655
**kwargs,
5756
) -> None:
5857
super().__init__(
@@ -69,7 +68,6 @@ def __init__(
6968
**kwargs,
7069
)
7170
self._result_set_class = AthenaResultSet
72-
self._on_start_query_execution = on_start_query_execution
7371

7472
@property
7573
def arraysize(self) -> int:

pyathena/pandas/cursor.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ def __init__(
7979
result_reuse_enable: bool = False,
8080
result_reuse_minutes: int = CursorIterator.DEFAULT_RESULT_REUSE_MINUTES,
8181
auto_optimize_chunksize: bool = False,
82-
on_start_query_execution: Callable[[str], None] | None = None,
8382
**kwargs,
8483
) -> None:
8584
"""Initialize PandasCursor with configuration options.
@@ -105,7 +104,6 @@ def __init__(
105104
auto_optimize_chunksize: Enable automatic chunksize determination for
106105
large files. Only effective when chunksize is None.
107106
Default: False (no automatic chunking).
108-
on_start_query_execution: Callback for query start events.
109107
**kwargs: Additional arguments passed to pandas.read_csv.
110108
"""
111109
super().__init__(
@@ -128,7 +126,6 @@ def __init__(
128126
self._cache_type = cache_type
129127
self._max_workers = max_workers
130128
self._auto_optimize_chunksize = auto_optimize_chunksize
131-
self._on_start_query_execution = on_start_query_execution
132129

133130
@staticmethod
134131
def get_default_converter(

pyathena/polars/cursor.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@ def __init__(
7474
unload: bool = False,
7575
result_reuse_enable: bool = False,
7676
result_reuse_minutes: int = CursorIterator.DEFAULT_RESULT_REUSE_MINUTES,
77-
on_start_query_execution: Callable[[str], None] | None = None,
7877
block_size: int | None = None,
7978
cache_type: str | None = None,
8079
max_workers: int = (cpu_count() or 1) * 5,
@@ -95,7 +94,6 @@ def __init__(
9594
unload: Enable UNLOAD for high-performance Parquet output.
9695
result_reuse_enable: Enable Athena query result reuse.
9796
result_reuse_minutes: Minutes to reuse cached results.
98-
on_start_query_execution: Callback invoked when query starts.
9997
block_size: S3 read block size.
10098
cache_type: S3 caching strategy.
10199
max_workers: Maximum worker threads for parallel S3 operations.
@@ -123,7 +121,6 @@ def __init__(
123121
**kwargs,
124122
)
125123
self._unload = unload
126-
self._on_start_query_execution = on_start_query_execution
127124
self._block_size = block_size
128125
self._cache_type = cache_type
129126
self._max_workers = max_workers

pyathena/s3fs/cursor.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ def __init__(
5858
kill_on_interrupt: bool = True,
5959
result_reuse_enable: bool = False,
6060
result_reuse_minutes: int = CursorIterator.DEFAULT_RESULT_REUSE_MINUTES,
61-
on_start_query_execution: Callable[[str], None] | None = None,
6261
csv_reader: CSVReaderType | None = None,
6362
**kwargs,
6463
) -> None:
@@ -75,7 +74,6 @@ def __init__(
7574
kill_on_interrupt: Cancel running query on keyboard interrupt.
7675
result_reuse_enable: Enable Athena query result reuse.
7776
result_reuse_minutes: Minutes to reuse cached results.
78-
on_start_query_execution: Callback invoked when query starts.
7977
csv_reader: CSV reader class to use for parsing results.
8078
Use AthenaCSVReader (default) to distinguish between NULL
8179
(unquoted empty) and empty string (quoted empty "").
@@ -104,7 +102,6 @@ def __init__(
104102
result_reuse_minutes=result_reuse_minutes,
105103
**kwargs,
106104
)
107-
self._on_start_query_execution = on_start_query_execution
108105
self._csv_reader = csv_reader
109106

110107
@staticmethod

0 commit comments

Comments
 (0)