Repository navigation
Parse temporal text with fromisoformat and convert PandasCursor time columns after reading (GH-1308) #1312
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
Parse temporal text with fromisoformat and convert PandasCursor time columns after reading (GH-1308) #1312
Changes from all commits
3bdd65d
9ebd8ff
f5029cf
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 |
|---|---|---|
|
|
@@ -12,7 +12,7 @@ | |
| from concurrent.futures import ThreadPoolExecutor | ||
| from contextlib import ExitStack | ||
| from contextvars import copy_context | ||
| from datetime import datetime, timezone | ||
| from datetime import datetime, time, timezone | ||
| from functools import partial | ||
| from io import BufferedReader, BytesIO, IOBase, StringIO, TextIOWrapper | ||
| from itertools import chain, islice | ||
|
|
@@ -30,7 +30,7 @@ | |
| from pyathena import OperationalError | ||
| from pyathena.arrow.reader import ParquetChunkReader | ||
| from pyathena.arrow.util import to_column_info | ||
| from pyathena.converter import Converter, _parse_time_zone, _to_datetime_with_tz | ||
| from pyathena.converter import Converter, _parse_time_zone, _to_datetime_with_tz, _to_time | ||
| from pyathena.error import DataError, ProgrammingError | ||
| from pyathena.filesystem.s3 import S3FileSystem | ||
| from pyathena.model import AthenaQueryExecution | ||
|
|
@@ -57,7 +57,7 @@ | |
|
|
||
| # The Athena types whose CSV text AthenaPandasResultSet reads as text and converts after | ||
| # reading; see _TemporalConverter. | ||
| _CSV_TEMPORAL_TYPES = ("timestamp", "date", "timestamp with time zone") | ||
| _CSV_TEMPORAL_TYPES = ("timestamp", "date", "timestamp with time zone", "time") | ||
| # A timestamp's text up to its sixth fractional digit, for years of four digits. | ||
| _TIMESTAMP_TEXT_LENGTH = len("2000-01-01 00:00:00.000000") | ||
|
|
||
|
|
@@ -314,12 +314,13 @@ def _convert_values(self, values: Series) -> Series: | |
| class _TemporalConverter: | ||
| """A converter for the text of a temporal column that ``pandas.read_csv()`` read. | ||
|
|
||
| ``timestamp``, ``date``, and ``timestamp with time zone`` columns are read as text | ||
| and converted as whole columns after reading, so that their dtypes depend only on | ||
| the Athena type: ``datetime64[us]``, ``datetime64[us]``, and ``datetime64[us, UTC]``. | ||
| Fractional digits beyond microseconds are truncated. A column that pandas already | ||
| parsed, as with ``parse_dates`` when a ``dtype`` given to ``execute()`` replaces | ||
| PyAthena's, gets the same unit. | ||
| ``timestamp``, ``date``, ``timestamp with time zone``, and ``time`` columns are | ||
| read as text and converted as whole columns after reading, so that their dtypes | ||
| depend only on the Athena type: ``datetime64[us]``, ``datetime64[us]``, | ||
| ``datetime64[us, UTC]``, and ``object`` with ``time`` objects and None for NULL. | ||
| Fractional digits beyond microseconds are truncated. A column that pandas or | ||
| pyarrow already parsed, as when ``dtype=None`` given to ``execute()`` replaces | ||
| PyAthena's dtype mapping, gets the same dtype. | ||
| """ | ||
|
|
||
| __slots__ = ("_type",) | ||
|
|
@@ -366,6 +367,8 @@ def _convert_values(self, values: Series) -> Series: | |
| """ | ||
| if self._type == "timestamp with time zone": | ||
| return _to_utc_timestamps(values) | ||
| if self._type == "time": | ||
| return _to_times(values) | ||
| return _to_timestamps(values, date=self._type == "date") | ||
|
|
||
|
|
||
|
|
@@ -448,6 +451,33 @@ def _to_timestamps(values: Series, date: bool = False) -> Series: | |
| return pd.to_datetime(text, format="ISO8601").dt.as_unit("us") | ||
|
|
||
|
|
||
| def _to_times(values: Series) -> Series: | ||
| """Convert time text to ``time`` objects. | ||
|
|
||
| pandas cannot infer the format of time text, which it would parse one value at a | ||
| time with ``dateutil``. | ||
|
|
||
| Args: | ||
| values: The text values, with missing values for NULL, datetime values that | ||
| pandas parsed, or ``time`` values that pyarrow parsed. | ||
|
|
||
| Returns: | ||
| The values as ``time`` objects, with None for NULL. | ||
| """ | ||
| import pandas as pd | ||
|
|
||
| if values.dtype.kind == "M": | ||
| return values.dt.time.astype(object).where(values.notna(), None) | ||
| return pd.Series( | ||
| [ | ||
| _to_time(v) if isinstance(v, str) else v if isinstance(v, time) else 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): Codex CLI
Result: FINDINGS (2 introduced by the diff) 1. P2. Times that pyarrow already parsed silently become NULL (
Pre-existing observation (P3 docs), folded into the same repair: the Correction to my round-two record: I wrote that 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 (relayed): Codex CLI. CLEAN
Result:
|
||
| for v in values.to_numpy(dtype=object) | ||
| ], | ||
| index=values.index, | ||
| dtype=object, | ||
| ) | ||
|
|
||
|
|
||
| def _to_utc_timestamps(values: Series) -> Series: | ||
| """Convert ``timestamp with time zone`` text to ``datetime64[us, UTC]``. | ||
|
|
||
|
|
@@ -1515,6 +1545,7 @@ def _get_fetch_types(self) -> dict[Any, str]: | |
| return { | ||
| label: "date" if converter.type == "date" else "datetime" | ||
| for label, converter in self._csv_temporal_converters.items() | ||
| if converter.type != "time" | ||
| } | ||
| if self._kwargs.keys() & {"dtype_backend", "coerce_int96_timestamp_unit"}: | ||
| return {} | ||
|
|
@@ -1529,11 +1560,11 @@ def _get_csv_temporal_converters( | |
| ) -> dict[Any, _TemporalConverter]: | ||
| """Get the converters of the temporal columns that are converted after reading. | ||
|
|
||
| PyAthena converts the ``timestamp``, ``date``, and ``timestamp with time zone`` | ||
| columns after reading, unless ``parse_dates``, ``dtype``, or ``converters`` is | ||
| given to ``execute()``, with which pandas reads them, or the converter maps the | ||
| type to a conversion function other than the default ``timestamp with time | ||
| zone`` one. | ||
| PyAthena converts the ``timestamp``, ``date``, ``timestamp with time zone``, and | ||
| ``time`` columns after reading, unless ``parse_dates``, ``dtype``, or | ||
| ``converters`` is given to ``execute()``, with which pandas reads them, or the | ||
| converter maps the type to a conversion function other than the default | ||
| ``timestamp with time zone`` one. | ||
|
|
||
| Args: | ||
| columns: The label and the description of each column that is read. | ||
|
|
@@ -1584,13 +1615,29 @@ def _get_csv_converter( | |
| return converter | ||
|
|
||
| def _trunc_date(self, df: DataFrame) -> DataFrame: | ||
| if self._time_columns: | ||
| """Convert the time columns that pandas parsed as datetimes to ``time`` objects. | ||
|
|
||
| pandas parses the time columns with ``parse_dates`` when ``parse_dates``, | ||
| ``dtype``, or ``converters`` given to ``execute()`` keeps PyAthena from | ||
| converting them after reading. A time column can also be an index level. Time | ||
| columns that PyAthena converted after reading or that pandas did not parse are | ||
| left as they are. | ||
|
|
||
| Args: | ||
| df: The DataFrame or chunk, updated in place. | ||
|
|
||
| Returns: | ||
| The same DataFrame. | ||
| """ | ||
| for time_col in self._time_columns: | ||
| if time_col in self._csv_temporal_converters: | ||
|
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), finding 2. P3. A converted
Pre-existing, not changed: malformed scalar text such as Validation on
|
||
| continue | ||
| # A NULL is None, as with the GetQueryResults fallback and the other types. | ||
| truncated = df.loc[:, self._time_columns].apply( | ||
| lambda r: r.dt.time.astype(object).where(r.notna(), None) | ||
| _replace_by_label( | ||
|
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 two (claims, callers, evidence): FINDINGS, repaired Base Claims checked: the PR description, the commit message, the Findings:
Also checked:
Re-run on
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. Round two follow-up on the repair
|
||
| df, | ||
| time_col, | ||
| lambda values: _to_times(values) if values.dtype.kind == "M" else values, | ||
| ) | ||
| for time_col in self._time_columns: | ||
| df.isetitem(df.columns.get_loc(time_col), truncated[time_col]) | ||
| return df | ||
|
|
||
| @override | ||
|
|
||
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): CLEAN
Base
0a58be36ff188fc1dc9541d1d28e5383500a8110, head3bdd65d9e0d61628da7e3341c970c2238c481f90.Covered:
_DEFAULT_CONVERTERS(Cursor, DictCursor, S3FSCursor, and the nested values ofTypedValueConverterinpyathena/parser.py, which does not catch converter errors); the Arrow, Polars, and pandas mappings; and_to_utc_timestamps().fromisoformat()on Python 3.11 and 3.14 for 0 to 12 fractional digits and years 1 and 9999. Results equal the old truncation.Tseparator, basic forms, offsets) is now accepted; noted under "Behavior change" in the PR description._to_times()(pyathena/pandas/result_set.py:454):future.infer_stringon and off), from pyarrow strings (pd.NA), and as an empty string becomes None.timecolumns give the same dtype and values as master for whole, chunked, andengine="pyarrow"reads.dtype={},converters={},usecols,keep_default_na,chunksize, andengine="pyarrow"give the same values as master.parse_dates=[]andparse_dates=["ts"]failed on master withAttributeError; a time column left out ofparse_datesnow stays text with the c, python, and pyarrow engines.index_col="t"failed on master withKeyError; the index now holdstimeobjects._get_fetch_types()leaves out thetimecolumns, whose values are alreadytimeobjects._types_frameexpectation pins the raw text read.Pre-existing and out of scope: since 36b23f0, the
values.dtype.kind == "M"branches of_to_timestamps()and_to_utc_timestamps()(pyathena/pandas/result_set.py:432,:496) look unreachable, because the temporal converters are empty wheneverparse_dates,dtype, orconvertersis given._to_times()does not add such a branch. This PR leaves them unchanged.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 on the repair
9ebd8ff3(affected scope): CLEANgit range-diffscope:_to_times()accepts parsed datetime values, and_trunc_date()calls_replace_by_label()._to_times()keeps master's.dt.time.astype(object).where(notna, None), so NULL is None as before.parse_dates=[]andparse_dates=["ts"]still give text with the c, python, and pyarrow engines._replace_by_label()rebuilds it, as for the other temporal columns.usecolsthat leaves the column out is skipped.df[name]is a DataFrame, is skipped by_replace_by_label()'s Series check. Duplicate CSV names get distinct labels from_key_csv_columns_by_labels().test_time_index[*-pandas_parses]fails with only the repair reverted, and passes on the head.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 on the repair
f5029cfe(independent-review repairs, affected scope): CLEAN_to_times(): pyarrow'stime64values come out ofto_numpy(dtype=object)asdatetime.timeand are kept.datetimeis not a subclass oftime, so no other value type passes through. The order is unchanged: text, then time, then None for NaN andpd.NA._trunc_date(): it skips only the labels in_csv_temporal_converters, which holdtimeobjects. Withparse_dates,dtype, orconvertersgiven, the mapping is empty, so pandas-parsed columns and index levels are still converted.usecols,index_col, chunks) and thedtype=Nonematrix with TIME(0) and TIME(3), whole and withindex_col. Results match master, except for the intended fixes.