Skip to content

Commit 4caf557

Browse files
Merge pull request #733 from pyathena-dev/feature/execute-options
Centralize shared execute() kwargs into an ExecuteOptions dataclass
2 parents a851a84 + 1ad8a1f commit 4caf557

24 files changed

Lines changed: 706 additions & 154 deletions

docs/api/connection.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ Connection
1515
:members:
1616
:inherited-members:
1717

18+
Execution Options
19+
-----------------
20+
21+
.. autoclass:: pyathena.options.ExecuteOptions
22+
:members:
23+
1824
Standard Cursors
1925
----------------
2026

docs/usage.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,53 @@ print(cursor.fetchall())
117117

118118
You can find more information about the [considerations and limitations of parameterized queries](https://docs.aws.amazon.com/athena/latest/ug/querying-with-prepared-statements.html) in the official documentation.
119119

120+
## Execution options
121+
122+
The `execute()` method of every SQL cursor (`Cursor`, `AsyncCursor`, the aio cursors, and their
123+
pandas/arrow/polars/s3fs variants) accepts the same set of shared keyword arguments, such as
124+
`work_group`, `s3_staging_dir`, `cache_size`, `cache_expiration_time`, `result_reuse_enable`,
125+
`result_reuse_minutes`, `paramstyle`, `on_start_query_execution`, and `result_set_type_hints`.
126+
The Spark cursors execute calculations instead of SQL queries and do not accept these arguments.
127+
128+
These arguments can also be passed together as an `ExecuteOptions` instance using the `options`
129+
keyword argument.
130+
131+
```python
132+
from pyathena import ExecuteOptions, connect
133+
134+
cursor = connect(s3_staging_dir="s3://YOUR_S3_BUCKET/path/to/",
135+
region_name="us-west-2").cursor()
136+
options = ExecuteOptions(work_group="YOUR_WORK_GROUP",
137+
result_reuse_enable=True,
138+
result_reuse_minutes=60)
139+
cursor.execute("SELECT * FROM one_row", options=options)
140+
```
141+
142+
When both `options` and individual keyword arguments are specified, the individual keyword
143+
arguments take precedence.
144+
145+
```python
146+
# Executes with work_group="ANOTHER_WORK_GROUP"; the other fields of options still apply.
147+
cursor.execute("SELECT * FROM one_row",
148+
options=options,
149+
work_group="ANOTHER_WORK_GROUP")
150+
```
151+
152+
Passing `None` for an individual keyword argument is treated as "not specified" and does not
153+
reset the corresponding `options` field. To run a single query without a field set on `options`,
154+
construct a new instance without that field.
155+
156+
`ExecuteOptions` is immutable. To create a variation of an existing instance, use the `merge()`
157+
method, which returns a new instance with the specified fields applied.
158+
159+
```python
160+
adhoc_options = options.merge(work_group="ANOTHER_WORK_GROUP")
161+
```
162+
163+
The `on_start_query_execution` field is invoked by the synchronous and aio cursors;
164+
`AsyncCursor`-based cursors return the query ID directly through their execution model and do
165+
not invoke it.
166+
120167
## Quickly re-run queries
121168

122169
### Result reuse configuration
@@ -423,6 +470,7 @@ The `on_start_query_execution` callback is supported by the following cursor typ
423470
- `PandasCursor`
424471
- `PolarsCursor`
425472
- `S3FSCursor`
473+
- `AioCursor`, `AioDictCursor`, `AioArrowCursor`, `AioPandasCursor`, `AioPolarsCursor`, `AioS3FSCursor`
426474

427475
Note: `AsyncCursor` and its variants do not support this callback as they already
428476
return the query ID immediately through their different execution model.

pyathena/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import TYPE_CHECKING, Any, overload
55

66
from pyathena.error import * # noqa: F403
7+
from pyathena.options import ExecuteOptions as ExecuteOptions
78

89
if TYPE_CHECKING:
910
from pyathena.aio.connection import AioConnection

pyathena/aio/arrow/cursor.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import logging
5+
from collections.abc import Callable
56
from typing import TYPE_CHECKING, Any, cast
67

78
from pyathena.aio.common import WithAsyncFetch
@@ -13,6 +14,7 @@
1314
from pyathena.common import CursorIterator
1415
from pyathena.error import OperationalError, ProgrammingError
1516
from pyathena.model import AthenaQueryExecution
17+
from pyathena.options import ExecuteOptions
1618

1719
if TYPE_CHECKING:
1820
import polars as pl
@@ -83,11 +85,15 @@ async def execute( # type: ignore[override]
8385
parameters: dict[str, Any] | list[str] | None = None,
8486
work_group: str | None = None,
8587
s3_staging_dir: str | None = None,
86-
cache_size: int | None = 0,
87-
cache_expiration_time: int | None = 0,
88+
cache_size: int | None = None,
89+
cache_expiration_time: int | None = None,
8890
result_reuse_enable: bool | None = None,
8991
result_reuse_minutes: int | None = None,
9092
paramstyle: str | None = None,
93+
on_start_query_execution: Callable[[str], None] | None = None,
94+
result_set_type_hints: dict[str | int, str] | None = None,
95+
*,
96+
options: ExecuteOptions | None = None,
9197
**kwargs,
9298
) -> AioArrowCursor:
9399
"""Execute a SQL query asynchronously and return results as Arrow Tables.
@@ -102,25 +108,41 @@ async def execute( # type: ignore[override]
102108
result_reuse_enable: Enable Athena result reuse for this query.
103109
result_reuse_minutes: Minutes to reuse cached results.
104110
paramstyle: Parameter style ('qmark' or 'pyformat').
111+
on_start_query_execution: Callback called when query starts.
112+
result_set_type_hints: Optional dictionary mapping column names to
113+
Athena DDL type signatures for precise type conversion within
114+
complex types.
115+
options: Shared execution options as an
116+
:class:`~pyathena.options.ExecuteOptions` instance. Individual
117+
keyword arguments take precedence over ``options`` fields.
105118
**kwargs: Additional execution parameters.
106119
107120
Returns:
108121
Self reference for method chaining.
109122
"""
110123
self._reset_state()
111-
operation, unload_location = self._prepare_unload(operation, s3_staging_dir)
112-
self.query_id = await self._execute(
113-
operation,
114-
parameters=parameters,
124+
options = ExecuteOptions.resolve(
125+
options,
115126
work_group=work_group,
116127
s3_staging_dir=s3_staging_dir,
117128
cache_size=cache_size,
118129
cache_expiration_time=cache_expiration_time,
119130
result_reuse_enable=result_reuse_enable,
120131
result_reuse_minutes=result_reuse_minutes,
121132
paramstyle=paramstyle,
133+
on_start_query_execution=on_start_query_execution,
134+
result_set_type_hints=result_set_type_hints,
135+
)
136+
operation, unload_location = self._prepare_unload(operation, options.s3_staging_dir)
137+
self.query_id = await self._execute(
138+
operation,
139+
parameters=parameters,
140+
options=options,
122141
)
123142

143+
# Call user callbacks immediately after start_query_execution
144+
self._call_on_start_query_execution(self.query_id, options)
145+
124146
query_execution = await self._poll(self.query_id)
125147
if query_execution.state == AthenaQueryExecution.STATE_SUCCEEDED:
126148
self.result_set = await asyncio.to_thread(
@@ -134,6 +156,7 @@ async def execute( # type: ignore[override]
134156
unload_location=unload_location,
135157
connect_timeout=self._connect_timeout,
136158
request_timeout=self._request_timeout,
159+
result_set_type_hints=options.result_set_type_hints,
137160
**kwargs,
138161
)
139162
else:

pyathena/aio/common.py

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pyathena.common import BaseCursor, CursorIterator
1111
from pyathena.error import DatabaseError, OperationalError, ProgrammingError
1212
from pyathena.model import AthenaDatabase, AthenaQueryExecution, AthenaTableMetadata
13+
from pyathena.options import ExecuteOptions
1314
from pyathena.result_set import AthenaResultSet, WithResultSet
1415

1516
_logger = logging.getLogger(__name__)
@@ -27,29 +28,24 @@ async def _execute( # type: ignore[override]
2728
self,
2829
operation: str,
2930
parameters: dict[str, Any] | list[str] | None = None,
30-
work_group: str | None = None,
31-
s3_staging_dir: str | None = None,
32-
cache_size: int | None = 0,
33-
cache_expiration_time: int | None = 0,
34-
result_reuse_enable: bool | None = None,
35-
result_reuse_minutes: int | None = None,
36-
paramstyle: str | None = None,
31+
options: ExecuteOptions | None = None,
3732
) -> str:
38-
query, execution_parameters = self._prepare_query(operation, parameters, paramstyle)
33+
options = ExecuteOptions.resolve(options)
34+
query, execution_parameters = self._prepare_query(operation, parameters, options.paramstyle)
3935

4036
request = self._build_start_query_execution_request(
4137
query=query,
42-
work_group=work_group,
43-
s3_staging_dir=s3_staging_dir,
44-
result_reuse_enable=result_reuse_enable,
45-
result_reuse_minutes=result_reuse_minutes,
38+
work_group=options.work_group,
39+
s3_staging_dir=options.s3_staging_dir,
40+
result_reuse_enable=options.result_reuse_enable,
41+
result_reuse_minutes=options.result_reuse_minutes,
4642
execution_parameters=execution_parameters,
4743
)
4844
query_id = await self._find_previous_query_id(
4945
query,
50-
work_group,
51-
cache_size=cache_size if cache_size else 0,
52-
cache_expiration_time=cache_expiration_time if cache_expiration_time else 0,
46+
options.work_group,
47+
cache_size=options.cache_size,
48+
cache_expiration_time=options.cache_expiration_time,
5349
)
5450
if query_id is None:
5551
try:

pyathena/aio/cursor.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
from __future__ import annotations
22

33
import logging
4+
from collections.abc import Callable
45
from typing import Any, cast
56

67
from pyathena.aio.common import WithAsyncFetch
78
from pyathena.aio.result_set import AthenaAioDictResultSet, AthenaAioResultSet
89
from pyathena.common import CursorIterator
910
from pyathena.error import OperationalError, ProgrammingError
1011
from pyathena.model import AthenaQueryExecution
12+
from pyathena.options import ExecuteOptions
1113

1214
_logger = logging.getLogger(__name__)
1315

@@ -74,12 +76,15 @@ async def execute( # type: ignore[override]
7476
parameters: dict[str, Any] | list[str] | None = None,
7577
work_group: str | None = None,
7678
s3_staging_dir: str | None = None,
77-
cache_size: int = 0,
78-
cache_expiration_time: int = 0,
79+
cache_size: int | None = None,
80+
cache_expiration_time: int | None = None,
7981
result_reuse_enable: bool | None = None,
8082
result_reuse_minutes: int | None = None,
8183
paramstyle: str | None = None,
84+
on_start_query_execution: Callable[[str], None] | None = None,
8285
result_set_type_hints: dict[str | int, str] | None = None,
86+
*,
87+
options: ExecuteOptions | None = None,
8388
**kwargs,
8489
) -> AioCursor:
8590
"""Execute a SQL query asynchronously.
@@ -94,27 +99,40 @@ async def execute( # type: ignore[override]
9499
result_reuse_enable: Enable result reuse (optional).
95100
result_reuse_minutes: Result reuse duration in minutes (optional).
96101
paramstyle: Parameter style to use (optional).
102+
on_start_query_execution: Callback called when query starts.
97103
result_set_type_hints: Optional dictionary mapping column names to
98104
Athena DDL type signatures for precise type conversion within
99105
complex types.
106+
options: Shared execution options as an
107+
:class:`~pyathena.options.ExecuteOptions` instance. Individual
108+
keyword arguments take precedence over ``options`` fields.
100109
**kwargs: Additional execution parameters.
101110
102111
Returns:
103112
Self reference for method chaining.
104113
"""
105114
self._reset_state()
106-
self.query_id = await self._execute(
107-
operation,
108-
parameters=parameters,
115+
options = ExecuteOptions.resolve(
116+
options,
109117
work_group=work_group,
110118
s3_staging_dir=s3_staging_dir,
111119
cache_size=cache_size,
112120
cache_expiration_time=cache_expiration_time,
113121
result_reuse_enable=result_reuse_enable,
114122
result_reuse_minutes=result_reuse_minutes,
115123
paramstyle=paramstyle,
124+
on_start_query_execution=on_start_query_execution,
125+
result_set_type_hints=result_set_type_hints,
126+
)
127+
self.query_id = await self._execute(
128+
operation,
129+
parameters=parameters,
130+
options=options,
116131
)
117132

133+
# Call user callbacks immediately after start_query_execution
134+
self._call_on_start_query_execution(self.query_id, options)
135+
118136
query_execution = await self._poll(self.query_id)
119137
if query_execution.state == AthenaQueryExecution.STATE_SUCCEEDED:
120138
self.result_set = await self._result_set_class.create(
@@ -123,7 +141,7 @@ async def execute( # type: ignore[override]
123141
query_execution,
124142
self.arraysize,
125143
self._retry_config,
126-
result_set_type_hints=result_set_type_hints,
144+
result_set_type_hints=options.result_set_type_hints,
127145
)
128146
else:
129147
raise OperationalError(query_execution.state_change_reason)

pyathena/aio/pandas/cursor.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import asyncio
44
import logging
5-
from collections.abc import Iterable
5+
from collections.abc import Callable, Iterable
66
from multiprocessing import cpu_count
77
from typing import (
88
TYPE_CHECKING,
@@ -14,6 +14,7 @@
1414
from pyathena.common import CursorIterator
1515
from pyathena.error import OperationalError, ProgrammingError
1616
from pyathena.model import AthenaQueryExecution
17+
from pyathena.options import ExecuteOptions
1718
from pyathena.pandas.converter import (
1819
DefaultPandasTypeConverter,
1920
DefaultPandasUnloadTypeConverter,
@@ -97,14 +98,18 @@ async def execute( # type: ignore[override]
9798
parameters: dict[str, Any] | list[str] | None = None,
9899
work_group: str | None = None,
99100
s3_staging_dir: str | None = None,
100-
cache_size: int | None = 0,
101-
cache_expiration_time: int | None = 0,
101+
cache_size: int | None = None,
102+
cache_expiration_time: int | None = None,
102103
result_reuse_enable: bool | None = None,
103104
result_reuse_minutes: int | None = None,
104105
paramstyle: str | None = None,
105106
keep_default_na: bool = False,
106107
na_values: Iterable[str] | None = ("",),
107108
quoting: int = 1,
109+
on_start_query_execution: Callable[[str], None] | None = None,
110+
result_set_type_hints: dict[str | int, str] | None = None,
111+
*,
112+
options: ExecuteOptions | None = None,
108113
**kwargs,
109114
) -> AioPandasCursor:
110115
"""Execute a SQL query asynchronously and return results as pandas DataFrames.
@@ -122,25 +127,41 @@ async def execute( # type: ignore[override]
122127
keep_default_na: Whether to keep default pandas NA values.
123128
na_values: Additional values to treat as NA.
124129
quoting: CSV quoting behavior (pandas csv.QUOTE_* constants).
130+
on_start_query_execution: Callback called when query starts.
131+
result_set_type_hints: Optional dictionary mapping column names to
132+
Athena DDL type signatures for precise type conversion within
133+
complex types.
134+
options: Shared execution options as an
135+
:class:`~pyathena.options.ExecuteOptions` instance. Individual
136+
keyword arguments take precedence over ``options`` fields.
125137
**kwargs: Additional pandas read_csv/read_parquet parameters.
126138
127139
Returns:
128140
Self reference for method chaining.
129141
"""
130142
self._reset_state()
131-
operation, unload_location = self._prepare_unload(operation, s3_staging_dir)
132-
self.query_id = await self._execute(
133-
operation,
134-
parameters=parameters,
143+
options = ExecuteOptions.resolve(
144+
options,
135145
work_group=work_group,
136146
s3_staging_dir=s3_staging_dir,
137147
cache_size=cache_size,
138148
cache_expiration_time=cache_expiration_time,
139149
result_reuse_enable=result_reuse_enable,
140150
result_reuse_minutes=result_reuse_minutes,
141151
paramstyle=paramstyle,
152+
on_start_query_execution=on_start_query_execution,
153+
result_set_type_hints=result_set_type_hints,
154+
)
155+
operation, unload_location = self._prepare_unload(operation, options.s3_staging_dir)
156+
self.query_id = await self._execute(
157+
operation,
158+
parameters=parameters,
159+
options=options,
142160
)
143161

162+
# Call user callbacks immediately after start_query_execution
163+
self._call_on_start_query_execution(self.query_id, options)
164+
144165
query_execution = await self._poll(self.query_id)
145166
if query_execution.state == AthenaQueryExecution.STATE_SUCCEEDED:
146167
self.result_set = await asyncio.to_thread(
@@ -161,6 +182,7 @@ async def execute( # type: ignore[override]
161182
cache_type=kwargs.pop("cache_type", self._cache_type),
162183
max_workers=kwargs.pop("max_workers", self._max_workers),
163184
auto_optimize_chunksize=self._auto_optimize_chunksize,
185+
result_set_type_hints=options.result_set_type_hints,
164186
**kwargs,
165187
)
166188
else:

0 commit comments

Comments
 (0)