1from __future__ import annotations
2
3from collections import defaultdict
4from typing import (
5 TYPE_CHECKING,
6 Any,
7)
8import warnings
9
10import numpy as np
11
12from pandas._libs import (
13 lib,
14 parsers,
15)
16from pandas.compat._optional import import_optional_dependency
17from pandas.errors import DtypeWarning
18from pandas.util._exceptions import find_stack_level
19
20from pandas.core.dtypes.common import pandas_dtype
21from pandas.core.dtypes.concat import (
22 concat_compat,
23 union_categoricals,
24)
25from pandas.core.dtypes.dtypes import CategoricalDtype
26
27from pandas.core.arrays.arrow.array import ArrowExtensionArray
28from pandas.core.arrays.string_ import StringDtype
29from pandas.core.arrays.string_arrow import ArrowStringArray
30from pandas.core.indexes.api import ensure_index_from_sequences
31
32from pandas.io.common import (
33 dedup_names,
34 is_potential_multi_index,
35)
36from pandas.io.parsers.base_parser import (
37 ParserBase,
38 ParserError,
39 date_converter,
40 evaluate_callable_usecols,
41 is_index_col,
42 validate_parse_dates_presence,
43)
44
45if TYPE_CHECKING:
46 from collections.abc import (
47 Hashable,
48 Mapping,
49 Sequence,
50 )
51
52 from pandas._typing import (
53 AnyArrayLike,
54 ArrayLike,
55 DtypeArg,
56 DtypeObj,
57 ReadCsvBuffer,
58 SequenceT,
59 )
60
61 from pandas import (
62 Index,
63 MultiIndex,
64 )
65
66
67class CParserWrapper(ParserBase):
68 low_memory: bool
69 _reader: parsers.TextReader
70 _exhausted: bool
71 # When False, read() leaves deferred string columns as raw pending
72 # handles for the caller to materialize (one chunked ExtensionArray per
73 # column, e.g. post-gather in a parallel read).
74 wrap_deferred: bool = True
75
76 def __init__(self, src: ReadCsvBuffer[str], **kwds) -> None:
77 super().__init__(kwds)
78 self.kwds = kwds
79 kwds = kwds.copy()
80
81 self.low_memory = kwds.pop("low_memory", False)
82 self._exhausted = False
83
84 # #2442
85 kwds["allow_leading_cols"] = self.index_col is not False
86
87 # GH20529, validate usecol arg before TextReader
88 kwds["usecols"] = self.usecols
89
90 # Have to pass int, would break tests using TextReader directly otherwise :(
91 kwds["on_bad_lines"] = self.on_bad_lines.value
92
93 for key in (
94 "storage_options",
95 "encoding",
96 "memory_map",
97 "compression",
98 ):
99 kwds.pop(key, None)
100
101 kwds["dtype"] = ensure_dtype_objs(kwds.get("dtype", None))
102 if "dtype_backend" not in kwds or kwds["dtype_backend"] is lib.no_default:
103 kwds["dtype_backend"] = "numpy"
104 if kwds["dtype_backend"] == "pyarrow":
105 # Fail here loudly instead of in cython after reading
106 import_optional_dependency("pyarrow")
107 self._reader = parsers.TextReader(src, **kwds)
108 # Let the pyarrow string fast path return raw pending-column handles;
109 # read() wraps them into one ExtensionArray per column at the end.
110 self._reader.defer_pa_wrap = True
111
112 self.unnamed_cols = self._reader.unnamed_cols
113
114 passed_names = self.names is None
115
116 if self._reader.header is None:
117 self.names = None
118 else:
119 (
120 self.names,
121 self.index_names,
122 self.col_names,
123 passed_names,
124 ) = self._extract_multi_indexer_columns(
125 self._reader.header,
126 self.index_names,
127 passed_names,
128 )
129
130 if self.names is None:
131 self.names = list(range(self._reader.table_width))
132
133 # gh-9755
134 #
135 # need to set orig_names here first
136 # so that proper indexing can be done
137 # with _set_noconvert_columns
138 #
139 # once names has been filtered, we will
140 # then set orig_names again to names
141 self.orig_names = self.names[:]
142
143 if self.usecols:
144 usecols = evaluate_callable_usecols(self.usecols, self.orig_names)
145
146 # GH 14671
147 # assert for mypy, orig_names is List or None, None would error in issubset
148 assert self.orig_names is not None
149 if self.usecols_dtype == "string" and not set(usecols).issubset(
150 self.orig_names
151 ):
152 self._validate_usecols_names(usecols, self.orig_names)
153
154 if len(self.names) > len(usecols):
155 self.names = [
156 n
157 for i, n in enumerate(self.names)
158 if (i in usecols or n in usecols)
159 ]
160
161 if len(self.names) < len(usecols):
162 self._validate_usecols_names(
163 usecols,
164 self.names,
165 )
166
167 validate_parse_dates_presence(self.parse_dates, self.names)
168 self._set_noconvert_columns()
169
170 self.orig_names = self.names
171
172 if self._reader.leading_cols == 0 and is_index_col(self.index_col):
173 (
174 index_names,
175 self.names,
176 self.index_col,
177 ) = self._clean_index_names(
178 self.names,
179 self.index_col,
180 )
181
182 if self.index_names is None:
183 self.index_names = index_names
184
185 if self._reader.header is None and not passed_names:
186 assert self.index_names is not None
187 self.index_names = [None] * len(self.index_names)
188
189 self._implicit_index = self._reader.leading_cols > 0
190
191 def close(self) -> None:
192 # close handles opened by C parser
193 try:
194 self._reader.close()
195 except ValueError:
196 pass
197
198 def _set_noconvert_columns(self) -> None:
199 """
200 Set the columns that should not undergo dtype conversions.
201
202 Currently, any column that is involved with date parsing will not
203 undergo such conversions.
204 """
205 assert self.orig_names is not None
206 # error: Cannot determine type of 'names'
207
208 # much faster than using orig_names.index(x) xref GH#44106
209 names_dict = {x: i for i, x in enumerate(self.orig_names)} # pyright: ignore[reportOptionalIterable]
210 col_indices = [names_dict[x] for x in self.names] # pyright: ignore[reportOptionalIterable]
211 noconvert_columns = self._set_noconvert_dtype_columns(
212 col_indices,
213 self.names,
214 )
215 for col in noconvert_columns:
216 self._reader.set_noconvert(col)
217
218 # Mark parse_dates columns so the C parser can try to emit datetime64
219 # directly, skipping the Python-str ndarray round-trip. In low_memory
220 # mode the C parser keeps raw-byte receipts per chunk so a fallback in
221 # any chunk restores the whole column to the object-string path.
222 # With a list parse_dates, noconvert_columns is exactly the resolved
223 # parse_dates targets (integer colspecs usecols-relative, matching
224 # _do_date_conversions).
225 # Not with implicit-index files (leading_cols > 0): noconvert indices
226 # are not shifted by leading_cols, so the fastpath gate in
227 # _convert_tokens cannot line up with them.
228 if isinstance(self.parse_dates, list) and self._reader.leading_cols == 0:
229 for col_idx in noconvert_columns:
230 name = self.orig_names[col_idx]
231 require_consistent = self._parse_dates_fastpath_strictness(name)
232 if require_consistent is not None:
233 self._reader.set_datetime_convert(col_idx, require_consistent)
234
235 def _parse_dates_fastpath_strictness(self, name: Hashable) -> bool | None:
236 """
237 Decide whether a parse_dates column is a candidate for the direct
238 char-buffer -> datetime64 fastpath, returning None if not, otherwise
239 whether the fastpath must require a single consistent format.
240
241 With date_format=None, to_datetime infers one format from the first
242 value and rejects rows that deviate, so the fastpath must do the same;
243 with date_format="ISO8601" mixed ISO8601 layouts are allowed.
244 """
245 if self.dayfirst:
246 return None
247 date_format = self.date_format
248 if isinstance(date_format, dict):
249 date_format = date_format.get(name)
250 if date_format is None:
251 return True
252 if date_format == "ISO8601":
253 return False
254 # If a specific format is supplied, let the existing path handle it.
255 # A future extension could detect ISO-shaped formats here.
256 return None
257
258 def _low_memory_column_labels(
259 self, chunks: list[dict[int, ArrayLike]]
260 ) -> dict[int, Hashable]:
261 """
262 Map the field positions ``read_low_memory`` keys its chunks by to labels.
263
264 The positions are those of the source row, so they skip whatever
265 ``usecols`` dropped and start past a leading implicit index column.
266 ``orig_names`` holds exactly the named columns that remain, in the same
267 order, which is how ``read`` renames these keys further down. A leading
268 implicit index column has no name of its own and maps to ``None``.
269 """
270 assert self.orig_names is not None
271 positions = sorted(chunks[0])
272 named = dict(
273 zip(
274 positions[self._reader.leading_cols :],
275 self.orig_names,
276 strict=False,
277 )
278 )
279 return {position: named.get(position) for position in positions}
280
281 def read(
282 self,
283 nrows: int | None = None,
284 ) -> tuple[
285 Index | MultiIndex | None,
286 Sequence[Hashable] | MultiIndex,
287 Mapping[Hashable, AnyArrayLike],
288 ]:
289 index: Index | MultiIndex | None
290 column_names: Sequence[Hashable] | MultiIndex
291 if self._exhausted:
292 # Exhausting the reader closed it, so calling into the C reader
293 # again would raise instead of signalling the end of the data.
294 raise StopIteration
295 try:
296 if self.low_memory:
297 chunks = self._reader.read_low_memory(nrows)
298 # destructive to chunks
299 data = _concatenate_chunks(
300 chunks, self._low_memory_column_labels(chunks)
301 )
302 # GH#56044 category-dtype inference is deferred until after
303 # concatenation so all chunks agree
304 self._reader._maybe_infer_categoricals(data)
305 else:
306 data = self._reader.read(nrows)
307 if self.wrap_deferred:
308 data = {
309 key: _wrap_deferred_pa(values) for key, values in data.items()
310 }
311 except StopIteration:
312 if self._first_chunk:
313 self._first_chunk = False
314 # assert for mypy, orig_names is List or None, None would error in
315 # list(...) in dedup_names
316 assert self.orig_names is not None
317 names = dedup_names(
318 self.orig_names,
319 is_potential_multi_index(self.orig_names, self.index_col),
320 )
321 index, columns, col_dict = self._get_empty_meta(
322 names,
323 dtype=self.dtype,
324 )
325 # error: Incompatible types in assignment (expression has type
326 # "list[Hashable] | MultiIndex", variable has type "list[Hashable]")
327 columns = self._maybe_make_multi_index_columns( # type: ignore[assignment]
328 columns, self.col_names
329 )
330
331 columns = _filter_usecols(self.usecols, columns)
332 columns_set = set(columns)
333
334 col_dict = {k: v for k, v in col_dict.items() if k in columns_set}
335
336 return index, columns, col_dict
337
338 else:
339 self._exhausted = True
340 self.close()
341 raise
342
343 # Done with first read, next time raise StopIteration
344 self._first_chunk = False
345
346 names = self.names
347
348 if self._reader.leading_cols:
349 # implicit index, no index names
350 arrays = []
351
352 if self.index_col and self._reader.leading_cols != len(self.index_col):
353 raise ParserError(
354 "Could not construct index. Requested to use "
355 f"{len(self.index_col)} number of columns, but "
356 f"{self._reader.leading_cols} left to parse."
357 )
358
359 for i in range(self._reader.leading_cols):
360 if self.index_col is None:
361 values = data.pop(i)
362 else:
363 values = data.pop(self.index_col[i])
364
365 if self._should_parse_dates(i):
366 values = date_converter(
367 values,
368 col=(
369 self.index_names[i]
370 if self.index_names is not None
371 else None
372 ),
373 dayfirst=self.dayfirst,
374 cache_dates=self.cache_dates,
375 date_format=self.date_format,
376 )
377 arrays.append(values)
378
379 index = ensure_index_from_sequences(arrays)
380
381 names = _filter_usecols(self.usecols, names)
382
383 names = dedup_names(names, is_potential_multi_index(names, self.index_col))
384
385 # rename dict keys
386 data_tups = sorted(data.items())
387 data = {k: v for k, (i, v) in zip(names, data_tups, strict=True)}
388
389 date_data = self._do_date_conversions(names, data)
390
391 # maybe create a mi on the columns
392 column_names = self._maybe_make_multi_index_columns(names, self.col_names)
393
394 else:
395 # rename dict keys
396 data_tups = sorted(data.items())
397
398 # ugh, mutation
399
400 # assert for mypy, orig_names is List or None, None would error in list(...)
401 assert self.orig_names is not None
402 names = list(self.orig_names)
403 names = dedup_names(names, is_potential_multi_index(names, self.index_col))
404
405 names = _filter_usecols(self.usecols, names)
406
407 # columns as list
408 alldata = [x[1] for x in data_tups]
409 if self.usecols is None:
410 self._check_data_length(names, alldata)
411
412 data = {k: v for k, (i, v) in zip(names, data_tups, strict=False)}
413
414 date_data = self._do_date_conversions(names, data)
415 index, column_names = self._make_index(alldata, names)
416
417 return index, column_names, date_data
418
419
420def _filter_usecols(usecols, names: SequenceT) -> SequenceT | list[Hashable]:
421 # hackish
422 usecols = evaluate_callable_usecols(usecols, names)
423 if usecols is not None and len(names) != len(usecols):
424 return [name for i, name in enumerate(names) if i in usecols or name in usecols]
425 return names
426
427
428def _pa_arrays_to_ea(arrs) -> ArrayLike:
429 """
430 Build one ExtensionArray from the pending string columns returned by
431 the deferred TextReader string fast path (``defer_pa_wrap``).
432
433 The fast path emits ``large_string`` for the default string dtype and
434 ``string`` for ``dtype_backend="pyarrow"``, so the arrow type determines
435 the target ExtensionArray.
436 """
437 import pyarrow as pa
438
439 chunked = pa.chunked_array([pending.materialize() for pending in arrs])
440 if pa.types.is_large_string(chunked.type):
441 return ArrowStringArray(chunked, dtype=StringDtype(na_value=np.nan))
442 return ArrowExtensionArray(chunked)
443
444
445def _wrap_deferred_pa(values):
446 """Wrap a pending string column from the deferred fast path; pass through
447 everything else."""
448 if isinstance(values, parsers._PendingStringColumn):
449 return _pa_arrays_to_ea([values])
450 return values
451
452
453def _harmonize_empty_categories(arrs: list[ArrayLike]) -> list[ArrayLike]:
454 """
455 Give chunks that inferred no categories the others' categories dtype.
456
457 GH#56044 a chunk that is entirely NA yields empty object-dtype categories
458 where a populated chunk yields string ones, which union_categoricals
459 rejects. When every chunk is empty there is nothing to match, so an
460 all-NA column keeps its object categories.
461 """
462 populated = {arr.categories.dtype for arr in arrs if len(arr.categories)} # type: ignore[union-attr]
463 if len(populated) != 1:
464 return arrs
465 cat_dtype = populated.pop()
466 return [
467 arr
468 if len(arr.categories) # type: ignore[union-attr]
469 else arr.set_categories(arr.categories.astype(cat_dtype)) # type: ignore[union-attr]
470 for arr in arrs
471 ]
472
473
474def _concatenate_chunks(
475 chunks: list[dict[int, ArrayLike]],
476 column_names: Sequence[Hashable] | Mapping[int, Hashable],
477 warn_mixed: bool = True,
478) -> dict[Any, ArrayLike]:
479 """
480 Concatenate chunks of data read with low_memory=True.
481
482 The tricky part is handling Categoricals, where different chunks
483 may have different inferred categories.
484
485 ``column_names`` names the keys of ``chunks`` for the mixed-dtype warning,
486 so it has to be indexable by whatever those keys are: the low_memory reader
487 keys its chunks by field position and passes a mapping keyed the same way,
488 while the parallel reader keys its own by name.
489
490 ``warn_mixed=False`` suppresses the mixed-dtype warning; the parallel
491 reader gathers byte-range chunks (not low_memory row chunks), so a
492 column's cross-chunk dtype has already been reconciled by the caller.
493 """
494 names = list(chunks[0].keys())
495 warning_columns = []
496
497 result: dict[Any, ArrayLike] = {}
498 for name in names:
499 arrs = [chunk.pop(name) for chunk in chunks]
500
501 # The homogeneous case (every chunk a pending string column from the
502 # deferred fast path) combines zero-copy into a single chunked
503 # ExtensionArray; mixed cases (e.g. earlier chunks inferred numeric)
504 # wrap each pending column and fall through to the regular concat
505 # below.
506 if isinstance(arrs[0], parsers._PendingStringColumn):
507 if all(isinstance(arr, parsers._PendingStringColumn) for arr in arrs):
508 result[name] = _pa_arrays_to_ea(arrs)
509 continue
510 arrs = [_wrap_deferred_pa(arr) for arr in arrs]
511 elif any(isinstance(arr, parsers._PendingStringColumn) for arr in arrs):
512 arrs = [_wrap_deferred_pa(arr) for arr in arrs]
513
514 # Check each arr for consistent types.
515 dtypes = {a.dtype for a in arrs}
516 non_cat_dtypes = {x for x in dtypes if not isinstance(x, CategoricalDtype)}
517
518 dtype = dtypes.pop()
519 if isinstance(dtype, CategoricalDtype):
520 arrs = _harmonize_empty_categories(arrs)
521 result[name] = union_categoricals(arrs, sort_categories=False) # type: ignore[arg-type]
522 else:
523 result[name] = concat_compat(arrs)
524 if len(non_cat_dtypes) > 1 and result[name].dtype == np.dtype(object):
525 warning_columns.append((name, column_names[name]))
526
527 if warning_columns and warn_mixed:
528 warning_names = ", ".join(
529 [
530 f"{position}: {label}" if label is not None else f"{position}"
531 for position, label in warning_columns
532 ]
533 )
534 warning_message = " ".join(
535 [
536 f"Columns ({warning_names}) have mixed types. "
537 f"Specify dtype option on import or set low_memory=False."
538 ]
539 )
540 warnings.warn(warning_message, DtypeWarning, stacklevel=find_stack_level())
541 return result
542
543
544def ensure_dtype_objs(
545 dtype: DtypeArg | dict[Hashable, DtypeArg] | None,
546) -> DtypeObj | dict[Hashable, DtypeObj] | None:
547 """
548 Ensure we have either None, a dtype object, or a dictionary mapping to
549 dtype objects.
550 """
551 if isinstance(dtype, defaultdict):
552 # "None" not callable [misc]
553 default_dtype = pandas_dtype(dtype.default_factory()) # type: ignore[misc]
554 dtype_converted: defaultdict[Hashable, DtypeObj] = defaultdict(
555 lambda: default_dtype
556 )
557 for key in dtype.keys():
558 dtype_converted[key] = pandas_dtype(dtype[key])
559 return dtype_converted
560 elif isinstance(dtype, dict):
561 return {k: pandas_dtype(dtype[k]) for k in dtype}
562 elif dtype is not None:
563 return pandas_dtype(dtype)
564 return dtype