Repository navigation
Keep columns with the same name, and key CSV column types by the reader's column labels #1050
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
d724287
3ee10ab
3216542
0e1cc83
fb7bfab
e783222
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 |
|---|---|---|
|
|
@@ -411,9 +411,10 @@ def __init__( | |
| # The converters that pandas.read_csv() applies, keyed by column name. | ||
| self._csv_converters: dict[Any, Callable[[str | None], Any]] = {} | ||
|
|
||
| # Cache time column names for efficient _trunc_date processing | ||
| # Cache time column names for efficient _trunc_date processing. _read_csv() | ||
| # replaces them with the labels of the columns that it reads. | ||
| description = self.description if self.description else [] | ||
| self._time_columns: list[str] = [d[0] for d in description if d[1] == "time"] | ||
| self._time_columns: list[Any] = [d[0] for d in description if d[1] == "time"] | ||
|
|
||
| import pandas as pd | ||
|
|
||
|
|
@@ -475,6 +476,12 @@ def _get_csv_engine( | |
| effective_chunksize is None | ||
| and self._quoting == 1 | ||
| and not self.converters | ||
| # The pyarrow engine does not rename columns with the same name, and | ||
| # the column labels cannot be resolved for it (see | ||
| # _get_csv_column_labels()). | ||
| and not self._needs_csv_column_name_resolution( | ||
| [d[0] for d in self.description or []] | ||
| ) | ||
| and (file_size_bytes is None or file_size_bytes >= self.PYARROW_MIN_FILE_SIZE_BYTES) | ||
| ) | ||
| if is_compatible: | ||
|
|
@@ -588,6 +595,23 @@ def parse_dates(self) -> list[Any | None]: | |
| description = self.description if self.description else [] | ||
| return [d[0] for d in description if d[1] in self._PARSE_DATES] | ||
|
|
||
| def _get_column_names(self) -> list[Any]: | ||
| """Get the names of the result columns in the DataFrame. | ||
|
|
||
| Columns with the same name are renamed as pandas renames them when it reads | ||
| the header of a CSV file, such as ``x`` and ``x.1``. | ||
|
|
||
| Returns: | ||
| List of column names. | ||
| """ | ||
| import pandas as pd | ||
|
|
||
| description = self.description if self.description else [] | ||
| names = [d[0] for d in description] | ||
| if len(set(names)) == len(names): | ||
| return names | ||
| return self._resolve_csv_column_names(names, {}, pd.read_csv)[0] | ||
|
|
||
| def _finish_csv_frame(self, df: DataFrame) -> DataFrame: | ||
| """Finish a DataFrame read from the CSV result file. | ||
|
|
||
|
|
@@ -641,8 +665,8 @@ def fetchone( | |
| return None | ||
| else: | ||
| self._rownumber = row[0] + 1 | ||
| description = self.description if self.description else [] | ||
| return tuple([row[1][d[0]] for d in description]) | ||
| # By position, so that columns with the same name keep their own values. | ||
| return tuple(row[1].values()) | ||
|
|
||
| def _read_csv(self) -> TextFileReader | DataFrame: | ||
| import pandas as pd | ||
|
|
@@ -676,11 +700,14 @@ def _read_csv(self) -> TextFileReader | DataFrame: | |
|
|
||
| csv_engine = self._get_csv_engine(length, effective_chunksize) | ||
| read_csv_kwargs = self._get_csv_read_options(csv_engine, effective_chunksize) | ||
| labels = self._get_csv_column_labels(csv_engine, read_csv_kwargs) | ||
| if labels is not None: | ||
| self._key_csv_columns_by_labels(read_csv_kwargs, labels) | ||
|
|
||
| try: | ||
| with ExitStack() as stack: | ||
| source: str | IOBase = self.output_location | ||
| binary_columns = self._configure_binary_csv_read(read_csv_kwargs, pd.read_csv) | ||
| binary_columns = self._configure_binary_csv_read(read_csv_kwargs, labels) | ||
| self._csv_converters = read_csv_kwargs.get("converters") or {} | ||
| if binary_columns: | ||
| # Given storage_options, even None, open the file through fsspec | ||
|
|
@@ -721,7 +748,7 @@ def _get_csv_read_options(self, csv_engine: str, chunksize: int | None) -> dict[ | |
| if self.output_location and self.output_location.endswith(".txt"): | ||
| sep = "\t" | ||
| header = None | ||
| names = [d[0] for d in self.description or []] | ||
| names = self._get_column_names() | ||
| else: | ||
| sep = "," | ||
| header = 0 | ||
|
|
@@ -796,12 +823,17 @@ def _resolve_csv_column_names( | |
| ) | ||
| return column_names, selected_names | ||
|
|
||
| def _can_preserve_binary_csv_nulls(self, read_csv_kwargs: dict[str, Any]) -> bool: | ||
| """Whether CSV settings support distinguishing binary NULL from empty values.""" | ||
| def _is_standard_csv_parsing(self, read_csv_kwargs: dict[str, Any]) -> bool: | ||
| """Whether the options read a CSV result file as Athena writes it. | ||
|
|
||
| Args: | ||
| read_csv_kwargs: The options for ``pandas.read_csv()``. | ||
|
|
||
| Returns: | ||
| True if pandas reads the header and quoted fields of the file as written. | ||
| """ | ||
| return not ( | ||
| "varbinary" not in self._converter.mappings | ||
| or "converters" in self._kwargs | ||
| or not self.output_location | ||
| not self.output_location | ||
| or not self.output_location.endswith(".csv") | ||
| or read_csv_kwargs.get("header") != 0 | ||
| or read_csv_kwargs.get("skiprows") is not None | ||
|
|
@@ -810,6 +842,14 @@ def _can_preserve_binary_csv_nulls(self, read_csv_kwargs: dict[str, Any]) -> boo | |
| or read_csv_kwargs.get("quotechar", '"') != '"' | ||
| ) | ||
|
|
||
| def _can_preserve_binary_csv_nulls(self, read_csv_kwargs: dict[str, Any]) -> bool: | ||
| """Whether CSV settings support distinguishing binary NULL from empty values.""" | ||
| return ( | ||
| "varbinary" in self._converter.mappings | ||
| and "converters" not in self._kwargs | ||
| and self._is_standard_csv_parsing(read_csv_kwargs) | ||
| ) | ||
|
|
||
| def _needs_csv_column_name_resolution(self, column_names: list[Any]) -> bool: | ||
| """Whether pandas must resolve column names instead of using Athena metadata.""" | ||
| return ( | ||
|
|
@@ -829,38 +869,98 @@ def _needs_csv_column_name_resolution(self, column_names: list[Any]) -> bool: | |
| ) | ||
| ) | ||
|
|
||
| def _get_csv_column_labels( | ||
| self, csv_engine: str, read_csv_kwargs: dict[str, Any] | ||
| ) -> list[Any] | None: | ||
| """Get the labels that pandas gives the result columns when it reads the CSV file. | ||
|
|
||
| Args: | ||
| csv_engine: The CSV engine that reads the file. | ||
| read_csv_kwargs: The options for ``pandas.read_csv()``. | ||
|
|
||
| Returns: | ||
| The label of each result column in the description order, with None for a | ||
| column that ``usecols`` leaves out. None if the options do not read the file | ||
| as Athena writes it, or if the labels do not match the result columns one | ||
| to one, such as with fewer ``names``. | ||
| """ | ||
| import pandas as pd | ||
|
|
||
| # The pyarrow engine runs only when no labels need resolving (see | ||
| # _get_csv_engine()), and does not support reading only the header. | ||
| if csv_engine == "pyarrow" or not self._is_standard_csv_parsing(read_csv_kwargs): | ||
| return None | ||
| column_names = [d[0] for d in self.description or []] | ||
| if not self._needs_csv_column_name_resolution(column_names): | ||
| return column_names | ||
| labels, selected_labels = self._resolve_csv_column_names( | ||
| column_names, read_csv_kwargs, pd.read_csv | ||
| ) | ||
| if len(labels) != len(column_names): | ||
| return None | ||
| return [label if label in selected_labels else None for label in labels] | ||
|
|
||
| def _key_csv_columns_by_labels( | ||
|
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, redesign (implementation): CLEAN Scope: full pass over Checked:
Limitations, recorded:
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. Repair 4585ef7, round one (implementation): CLEAN. Reviewed
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. Repair a21bb92, round one: CLEAN. In Polars,
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. Repair f66d765, rounds one and two: CLEAN. Both Polars readers pop
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. Rebase over #1044 (e783222), rounds one and two: CLEAN. Reviewed
Validation on e783222:
PR body updated with the tested commit and the #1044 interaction. |
||
| self, read_csv_kwargs: dict[str, Any], labels: list[Any] | ||
| ) -> None: | ||
| """Key the column options of ``pandas.read_csv()`` by the labels of the columns. | ||
|
|
||
| Columns with the same name keep their own converters and date parsing, and | ||
| options that rename or select columns get the types of the columns they | ||
| read. The ``dtype``, ``converters``, and ``parse_dates`` given to | ||
| ``execute()`` are kept as they are. | ||
|
|
||
| Args: | ||
| read_csv_kwargs: The options for ``pandas.read_csv()``, updated in place. | ||
| labels: The labels from ``_get_csv_column_labels()``. | ||
| """ | ||
| description = self.description or [] | ||
| columns = [ | ||
| (label, d) for label, d in zip(labels, description, strict=True) if label is not None | ||
| ] | ||
| if "dtype" not in self._kwargs: | ||
| read_csv_kwargs["dtype"] = { | ||
| label: dtype | ||
| for label, d in columns | ||
| if (dtype := self._converter.get_dtype(d[1], d[4], d[5])) is not None | ||
| } | ||
| if "converters" not in self._kwargs: | ||
| read_csv_kwargs["converters"] = { | ||
| label: self._get_csv_converter(d[1]) | ||
| for label, d in columns | ||
| if d[1] in self._converter.mappings | ||
| } | ||
| if "parse_dates" not in self._kwargs: | ||
| read_csv_kwargs["parse_dates"] = [ | ||
| label for label, d in columns if d[1] in self._PARSE_DATES | ||
| ] | ||
| self._time_columns = [label for label, d in columns if d[1] == "time"] | ||
|
|
||
| def _configure_binary_csv_read( | ||
| self, read_csv_kwargs: dict[str, Any], read_csv: Callable[..., DataFrame] | ||
| self, read_csv_kwargs: dict[str, Any], labels: list[Any] | None | ||
| ) -> set[int]: | ||
| """Wrap binary converters and return column positions needing NULL preservation.""" | ||
| if not self._can_preserve_binary_csv_nulls(read_csv_kwargs): | ||
| return set() | ||
| """Wrap binary converters and return column positions needing NULL preservation. | ||
|
|
||
| description = self.description or [] | ||
| binary_columns = {i for i, d in enumerate(description) if d[1] == "varbinary"} | ||
| if not binary_columns: | ||
| return set() | ||
| Args: | ||
| read_csv_kwargs: The options for ``pandas.read_csv()``, whose converters | ||
| are keyed by the column labels. | ||
| labels: The labels from ``_get_csv_column_labels()``. | ||
|
|
||
| column_names = [d[0] for d in description] | ||
| converters = read_csv_kwargs["converters"] | ||
| if self._needs_csv_column_name_resolution(column_names): | ||
| column_names, selected_names = self._resolve_csv_column_names( | ||
| column_names, read_csv_kwargs, read_csv | ||
| ) | ||
| if len(column_names) != len(description): | ||
| return set() | ||
| converters = { | ||
| name: self._get_csv_converter(d[1]) | ||
| for name, d in zip(column_names, description, strict=True) | ||
| if d[1] in self._converter.mappings and name in selected_names | ||
| } | ||
| binary_columns = {i for i in binary_columns if column_names[i] in selected_names} | ||
| Returns: | ||
| The positions of the binary columns whose NULL fields the stream preserves. | ||
| """ | ||
| if labels is None or not self._can_preserve_binary_csv_nulls(read_csv_kwargs): | ||
| return set() | ||
|
|
||
| description = self.description or [] | ||
| binary_columns = { | ||
| i for i, d in enumerate(description) if d[1] == "varbinary" and labels[i] is not None | ||
| } | ||
| if binary_columns: | ||
| converters = read_csv_kwargs["converters"] | ||
| for index in binary_columns: | ||
| name = column_names[index] | ||
| converters[name] = partial(_convert_binary_csv, converters[name]) | ||
| read_csv_kwargs["converters"] = converters | ||
| label = labels[index] | ||
| converters[label] = partial(_convert_binary_csv, converters[label]) | ||
| return binary_columns | ||
|
|
||
| def _open_binary_csv_stream( | ||
|
|
@@ -957,24 +1057,20 @@ def _as_pandas_from_api(self, converter: Converter | None = None) -> DataFrame: | |
| if not rows: | ||
| return pd.DataFrame() | ||
| description = self.description if self.description else [] | ||
| columns = [d[0] for d in description] | ||
| columnar = self._rows_to_columnar(rows, columns) | ||
| # Positional, so that columns with the same name keep their own values. | ||
| columns = [list(column) for column in zip(*rows, strict=True)] | ||
| # Integer columns get the dtype that the CSV result file reads them with, | ||
| # and json columns with NULL stay objects as there, so that NULL does not | ||
| # make their values floats. | ||
| dtypes: dict[str, Any] = {} | ||
| for d in description: | ||
| data: dict[Any, Any] = {} | ||
| for name, values, d in zip(self._get_column_names(), columns, description, strict=True): | ||
| dtype = None | ||
| if d[1] in self._INTEGER_TYPES: | ||
| if (dtype := self._converter.get_dtype(d[1], d[4], d[5])) is not None: | ||
| dtypes[d[0]] = dtype | ||
| elif d[1] == "json" and None in columnar[d[0]]: | ||
| dtypes[d[0]] = object | ||
| return pd.DataFrame( | ||
| { | ||
| name: values if name not in dtypes else pd.array(values, dtype=dtypes[name]) | ||
| for name, values in columnar.items() | ||
| } | ||
| ) | ||
| dtype = self._converter.get_dtype(d[1], d[4], d[5]) | ||
| elif d[1] == "json" and None in values: | ||
| dtype = object | ||
| data[name] = values if dtype is None else pd.array(values, dtype=dtype) | ||
| return pd.DataFrame(data) | ||
|
|
||
| def as_pandas(self) -> PandasDataFrameIterator | DataFrame: | ||
| """Return the query results as a DataFrame or an iterator of DataFrame chunks. | ||
|
|
||
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
Scope:
git diff 28ec68d9f70b5bc2fb447f6a4fb745ee0a467683..8e5dda66ad09576e7f19aa941c50651ab72e38ee, all 9 files.Checked:
AsyncCursor, and aio cursors (pyathena/{pandas,arrow,polars}/async_cursor.py,pyathena/aio/{pandas,arrow,polars}/cursor.py)._fetch_all_rows()is also used byAthenaS3FSResultSet(pyathena/s3fs/result_set.py:125), which reads positionally and gets the first-page labels fix. Nothing else referenced_rows_to_columnar(). Only Polars passescolumn_namesto_json_converters(); Arrow keeps the description names, wherestrict=Trueholds._get_column_names()returns the description names unchanged unless a name repeats. In that case it uses each library's header parser, so a collision such asx, x, x.1gets pandas' ownx.2. Read paths keyed by these names:dtypes,converters,parse_dates,_time_columns,fetchone(), the.txtnames=(pandas) /new_columns(Polars), the chunked readers, and the GetQueryResults fallback. The binary-NULL path (_configure_binary_csv_read) already resolves duplicates with the same parser, so its converter keys match. Measured live: duplicatejson/time/date/varbinary/decimalcolumns convert correctly on the S3 and managed paths, and withchunksize=2.engine="pyarrow". It does not rename duplicates (pandas 3.0.6: columnsx, x, and adtypekeyed byx.1raisesAttributeError), so it falls back toconly when names repeat.close()now leaves an exhausted iterator, sofetchone()/fetchmany()/fetchall()returnNone/[], as pandas (enumerate([])) and Polars (iter([])) do.first_pageis computed before the request, so only the request without aNextTokenskips the labels row, matching_pre_fetch().pyathena/only). The live duplicate tests include a non-duplicate column (y), so they hit the managed-path length mismatch, and they includejsonandtime, which exercise converters andparse_dates.Limitations, recorded rather than fixed:
x, x, x_duplicated_0raises Polars'DuplicateError. The S3 path raised it before this PR (wrapped inOperationalError), and the managed path raisedShapeErrorbefore. Both still fail..txtpaths with duplicates; Athena writes.txtonly for DDL-style output.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.
Rebase repair, round one (implementation): CLEAN. The branch was rebased from 8e5dda6 (merge-base 28ec68d) to e87840a (merge-base ec5323e) to resolve conflicts with #1033. Reviewed with
git range-diff 28ec68d9f70b5bc2fb447f6a4fb745ee0a467683..8e5dda66ad09576e7f19aa941c50651ab72e38ee ec5323ea30e3fc2da1aca536d9cbdf8b51c7b3e2..e87840ac4fe02887f43981d8a4009a0797a14513; both old objects exist.The resolution keeps both changes:
_json_convertersbecame Convert time zone values to aware times and empty TIME/JSON text to NULL #1033's_text_value_converters(types in_TEXT_VALUE_TYPES), still with the optionalcolumn_names._time_columnsuses Convert time zone values to aware times and empty TIME/JSON text to NULL #1033'sd[1] == "time"over the renamed names._fetchauto-merge reads by position and calls_text_value_converterswith the description names, which equal the table names.Upstream contracts checked: #1033 adds no other name-keyed maps in the result set files. Its new pandas/Polars/Arrow converters are reached through the
converters/dtypesproperties, which are keyed by the renamed names.Validation on e87840a:
just lint; offline tests 5 passed; live-k "duplicate_column_names or fetch_all_rows or time"18 passed. Live duplicatetime with time zone/jsoncolumns return aware times and decoded JSON for all three cursors, on S3 and managed.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.
CI repair (6d073ae), round one: CLEAN. AWS CI on e87840a failed only
tests/pyathena/pandas/test_cursor.py::TestPandasCursor::test_get_csv_engine_explicit_specification(AttributeError: ... no attribute '_metadata'; 2798 passed). The test buildsAthenaPandasResultSet.__new__without__init__, and the new duplicate-name condition in_get_csv_engine()readsdescription. That was my local miss, because I selected tests with-k._get_csv_engine()is called only from_read_csv(), after__init__has set_metadata.result_set._metadata = None, which gives the samedescriptionas a real result set without metadata.descriptionrepeats a name with the pyarrow engine requested and available, and asserts thecengine. Before this, the new branch had no test.pyathena/reverted to the merge-base (assert 'pyarrow' == 'c') and passes on 6d073ae. Lint passed.