Repository navigation
Read CSV results through fsspec for Polars 2.0 #1169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0c6a0b2
6ec3e00
a473e9c
22de454
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,8 @@ | |
| cast, | ||
| ) | ||
|
|
||
| import fsspec | ||
|
|
||
| from pyathena import OperationalError | ||
| from pyathena.arrow.reader import ParquetChunkReader | ||
| from pyathena.converter import Converter | ||
|
|
@@ -346,10 +348,10 @@ def _read_kwargs( | |
|
|
||
| @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. | ||
|
|
@@ -592,17 +594,22 @@ def _read_csv(self) -> pl.DataFrame: | |
| if "schema_overrides" not in self._kwargs: | ||
| # Renamed after reading, so that Polars matches the types to the header. | ||
| kwargs.pop("new_columns", None) | ||
| # 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. | ||
| storage_options = kwargs.pop("storage_options") | ||
| source: str | bytes | ||
| if self.output_location: | ||
| source = location = self.output_location | ||
| location = self.output_location | ||
| else: | ||
| source = self._fetch_all_rows_as_csv() | ||
| if not source: | ||
| return pl.DataFrame() | ||
| location = "the GetQueryResults rows" | ||
| del kwargs["storage_options"] | ||
|
|
||
| try: | ||
| if self.output_location: | ||
| with fsspec.open(self.output_location, "rb", **(storage_options or {})) as file: | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Self-review round one (implementation behavior): Base Covered:
|
||
| source = file.read() | ||
| try: | ||
| df = pl.read_csv(source, **kwargs) | ||
| except pl.exceptions.ComputeError: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -104,12 +104,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: | ||
| return ("array", 0, 0) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Self-review of the repair (both perspectives): Range: Round-one perspective (behavior): Round-two perspective (claims): the commit message, PR release notes, and docs line were checked against the live check above and the Validation: Polars suites 154 passed (3.14/2.0.0) and 153 passed + 1 skipped (3.12/1.44.2); |
||
| 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) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Independent review (relayed): Reviewer: Codex CLI 0.162.1, model Reviewer output: Covered:
FINDINGS
The CSV fix itself appears sound. The new test meaningfully checks replacement options and real parsing and would fail against the old Polars 2.0 path. The adjusted naming tests accommodate verified upstream changes, though they remove explicit partial-renaming-with-schema coverage on Polars 1.x. The dependency split matches the stated CI intent. Static review only; no tests, builds, edits, or GitHub writes performed.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Author verification and repair (finding 1): confirmed and fixed in a473e9c. Live UNLOAD check of Deferred, with reasons: the reviewer noted that naming every column in the
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Independent follow-up review (relayed): Reviewer: Codex CLI 0.162.1, model Reviewer output: Covered surfaces:
CLEAN Both earlier findings are resolved within the reviewed scope.
Static review only; no builds, tests, or AWS calls were run. Polars 1.39 compatibility was assessed from the guarded API usage, not execution against that exact version. |
||
| 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"): | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -626,6 +626,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) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CI failure, repair, and self-review of the repair (both perspectives): Test run 38570714905 at Cause, checked locally: Behavior perspective: PyAthena code is unchanged; no other test or doc describes the map representation of
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Independent follow-up review (relayed): Reviewer: Codex CLI 0.162.1, model Reviewer output: Surfaces covered:
CLEAN The expectations at test_cursor.py:629 correctly distinguish Polars 1.x’s list-of-structs representation from Polars 2.0’s The change preserves the class-based integration test, indirect fixture parametrization, and existing assertions, consistent with No missed unconditional Polars 1.x map assumption was found. Related metadata handling already guards Static review only; no tests, builds, or AWS calls were run. Installed-source checks covered 1.44.2 and 2.0.0, not every supported release. |
||
| 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, | ||
|
|
@@ -641,7 +648,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), | ||
| ) | ||
|
|
@@ -661,7 +668,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"), | ||
| } | ||
|
|
||
| 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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Author verification and repair (finding 2): confirmed and fixed in a473e9c.
In both Polars 1.44.2 and 2.0.0,
read_parquet(use_pyarrow=True)calls_read_parquet_with_pyarrow(), which opens the source withprepare_file_arg(..., storage_options=...), that is, with fsspec. The added line now says the Polars read function uses its native S3 access by default and fsspec withuse_pyarrow=True.just docs lintpasses.