Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions pyathena/aio/sqlalchemy/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

import pyathena
from pyathena.aio.connection import AioConnection
from pyathena.aio.cursor import AioCursor
from pyathena.cursor import Cursor
from pyathena.error import (
DatabaseError,
DataError,
Expand All @@ -29,6 +31,9 @@
from sqlalchemy import URL


_ASYNC_CURSOR_CLASSES: dict[Any, Any] = {Cursor: AioCursor}


class AsyncAdapt_pyathena_cursor:
"""Wraps any async PyAthena cursor with a sync DBAPI interface.

Expand Down Expand Up @@ -139,8 +144,10 @@ def catalog_name(self) -> str | None:
def schema_name(self) -> str | None:
return self._connection.schema_name # type: ignore[no-any-return]

def cursor(self) -> AsyncAdapt_pyathena_cursor:
raw_cursor = self._connection.cursor()
def cursor(self, cursor: Any = None) -> AsyncAdapt_pyathena_cursor:
# The shared dialect names a cursor class in its synchronous form; this
# connection can only drive the async counterpart.
raw_cursor = self._connection.cursor(_ASYNC_CURSOR_CLASSES.get(cursor, cursor))
return AsyncAdapt_pyathena_cursor(raw_cursor)

def close(self) -> None:
Expand Down
15 changes: 10 additions & 5 deletions pyathena/polars/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
cast,
)

import fsspec

from pyathena import OperationalError
from pyathena.converter import Converter
from pyathena.error import ProgrammingError
Expand Down Expand Up @@ -268,10 +270,10 @@ def __init__(

@property
def _csv_storage_options(self) -> dict[str, Any]:
"""Get storage options for Polars CSV reading via fsspec.
"""Get storage options for reading a CSV result file via fsspec.

Polars read_csv uses fsspec for cloud storage access, which works
with PyAthena's registered S3FileSystem.
``_read_csv()`` opens the file with ``fsspec.open()``, which uses
PyAthena's registered S3FileSystem, and passes its contents to Polars.

Returns:
Dictionary with fsspec-compatible options for S3 access.
Expand Down Expand Up @@ -463,12 +465,15 @@ def _read_csv(self) -> pl.DataFrame:
separator, has_header, new_columns = self._get_csv_params()

try:
# Polars 2.0 reads a path with its native object store, which does not take
# fsspec options, so the result file is read through fsspec here.
with fsspec.open(self.output_location, "rb", **self._csv_storage_options) as file:
source = file.read()
df = pl.read_csv(
self.output_location,
source,
separator=separator,
has_header=has_header,
schema_overrides=self.dtypes,
storage_options=self._csv_storage_options,
**self._kwargs,
)
if new_columns:
Expand Down
14 changes: 10 additions & 4 deletions pyathena/polars/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,18 @@ def get_athena_type(dtype: Any) -> tuple[str, int, int]:
if base_dtype == polars_type or dtype == polars_type:
return athena_info

# Handle parameterized types that didn't match above
dtype_str = str(dtype).lower()
if "list" in dtype_str:
# Nested types are matched by their outer type, not by the types they contain.
if base_dtype == pl.List:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round two (claims, callers, operations): FINDINGS, repaired in the PR body only; no code change.

Scope: base 5970145, head a2e6899; the PR body, the five commit messages, the changed comments and docstring, and docs/polars.md and docs/sqlalchemy.md on 3.x.

Finding (repaired): the release note said an UNLOAD map was described as string before and map now. With Polars 2.0 on the base, a map whose values are lists or structs was described as array or row, because str(dtype) is Map(String, List(Int32)) or Map(String, Struct({'a': Int32})) and the old substring checks matched it. The note now states all three previous results. The TEST section's account of the Polars 1.36.1 file-cache panic is narrowed to what was observed. The eviction explanation is marked as inferred from the Polars source (EvictionCandidate::update_ttl()), not traced in the run.

Claims checked and held:

  • **kwargs and the adapter's cursor_kwargs/retry_config properties come from Fix metadata reflection throttling and reuse listed metadata #777 (commits 7932642, 922b295, and 9d68b59 all belong to Fix metadata reflection throttling and reuse listed metadata #777). Connection._internal_cursor() comes from BREAKING: Reject unknown cursor keyword arguments #1102 (09f7fb3). 3.x has no pyathena.util.override.
  • uv.lock: only the polars and polars-runtime-32 entries change. Polars 2.0.0 requires Python >=3.10, so every Python version 3.x supports resolves it for a fresh PyAthena[polars]<4 install.
  • The tests 0c6a0b2 adjusts do not exist on 3.x (TestAthenaPolarsResultSet, test_new_columns_with_schema_overrides, test_csv_read_kwargs_replace_defaults, test_txt_new_columns_with_schema_overrides, test_storage_options_replace_defaults: 0 matches).
  • The chunked UNLOAD path (_create_dataframe_iterator() → _iter_parquet_chunks()) never calls _read_parquet_schema(), so it keeps the UNLOAD statement's rows metadata; Describe chunked Polars UNLOAD results with the Parquet schema #1012 ("Describe chunked Polars UNLOAD results with the Parquet schema") is the master fix.
  • Existing callers: Apache Superset's connection test calls engine.dialect.do_ping(engine.raw_connection()) (superset/commands/database/utils.py). That passes a _ConnectionFairy, whose cursor(*args, **kwargs) forwards the cursor class to the DBAPI connection (SQLAlchemy 2.0.46, pool/base.py). SQLAlchemy 1.4's default do_ping() re-raises a non-disconnect error, and this dialect does not override is_disconnect(), so error propagation is unchanged.
  • Operations: the 3.x release gate runs just tox → just test pyathena (pytest -n 8) on Python 3.10–3.14, so Python 3.10–3.13 keep Polars 1.36.1 as before.
  • Docs: docs/polars.md still holds (PolarsCursor uses pl.read_csv/pl.read_parquet, and PyAthena's S3FileSystem for S3 access); docs/sqlalchemy.md does not mention pings.
  • Evidence: every result in the TEST section was produced at a2e6899, including the base comparisons, which ran on 5970145.

return ("array", 0, 0)
if "struct" in dtype_str:
if base_dtype == pl.Struct:
return ("row", 0, 0)
# Polars 2.0 reads a Parquet map as Map; Polars 1.x reads it as a list of structs.
map_dtype = getattr(pl, "Map", None)
if map_dtype is not None and base_dtype == map_dtype:
return ("map", 0, 0)

# Handle parameterized types that didn't match above
dtype_str = str(dtype).lower()
if "decimal" in dtype_str:
# Extract precision and scale from Decimal type if available
if hasattr(dtype, "precision") and hasattr(dtype, "scale"):
Expand Down
39 changes: 39 additions & 0 deletions pyathena/sqlalchemy/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
)

import pyathena
from pyathena.cursor import Cursor
from pyathena.sqlalchemy.compiler import (
AthenaDDLCompiler,
AthenaStatementCompiler,
Expand Down Expand Up @@ -52,6 +53,7 @@
PoolProxiedConnection,
)
from sqlalchemy.engine.interfaces import (
DBAPIConnection,
ReflectedForeignKeyConstraint,
ReflectedIndex,
ReflectedPrimaryKeyConstraint,
Expand Down Expand Up @@ -85,6 +87,35 @@
}


class _PingConnection:
"""A DBAPI connection whose ``cursor()`` opens an API cursor.

``AthenaDialect.do_ping()`` passes it to ``DefaultDialect.do_ping()``, so the ping
does not run through the connection's default cursor class. Other attributes are
those of the pinged connection, which ``is_disconnect()`` receives before
SQLAlchemy 2.0.5.
"""

def __init__(self, connection: Any) -> None:
"""Wrap the pinged connection.

Args:
connection: The pinged DBAPI connection, or the async adapter around one.
"""
self._connection = connection

def cursor(self) -> Any:
"""Open an API cursor on the pinged connection.

Returns:
The cursor, wrapped by the async adapter for an async connection.
"""
return self._connection.cursor(Cursor)

def __getattr__(self, name: str) -> Any:
return getattr(self._connection, name)


class AthenaDialect(DefaultDialect):
"""SQLAlchemy dialect for Amazon Athena.

Expand Down Expand Up @@ -284,6 +315,14 @@ def _get_table(self, connection, table_name: str, schema: str | None = None, **k
raise exc.NoSuchTableError(table_name) from e
raise

def do_ping(self, dbapi_connection: DBAPIConnection) -> bool:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round one (implementation behavior): CLEAN.

Scope: base 5970145, head a2e6899; every changed file (dialect do_ping(), the async adapter's cursor(), AthenaPolarsResultSet._read_csv(), get_athena_type(), the dev group and uv.lock, and the new and changed tests).

  • Behavior and failure paths: do_ping() returns True or raises, like SQLAlchemy's default on 2.x. On 1.x the default also catches dbapi.Error for is_disconnect(), which this dialect does not override, so the error propagates there too. Hypothesis checked and rejected: a DataFrame cursor's arraysize above 1000 in cursor_kwargs making the API cursor raise. On 3.x, Connection.cursor() and AioConnection.cursor() ignore arraysize from cursor_kwargs for every cursor class (measured arraysize == 1000 for PandasCursor, Cursor, AioPandasCursor, and AioCursor with 5000 and 0), so the ping cannot fail where the default cursor succeeded. unload and chunksize are accepted and ignored by Cursor/AioCursor.
  • The async adapter's cursor() with no argument passes None to AioConnection.cursor(), which picks the default cursor class as before; SQLAlchemy never passed arguments to it on 3.x, since it accepted none.
  • Data and resource boundaries: _read_csv() keeps the output_location check and wraps fsspec.open() failures in OperationalError with the same log line. The bytes are read whole, as Polars 1.x did for an fsspec path. A storage_options key in the execute kwargs raised TypeError (duplicate keyword) before and now reaches pl.read_csv(); not a regression, and 3.x never supported it.
  • get_athena_type(): Polars 1.x still describes an UNLOAD map (list of structs) as array; a struct containing a list changes from array to row, which is release-noted.
  • Tests: test_do_ping_uses_api_cursor_without_unload fails on the base (the default do_ping() opens PandasCursor/AioPandasCursor, whose execute() is not patched). The live reproduction on the base and the branch is recorded in the PR body.

Out of scope, recorded only: with Polars 1.36.1, _iter_csv_chunks() can yield no chunks when Polars' file cache panics in a collect_batches() worker (see the PR body). That is pre-existing on 3.x and unaffected by this diff.

# When unload is enabled in the default cursor, SELECT 1 is wrapped in UNLOAD
# which Athena rejects with MISSING_COLUMN_NAME because the column is unnamed.
# Using the plain cursor here performs the SELECT without the UNLOAD.
# The ping still runs in DefaultDialect.do_ping(), which before SQLAlchemy 2.0.5
# is also where a failed ping is checked for a disconnect.
return super().do_ping(cast("DBAPIConnection", _PingConnection(dbapi_connection)))

@reflection.cache
def _get_tables(self, connection, schema: str | None = None, **kw):
raw_connection = self._raw_connection(connection)
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ dev = [
"pyarrow>=10.0.0; python_version<'3.14'",
"pyarrow>=22.0.0; python_version>='3.14'",
"polars>=1.0.0",
# Pull requests test the newest Python with Polars 2, and the older Pythons keep
# the newest Polars 1.x release.
"polars>=2.0.0; python_version >= '3.14'",
"polars<2.0.0; python_version < '3.14'",
"Jinja2>=3.1.0",
"mypy>=0.900",
"pytest>=3.5",
Expand Down
11 changes: 11 additions & 0 deletions tests/pyathena/aio/sqlalchemy/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@


class TestAsyncSQLAlchemyAthena:
@pytest.mark.parametrize(
"async_engine",
[{"driver": "aiopandas", "unload": True}],
indirect=True,
)
async def test_ping_with_unload(self, async_engine):
_, conn = async_engine
assert await conn.run_sync(
lambda sync_conn: sync_conn.dialect.do_ping(sync_conn.connection.dbapi_connection)
)

@pytest.mark.parametrize(
"async_engine",
[
Expand Down
11 changes: 9 additions & 2 deletions tests/pyathena/arrow/test_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,13 @@ def test_complex_unload_as_polars(self, arrow_cursor):
).as_polars()
assert df.height == 1
assert df.width == 16
# Polars 2.0 converts an Arrow map to Map; Polars 1.x to a list of key-value structs.
if hasattr(pl, "Map"):
map_dtype = pl.Map(pl.Int32, pl.Int32)
map_value = {1: 2, 3: 4}
else:
map_dtype = pl.List(pl.Struct([pl.Field("key", pl.Int32), pl.Field("value", pl.Int32)]))
map_value = [{"key": 1, "value": 2}, {"key": 3, "value": 4}]
dtypes = tuple(df.dtypes)
assert dtypes == (
pl.Boolean,
Expand All @@ -624,7 +631,7 @@ def test_complex_unload_as_polars(self, arrow_cursor):
pl.Date,
pl.Binary,
pl.List(pl.Int32),
pl.List(pl.Struct([pl.Field("key", pl.Int32), pl.Field("value", pl.Int32)])),
map_dtype,
pl.Struct([pl.Field("a", pl.Int32), pl.Field("b", pl.Int32)]),
pl.Decimal(precision=10, scale=1),
)
Expand All @@ -644,7 +651,7 @@ def test_complex_unload_as_polars(self, arrow_cursor):
"col_date": datetime(2017, 1, 2).date(),
"col_binary": b"123",
"col_array": [1, 2],
"col_map": [{"key": 1, "value": 2}, {"key": 3, "value": 4}],
"col_map": map_value,
"col_struct": {"a": 1, "b": 2},
"col_decimal": Decimal("0.1"),
}
Expand Down
22 changes: 22 additions & 0 deletions tests/pyathena/polars/test_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,28 @@ def test_iterator_with_chunksize_unload(self, polars_cursor):
rows = list(polars_cursor)
assert len(rows) == 15

@pytest.mark.parametrize(
"polars_cursor",
[
{"cursor_kwargs": {"unload": True}},
],
indirect=["polars_cursor"],
)
def test_nested_type_description_unload(self, polars_cursor):
"""UNLOAD results describe nested Parquet columns by their outer type."""
polars_cursor.execute(
"""
SELECT
ARRAY[1, 2] AS a
,CAST(ROW(ARRAY[1]) AS ROW(x ARRAY(INTEGER))) AS r
,MAP(ARRAY['k'], ARRAY[1]) AS m
"""
)
# Polars 1.x reads a Parquet map as a list of key-value structs.
map_type = "map" if hasattr(pl, "Map") else "array"
description = [d[:2] for d in polars_cursor.description]
assert description == [("a", "array"), ("r", "row"), ("m", map_type)]

@pytest.mark.parametrize(
"polars_cursor",
[
Expand Down
31 changes: 31 additions & 0 deletions tests/pyathena/polars/test_util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Copyright 2026 The PyAthena authors
#
# Licensed under the MIT License.
# See LICENSE or https://opensource.org/licenses/MIT.
#
# SPDX-License-Identifier: MIT

import polars as pl
import pytest

from pyathena.polars.util import get_athena_type


@pytest.mark.parametrize(
("dtype", "expected"),
[
(pl.List(pl.Int32), ("array", 0, 0)),
(pl.List(pl.Struct({"x": pl.Int32})), ("array", 0, 0)),
(pl.Struct({"x": pl.Int32}), ("row", 0, 0)),
(pl.Struct({"x": pl.List(pl.Int32)}), ("row", 0, 0)),
],
)
def test_get_athena_type_nested(dtype, expected):
assert get_athena_type(dtype) == expected


@pytest.mark.skipif(not hasattr(pl, "Map"), reason="Polars 1.x has no Map type")
def test_get_athena_type_map():
assert get_athena_type(pl.Map(pl.String, pl.Int32)) == ("map", 0, 0)
assert get_athena_type(pl.Map(pl.String, pl.List(pl.Int32))) == ("map", 0, 0)
assert get_athena_type(pl.List(pl.Map(pl.String, pl.Int32))) == ("array", 0, 0)
Loading
Loading