Repository navigation
Read the timestamp columns of chunked Polars CSV results as text #1194
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
bc95db9
f5023e8
80424b2
eee0346
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 |
|---|---|---|
|
|
@@ -546,29 +546,41 @@ def _get_converters( | |
| for name, d in zip(column_names, description, strict=True) | ||
| } | ||
|
|
||
| def _get_timestamp_dtypes(self, has_header: bool) -> dict[str, Any]: | ||
| """Get the Datetime dtypes of the timestamp columns, which are read as text. | ||
| def _get_csv_schema_overrides(self, has_header: bool) -> tuple[dict[str, Any], dict[str, Any]]: | ||
| """Get the Polars data types for reading CSV data, with the timestamp columns as text. | ||
|
|
||
| Athena writes up to 12 fractional digits, which Polars does not parse into a | ||
| Datetime whose time unit holds fewer. A chunked read always reads the timestamp | ||
| columns as text and converts them with ``_to_datetimes()``, and a whole read | ||
| does so when parsing them fails. | ||
|
|
||
| Args: | ||
| has_header: Whether the CSV data has a header. Without one, or with | ||
| ``schema_overrides`` or ``with_column_names`` given to ``execute()``, | ||
| Polars does not name the columns by the header, and they are not read | ||
| as text. | ||
| Polars does not name the columns by the header, and with ``schema``, | ||
| Polars ignores ``schema_overrides``, so the columns are not read as text. | ||
|
|
||
| Returns: | ||
| The Datetime dtypes keyed by the header of a CSV file. | ||
| Tuple of (schema_overrides, timestamp_dtypes): the data types to read the | ||
| columns with, and the Datetime dtypes of the timestamp columns read as text, | ||
| both keyed by the header of a CSV file. | ||
| """ | ||
| import polars as pl | ||
|
|
||
| if not has_header or self._kwargs.keys() & {"schema_overrides", "with_column_names"}: | ||
| return {} | ||
| dtypes = self._csv_dtypes | ||
| return { | ||
| if not has_header or self._kwargs.keys() & { | ||
| "schema", | ||
| "schema_overrides", | ||
| "with_column_names", | ||
| }: | ||
| return dtypes, {} | ||
| timestamp_dtypes = { | ||
| name: dtype | ||
| for name, d in zip(self._get_column_names(), self.description or [], strict=True) | ||
| if d[1] == "timestamp" | ||
| and ((dtype := dtypes.get(name)) is pl.Datetime or isinstance(dtype, pl.Datetime)) | ||
| } | ||
| return {**dtypes, **dict.fromkeys(timestamp_dtypes, pl.String)}, timestamp_dtypes | ||
|
|
||
| def _get_column_names(self) -> list[str]: | ||
| """Get the names of the result columns in a DataFrame. | ||
|
|
@@ -729,17 +741,16 @@ def _read_csv(self) -> pl.DataFrame: | |
| try: | ||
| df = pl.read_csv(source, **kwargs) | ||
| except pl.exceptions.ComputeError: | ||
| timestamp_dtypes = self._get_timestamp_dtypes(has_header) | ||
| schema_overrides, timestamp_dtypes = self._get_csv_schema_overrides(has_header) | ||
| if not timestamp_dtypes: | ||
| raise | ||
| # Athena writes up to 12 fractional digits, which Polars does not parse | ||
| # into a Datetime whose time unit holds fewer, so the data is read again | ||
| # with the timestamp columns as text. | ||
| kwargs["schema_overrides"] = { | ||
| **self._csv_dtypes, | ||
| **dict.fromkeys(timestamp_dtypes, pl.String), | ||
| } | ||
| df = _to_datetimes(pl.read_csv(source, **kwargs), timestamp_dtypes) | ||
| # The data is read again with the timestamp columns as text. | ||
| kwargs["schema_overrides"] = schema_overrides | ||
| df = _to_datetimes( | ||
| pl.read_csv(source, **kwargs), | ||
| timestamp_dtypes, | ||
| strict=not kwargs.get("ignore_errors", False), | ||
| ) | ||
| if new_columns: | ||
| df.columns = [*new_columns, *df.columns[len(new_columns) :]] | ||
| return df | ||
|
|
@@ -906,14 +917,16 @@ def _iter_csv_chunks(self) -> Iterator[pl.DataFrame]: | |
| raise ProgrammingError("output_location is not available.") | ||
|
|
||
| separator, has_header, new_columns = self._get_csv_params() | ||
| schema_overrides, timestamp_dtypes = self._get_csv_schema_overrides(has_header) | ||
| return self._scan_csv_chunks( | ||
| self.output_location, | ||
| self.connection, | ||
| { | ||
| "separator": separator, | ||
| "has_header": has_header, | ||
| "schema_overrides": self._csv_dtypes, | ||
| "schema_overrides": schema_overrides, | ||
| }, | ||
| timestamp_dtypes, | ||
| new_columns, | ||
| ) | ||
|
|
||
|
|
@@ -922,6 +935,7 @@ def _scan_csv_chunks( | |
| location: str, | ||
| connection: Connection[Any], | ||
| defaults: dict[str, Any], | ||
| timestamp_dtypes: dict[str, Any], | ||
| new_columns: list[str] | None, | ||
| ) -> Iterator[pl.DataFrame]: | ||
| """Scan a CSV result file in chunks with Polars. | ||
|
|
@@ -932,6 +946,8 @@ def _scan_csv_chunks( | |
| frozen when the first chunk is read. | ||
| defaults: The arguments of ``polars.scan_csv()`` that the result set | ||
| chooses; see ``_read_kwargs()``. | ||
| timestamp_dtypes: The Datetime dtypes of the timestamp columns read as | ||
| text; see ``_get_csv_schema_overrides()``. | ||
| new_columns: The names of the first columns, set after reading; see | ||
| ``_get_csv_params()``. | ||
|
|
||
|
|
@@ -952,7 +968,12 @@ def _scan_csv_chunks( | |
| if "schema_overrides" not in self._kwargs: | ||
| # Renamed after reading, so that Polars matches the types to the header. | ||
| read_kwargs.pop("new_columns", None) | ||
| lazy_df = pl.scan_csv(location, **read_kwargs) | ||
| lazy_df = _to_datetimes( | ||
| pl.scan_csv(location, **read_kwargs), | ||
| timestamp_dtypes, | ||
| # As Polars reads a value it cannot parse as null with ignore_errors. | ||
| strict=not read_kwargs.get("ignore_errors", False), | ||
|
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): FINDINGS. Reviewer: Codex CLI 0.162.1, model Covered: whole-read parse and fallback; chunked lazy conversion and Finding (P2): Verified and repaired in eee0346:
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): CLEAN. Reviewer: Codex CLI 0.162.1, Covered: Notes from the reviewer, not actionable: the new tests do not exercise the whole read's fallback with |
||
| ) | ||
| for batch in lazy_df.collect_batches(chunk_size=self._chunksize): | ||
| if new_columns: | ||
| batch.columns = [*new_columns, *batch.columns[len(new_columns) :]] | ||
|
|
||
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.
Self-review round one (implementation behavior): FINDINGS, repaired.
Scope: merge-base
06224494788043a4887afcb112a96c94bbcb02f1..bc95db9a6441caf4e112754b16c0631106583d16;pyathena/polars/result_set.py(_get_csv_schema_overrides(),_read_csv(),_iter_csv_chunks(),_scan_csv_chunks()),tests/pyathena/polars/test_result_set.py,tests/pyathena/polars/test_cursor.py. Callers traced: the sync and async PolarsCursor shareAthenaPolarsResultSet; the S3 result file and the managed GetQueryResults rows both reach_read_csv().Finding: with
schemagiven toexecute(), Polars ignoresschema_overrides(checked withread_csv()andscan_csv()), so a timestamp column keeps the dtype from that schema. bc95db9 still converted it with_to_datetimes(): aStringcolumn becameDatetime("us"), and aDatetime("ms")column failed withOperationalError, for both readers. On master both kept the schema's dtype; the old fallback ran only after aComputeError.Repair (f5023e8):
schemajoinsschema_overridesandwith_column_namesas a reason not to read the columns as text.test_read_csv_truncates_timestampsgains aschemacase per reader; both fail without the repair.Checked without findings: errors from
_to_datetimes()are inside each reader'stryand becomeOperationalError; NULL as an empty field or"";columns,new_columns(renamed after the conversion),.txtresults without a header, and tz-aware ornsDatetime dtypes from a custom converter.Validation:
just lint;pytest tests/pyathena/polars/test_result_set.py tests/pyathena/polars/test_converter.py: 76 passed. The AWS Polars suites ran at bc95db9 (209 passed) and are rerun before Ready.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.
Round one, follow-up after the design change (80424b2): CLEAN for the changed scope (
git range-diff-equivalent: f5023e8..80424b2 touches_read_csv(),_scan_csv_chunks(),_to_datetimes(), and their tests).Round two measured that reading the timestamp columns of a whole read as text raised its peak RSS by about 35-40%. After consulting Codex and claude-fable-5-1, the maintainer chose to keep the whole read as on master (native parse, text re-read only on
ComputeError) and to convert the chunked read's text columns in thescan_csv()query plan.Checked: the whole read matches master except that a
ComputeErrorwithschemagiven re-raises without the futile re-read (the outcome is the sameOperationalError); a conversion error in a chunk surfaces fromcollect_batches()inside thetryasOperationalError;cs.by_name(require_all=False)matches names literally (not as a regex), skips a column the frame lacks without resolving a LazyFrame's schema, and keeps the column order;collect_batches()keepsmaintain_order=True; a header-only file yields no batches as before;new_columnsis applied after the conversion.Validation at 80424b2:
just lint; Polars 2.0.0 with AWStests/pyathena/polars tests/pyathena/aio/polars: 214 passed; unit tests on Polars 1.39.0 and 1.44.2: 79 passed each.