Coverage for python/lsst/daf/butler/formatters/parquet.py: 96%
582 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 01:54 -0700
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 01:54 -0700
1# This file is part of daf_butler.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This software is dual licensed under the GNU General Public License and also
10# under a 3-clause BSD license. Recipients may choose which of these licenses
11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt,
12# respectively. If you choose the GPL option then the following text applies
13# (but note that there is still no warranty even if you opt for BSD instead):
14#
15# This program is free software: you can redistribute it and/or modify
16# it under the terms of the GNU General Public License as published by
17# the Free Software Foundation, either version 3 of the License, or
18# (at your option) any later version.
19#
20# This program is distributed in the hope that it will be useful,
21# but WITHOUT ANY WARRANTY; without even the implied warranty of
22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23# GNU General Public License for more details.
24#
25# You should have received a copy of the GNU General Public License
26# along with this program. If not, see <http://www.gnu.org/licenses/>.
28from __future__ import annotations
30__all__ = (
31 "ArrowAstropySchema",
32 "ArrowNumpySchema",
33 "DataFrameSchema",
34 "ParquetFormatter",
35 "add_pandas_index_to_astropy",
36 "arrow_schema_to_pandas_index",
37 "arrow_to_astropy",
38 "arrow_to_numpy",
39 "arrow_to_numpy_dict",
40 "arrow_to_pandas",
41 "astropy_to_arrow",
42 "astropy_to_pandas",
43 "compute_row_group_size",
44 "numpy_dict_to_arrow",
45 "numpy_to_arrow",
46 "numpy_to_astropy",
47 "pandas_to_arrow",
48 "pandas_to_astropy",
49 "pandas_to_numpy",
50)
52import collections.abc
53import contextlib
54import itertools
55import json
56import logging
57import re
58from collections.abc import Generator, Iterable, Sequence
59from fnmatch import fnmatchcase
60from typing import IO, TYPE_CHECKING, Any, cast
62import pyarrow as pa
63import pyarrow.parquet as pq
65from lsst.daf.butler import DatasetProvenance, FormatterV2
66from lsst.daf.butler.delegates.arrowtable import _add_arrow_provenance, _checkArrowCompatibleType
67from lsst.resources import ResourcePath
68from lsst.utils.introspection import get_full_type_name
69from lsst.utils.iteration import ensure_iterable
71log = logging.getLogger(__name__)
73if TYPE_CHECKING:
74 import astropy.table as atable
75 import numpy as np
76 import pandas as pd
78 try:
79 import fsspec
80 from fsspec.spec import AbstractFileSystem
81 except ImportError:
82 fsspec = None
83 AbstractFileSystem = type
85TARGET_ROW_GROUP_BYTES = 1_000_000_000
86ASTROPY_PANDAS_INDEX_KEY = "lsst::arrow::astropy_pandas_index"
89@contextlib.contextmanager
90def generic_open(path: str, fs: AbstractFileSystem | None) -> Generator[IO]:
91 if fs is None:
92 with open(path, "rb") as fh:
93 yield fh
94 else:
95 with fs.open(path) as fh:
96 yield fh
99class ParquetFormatter(FormatterV2):
100 """Interface for reading and writing Arrow Table objects to and from
101 Parquet files.
102 """
104 default_extension = ".parq"
105 can_read_from_uri = True
106 can_read_from_local_file = True
108 def can_accept(self, in_memory_dataset: Any) -> bool:
109 # Docstring inherited.
110 return _checkArrowCompatibleType(in_memory_dataset) is not None
112 def read_from_uri(self, uri: ResourcePath, component: str | None = None, expected_size: int = -1) -> Any:
113 # Docstring inherited from Formatter.read.
114 try:
115 fs, path = uri.to_fsspec()
116 except ImportError:
117 log.debug("fsspec not available; falling back to local file access.")
118 # This signals to the formatter to use the read_from_local_file
119 # code path.
120 return NotImplemented
122 return self._read_parquet(path=path, fs=fs, component=component, expected_size=expected_size)
124 def read_from_local_file(self, path: str, component: str | None = None, expected_size: int = -1) -> Any:
125 # Docstring inherited from Formatter.read.
126 return self._read_parquet(path=path, component=component, expected_size=expected_size)
128 def _read_parquet(
129 self,
130 path: str,
131 fs: AbstractFileSystem | None = None,
132 component: str | None = None,
133 expected_size: int = -1,
134 ) -> Any:
135 with generic_open(path, fs) as handle:
136 schema = pq.read_schema(handle)
138 schema_names = ["ArrowSchema", "DataFrameSchema", "ArrowAstropySchema", "ArrowNumpySchema"]
140 if component in ("columns", "schema") or self.file_descriptor.readStorageClass.name in schema_names:
141 # The schema will be translated to column format
142 # depending on the input type.
143 return schema
144 elif component == "rowcount":
145 # Get the rowcount from the metadata if possible, otherwise count.
146 if b"lsst::arrow::rowcount" in schema.metadata:
147 return int(schema.metadata[b"lsst::arrow::rowcount"])
149 with generic_open(path, fs) as handle:
150 temp_table = pq.read_table(
151 handle,
152 columns=[schema.names[0]],
153 use_threads=False,
154 use_pandas_metadata=False,
155 )
157 return len(temp_table[schema.names[0]])
159 par_columns = None
160 strip_astropy_meta_yaml = True
161 if self.file_descriptor.parameters:
162 par_columns = self.file_descriptor.parameters.pop("columns", None)
163 if par_columns:
164 has_pandas_multi_index = False
165 index_columns = []
166 if schema.metadata and b"pandas" in schema.metadata:
167 md = json.loads(schema.metadata[b"pandas"])
168 if len(md["column_indexes"]) > 1:
169 has_pandas_multi_index = True
171 index_columns = _get_pandas_index_columns(md)
173 if not has_pandas_multi_index:
174 # Ensure uniqueness, keeping order.
175 par_columns_in = list(dict.fromkeys(ensure_iterable(par_columns)))
176 file_columns = [name for name in schema.names if not name.startswith("__")]
178 # Do case-sensitive glob-style matching, again ensuring
179 # uniqueness and ordering.
180 par_columns = {}
181 for par_column in par_columns_in:
182 found = False
183 for file_column in file_columns:
184 if fnmatchcase(file_column, par_column):
185 found = True
186 par_columns[file_column] = True
187 if not found:
188 if par_column not in index_columns: 188 ↛ 181line 188 didn't jump to line 181 because the condition on line 188 was always true
189 # Note that the pandas index column will always
190 # be loaded.
191 raise ValueError(
192 f"Column {par_column} specified in parameters not "
193 "available in parquet file."
194 )
195 par_columns = list(par_columns.keys())
196 else:
197 par_columns = _standardize_multi_index_columns(
198 arrow_schema_to_pandas_index(schema),
199 par_columns,
200 )
202 strip_astropy_meta_yaml = self.file_descriptor.parameters.pop(
203 "strip_astropy_meta_yaml",
204 True,
205 )
207 if len(self.file_descriptor.parameters): 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true
208 raise ValueError(
209 f"Unsupported parameters {self.file_descriptor.parameters} in ArrowTable read."
210 )
212 metadata = schema.metadata if schema.metadata is not None else {}
213 with generic_open(path, fs) as handle:
214 arrow_table = pq.read_table(
215 handle,
216 columns=par_columns,
217 use_threads=False,
218 use_pandas_metadata=(b"pandas" in metadata),
219 )
221 if strip_astropy_meta_yaml:
222 metadata = arrow_table.schema.metadata
223 # Only strip if (a) we have metadata; (b) it contains
224 # ``table_meta_yaml``; (c) it contains ``lsst::arrow::rowcount``
225 # to avoid stripping data from pure astropy tables (not written
226 # by the butler).
227 if metadata and metadata.pop(b"table_meta_yaml", None) and b"lsst::arrow::rowcount" in metadata:
228 arrow_table = arrow_table.replace_schema_metadata(metadata)
230 return arrow_table
232 def add_provenance(self, in_memory_dataset: Any, provenance: DatasetProvenance | None = None) -> Any:
233 return _add_arrow_provenance(in_memory_dataset, self.dataset_ref, provenance)
235 def write_local_file(self, in_memory_dataset: Any, uri: ResourcePath) -> None:
236 """Serialize the in memory dataset to a local parquet file.
238 Parameters
239 ----------
240 in_memory_dataset : `typing.Any`
241 The Python object to serialize.
242 uri : `lsst.resources.ResourcePath`
243 The location to write the local file.
244 """
245 if isinstance(in_memory_dataset, pa.Schema):
246 pq.write_metadata(in_memory_dataset, uri.ospath)
247 return
249 type_string = _checkArrowCompatibleType(in_memory_dataset)
251 if type_string is None:
252 raise ValueError(
253 f"Unsupported type {get_full_type_name(in_memory_dataset)} of "
254 "inMemoryDataset for ParquetFormatter."
255 )
257 if type_string == "arrow":
258 arrow_table = in_memory_dataset
259 elif type_string == "astropy":
260 arrow_table = astropy_to_arrow(in_memory_dataset)
261 elif type_string == "numpy":
262 arrow_table = numpy_to_arrow(in_memory_dataset)
263 elif type_string == "numpydict":
264 arrow_table = numpy_dict_to_arrow(in_memory_dataset)
265 else:
266 arrow_table = pandas_to_arrow(in_memory_dataset)
268 row_group_size = compute_row_group_size(arrow_table.schema)
270 pq.write_table(arrow_table, uri.ospath, row_group_size=row_group_size)
273def arrow_to_pandas(arrow_table: pa.Table) -> pd.DataFrame:
274 """Convert a pyarrow table to a pandas DataFrame.
276 Parameters
277 ----------
278 arrow_table : `pyarrow.Table`
279 Input arrow table to convert. If the table has ``pandas`` metadata
280 in the schema it will be used in the construction of the
281 ``DataFrame``.
283 Returns
284 -------
285 dataframe : `pandas.DataFrame`
286 Converted pandas dataframe.
287 """
288 dataframe = arrow_table.to_pandas(use_threads=False, integer_object_nulls=True)
290 metadata = arrow_table.schema.metadata if arrow_table.schema.metadata is not None else {}
291 if (key := ASTROPY_PANDAS_INDEX_KEY.encode()) in metadata:
292 pandas_index = metadata[key].decode("UTF8")
293 if pandas_index in arrow_table.schema.names:
294 dataframe.set_index(pandas_index, inplace=True)
295 else:
296 log.warning(
297 "Index column ``%s`` not available for arrow table conversion to DataFrame",
298 pandas_index,
299 )
301 return dataframe
304def arrow_to_astropy(arrow_table: pa.Table) -> atable.Table:
305 """Convert a pyarrow table to an `astropy.table.Table`.
307 Parameters
308 ----------
309 arrow_table : `pyarrow.Table`
310 Input arrow table to convert. If the table has astropy unit
311 metadata in the schema it will be used in the construction
312 of the ``astropy.table.Table``.
314 Returns
315 -------
316 table : `astropy.table.Table`
317 Converted astropy table.
318 """
319 from astropy.table import Table
321 astropy_table = Table(arrow_to_numpy_dict(arrow_table))
323 _apply_astropy_metadata(astropy_table, arrow_table.schema)
325 if (key := ASTROPY_PANDAS_INDEX_KEY) in astropy_table.meta:
326 if astropy_table.meta[key] not in astropy_table.columns:
327 astropy_table.meta.pop(key)
329 return astropy_table
332def arrow_to_numpy(arrow_table: pa.Table) -> np.ndarray | np.ma.MaskedArray:
333 """Convert a pyarrow table to a structured numpy array.
335 Parameters
336 ----------
337 arrow_table : `pyarrow.Table`
338 Input arrow table.
340 Returns
341 -------
342 array : `numpy.ndarray` or `numpy.ma.MaskedArray` (N,)
343 Numpy array table with N rows and the same column names
344 as the input arrow table. Will be masked records if any values
345 in the table are null.
346 """
347 import numpy as np
349 numpy_dict = arrow_to_numpy_dict(arrow_table)
351 has_mask = False
352 dtype: list[tuple] = []
353 for name, col in numpy_dict.items():
354 if len(shape := numpy_dict[name].shape) <= 1:
355 dtype.append((name, col.dtype))
356 else:
357 dtype.append((name, (col.dtype, shape[1:])))
359 if not has_mask and isinstance(col, np.ma.MaskedArray):
360 has_mask = True
362 array: Any
364 if has_mask:
365 import numpy.ma.mrecords as mrecords
367 array = mrecords.fromarrays(list(numpy_dict.values()), dtype=dtype)
368 else:
369 array = np.rec.fromarrays(numpy_dict.values(), dtype=dtype)
370 return array
373def arrow_to_numpy_dict(arrow_table: pa.Table) -> dict[str, np.ndarray]:
374 """Convert a pyarrow table to a dict of numpy arrays.
376 Parameters
377 ----------
378 arrow_table : `pyarrow.Table`
379 Input arrow table.
381 Returns
382 -------
383 numpy_dict : `dict` [`str`, `numpy.ndarray`]
384 Dict with keys as the column names, values as the arrays.
385 """
386 import numpy as np
388 schema = arrow_table.schema
389 metadata = schema.metadata if schema.metadata is not None else {}
391 numpy_dict = {}
393 for name in schema.names:
394 t = schema.field(name).type
396 if arrow_table[name].null_count == 0:
397 # Regular non-masked column
398 col = arrow_table[name].to_numpy()
399 else:
400 # For a masked column, we need to ask arrow to fill the null
401 # values with an appropriately typed value before conversion.
402 # Then we apply the mask to get a masked array of the correct type.
403 null_value: Any
404 match t:
405 case t if t in (pa.float64(), pa.float32(), pa.float16()):
406 null_value = np.nan
407 case t if t in (pa.int64(), pa.int32(), pa.int16(), pa.int8()):
408 null_value = -1
409 case t if t in (pa.bool_(),):
410 null_value = True
411 case t if _is_string(t) or _is_binary(t):
412 null_value = ""
413 case _:
414 # This is the fallback for unsigned ints in particular.
415 null_value = 0
417 col = np.ma.MaskedArray(
418 data=arrow_table[name].fill_null(null_value).to_numpy(),
419 mask=arrow_table[name].is_null().to_numpy(),
420 fill_value=null_value,
421 )
423 if _is_string(t) or _is_binary(t):
424 col = col.astype(_arrow_string_to_numpy_dtype(schema, name, col))
425 elif isinstance(t, pa.FixedSizeListType):
426 if len(col) > 0:
427 col = np.stack(col)
428 else:
429 # this is an empty column, and needs to be coerced to type.
430 col = col.astype(t.value_type.to_pandas_dtype())
432 shape = _multidim_shape_from_metadata(metadata, t.list_size, name)
433 col = col.reshape((len(arrow_table), *shape))
435 numpy_dict[name] = col
437 return numpy_dict
440def _numpy_dict_to_numpy(numpy_dict: dict[str, np.ndarray]) -> np.ndarray:
441 """Convert a dict of numpy arrays to a structured numpy array.
443 Parameters
444 ----------
445 numpy_dict : `dict` [`str`, `numpy.ndarray`]
446 Dict with keys as the column names, values as the arrays.
448 Returns
449 -------
450 array : `numpy.ndarray` (N,)
451 Numpy array table with N rows and columns names from the dict keys.
452 """
453 return arrow_to_numpy(numpy_dict_to_arrow(numpy_dict))
456def _numpy_to_numpy_dict(np_array: np.ndarray) -> dict[str, np.ndarray]:
457 """Convert a structured numpy array to a dict of numpy arrays.
459 Parameters
460 ----------
461 np_array : `numpy.ndarray`
462 Input numpy array with multiple fields.
464 Returns
465 -------
466 numpy_dict : `dict` [`str`, `numpy.ndarray`]
467 Dict with keys as the column names, values as the arrays.
468 """
469 return arrow_to_numpy_dict(numpy_to_arrow(np_array))
472def numpy_to_arrow(np_array: np.ndarray) -> pa.Table:
473 """Convert a numpy array table to an arrow table.
475 Parameters
476 ----------
477 np_array : `numpy.ndarray`
478 Input numpy array with multiple fields.
480 Returns
481 -------
482 arrow_table : `pyarrow.Table`
483 Converted arrow table.
484 """
485 type_list = _numpy_dtype_to_arrow_types(np_array.dtype)
487 md = {}
488 md[b"lsst::arrow::rowcount"] = str(len(np_array))
490 names = np_array.dtype.names
491 if names is None: 491 ↛ 492line 491 didn't jump to line 492 because the condition on line 491 was never true
492 names = ()
494 for name in names:
495 _append_numpy_string_metadata(md, name, np_array.dtype[name])
496 _append_numpy_multidim_metadata(md, name, np_array.dtype[name])
498 schema = pa.schema(type_list, metadata=md)
500 arrays = _numpy_style_arrays_to_arrow_arrays(
501 np_array.dtype,
502 len(np_array),
503 np_array,
504 schema,
505 )
507 arrow_table = pa.Table.from_arrays(arrays, schema=schema)
509 return arrow_table
512def numpy_dict_to_arrow(numpy_dict: dict[str, np.ndarray]) -> pa.Table:
513 """Convert a dict of numpy arrays to an arrow table.
515 Parameters
516 ----------
517 numpy_dict : `dict` [`str`, `numpy.ndarray`]
518 Dict with keys as the column names, values as the arrays.
520 Returns
521 -------
522 arrow_table : `pyarrow.Table`
523 Converted arrow table.
525 Raises
526 ------
527 ValueError
528 Raised if columns in ``numpy_dict`` have unequal numbers of
529 rows.
530 """
531 dtype, rowcount = _numpy_dict_to_dtype(numpy_dict)
532 type_list = _numpy_dtype_to_arrow_types(dtype)
534 md = {}
535 md[b"lsst::arrow::rowcount"] = str(rowcount)
537 if dtype.names is not None: 537 ↛ 542line 537 didn't jump to line 542 because the condition on line 537 was always true
538 for name in dtype.names:
539 _append_numpy_string_metadata(md, name, dtype[name])
540 _append_numpy_multidim_metadata(md, name, dtype[name])
542 schema = pa.schema(type_list, metadata=md)
544 arrays = _numpy_style_arrays_to_arrow_arrays(
545 dtype,
546 rowcount,
547 numpy_dict,
548 schema,
549 )
551 arrow_table = pa.Table.from_arrays(arrays, schema=schema)
553 return arrow_table
556def astropy_to_arrow(astropy_table: atable.Table) -> pa.Table:
557 """Convert an astropy table to an arrow table.
559 Parameters
560 ----------
561 astropy_table : `astropy.table.Table`
562 Input astropy table.
564 Returns
565 -------
566 arrow_table : `pyarrow.Table`
567 Converted arrow table.
568 """
569 from astropy.table import meta
571 type_list = _numpy_dtype_to_arrow_types(astropy_table.dtype)
573 md = {}
574 md[b"lsst::arrow::rowcount"] = str(len(astropy_table))
576 if (key := ASTROPY_PANDAS_INDEX_KEY) in astropy_table.meta:
577 md[key.encode()] = astropy_table.meta[key]
579 for name in astropy_table.dtype.names:
580 _append_numpy_string_metadata(md, name, astropy_table.dtype[name])
581 _append_numpy_multidim_metadata(md, name, astropy_table.dtype[name])
583 meta_yaml = meta.get_yaml_from_table(astropy_table)
584 meta_yaml_str = "\n".join(meta_yaml)
585 md[b"table_meta_yaml"] = meta_yaml_str
587 # Convert type list to fields with metadata.
588 fields = []
589 for name, pa_type in type_list:
590 field_metadata = {}
591 if description := astropy_table[name].description:
592 field_metadata["description"] = description
593 if unit := astropy_table[name].unit:
594 field_metadata["unit"] = str(unit)
595 fields.append(
596 pa.field(
597 name,
598 pa_type,
599 metadata=field_metadata,
600 )
601 )
603 schema = pa.schema(fields, metadata=md)
605 arrays = _numpy_style_arrays_to_arrow_arrays(
606 astropy_table.dtype,
607 len(astropy_table),
608 astropy_table,
609 schema,
610 )
612 arrow_table = pa.Table.from_arrays(arrays, schema=schema)
614 return arrow_table
617def astropy_to_pandas(astropy_table: atable.Table, index: str | None = None) -> pd.DataFrame:
618 """Convert an astropy table to a pandas dataframe via arrow.
620 By going via arrow we avoid pandas masked column bugs (e.g.
621 https://github.com/pandas-dev/pandas/issues/58173)
623 Parameters
624 ----------
625 astropy_table : `astropy.table.Table`
626 Input astropy table.
627 index : `str`, optional
628 Name of column to set as index.
630 Returns
631 -------
632 dataframe : `pandas.DataFrame`
633 Output pandas dataframe.
634 """
635 index_requested = False
636 if (key := ASTROPY_PANDAS_INDEX_KEY) in astropy_table.meta:
637 _index = astropy_table.meta[key]
638 if _index not in astropy_table.columns:
639 log.warning(
640 "Index column ``%s`` not available for astropy table conversion to DataFrame",
641 _index,
642 )
643 _index = None
644 else:
645 index_requested = True
646 _index = index
648 dataframe = arrow_to_pandas(astropy_to_arrow(astropy_table))
650 # Set the index if we have a valid index name, and either the
651 # index was requested in the call to the function or the dataframe
652 # was not previously indexed with the call to arrow_to_pandas.
653 if isinstance(_index, str) and (index_requested or dataframe.index.name is None):
654 dataframe.set_index(_index, inplace=True)
655 elif _index and index_requested: 655 ↛ 656line 655 didn't jump to line 656 because the condition on line 655 was never true
656 raise RuntimeError("index must be a string or None.")
658 return dataframe
661def add_pandas_index_to_astropy(astropy_table: atable.Table, index: str) -> None:
662 """Add special metadata to an astropy table to indicate a pandas index.
664 Parameters
665 ----------
666 astropy_table : `astropy.table.Table`
667 Input astropy table.
668 index : `str`
669 Name of column for pandas to set as index, if read as DataFrame.
670 """
671 if index not in astropy_table.columns:
672 raise ValueError("Column ``%s`` not in astropy table columns to use as pandas index.", index)
673 astropy_table.meta[ASTROPY_PANDAS_INDEX_KEY] = index
676def _astropy_to_numpy_dict(astropy_table: atable.Table) -> dict[str, np.ndarray]:
677 """Convert an astropy table to an arrow table.
679 Parameters
680 ----------
681 astropy_table : `astropy.table.Table`
682 Input astropy table.
684 Returns
685 -------
686 numpy_dict : `dict` [`str`, `numpy.ndarray`]
687 Dict with keys as the column names, values as the arrays.
688 """
689 return arrow_to_numpy_dict(astropy_to_arrow(astropy_table))
692def pandas_to_arrow(dataframe: pd.DataFrame, default_length: int = 10) -> pa.Table:
693 """Convert a pandas dataframe to an arrow table.
695 Parameters
696 ----------
697 dataframe : `pandas.DataFrame`
698 Input pandas dataframe.
699 default_length : `int`, optional
700 Default string length when not in metadata or can be inferred
701 from column.
703 Returns
704 -------
705 arrow_table : `pyarrow.Table`
706 Converted arrow table.
707 """
708 try:
709 arrow_table = pa.Table.from_pandas(dataframe, preserve_index=True)
710 except pa.ArrowInvalid as e:
711 msg = "; ".join(e.args)
712 msg += "; This is usually because the column is mixed type or has uneven length rows."
713 e.add_note(msg)
714 raise
716 # Update the metadata
717 md = arrow_table.schema.metadata
719 md[b"lsst::arrow::rowcount"] = str(arrow_table.num_rows)
721 # We loop through the arrow table columns because the datatypes have
722 # been checked and converted from pandas objects.
723 for name in arrow_table.column_names:
724 if not name.startswith("__") and _is_string(arrow_table[name].type):
725 col = arrow_table[name]
726 if len(col) > 0 and (col.length() > col.null_count): 726 ↛ 729line 726 didn't jump to line 729 because the condition on line 726 was always true
727 strlen = max(len(row.as_py()) for row in arrow_table[name] if row.is_valid)
728 else:
729 strlen = default_length
730 md[f"lsst::arrow::len::{name}".encode()] = str(strlen)
732 arrow_table = arrow_table.replace_schema_metadata(md)
734 return arrow_table
737def pandas_to_astropy(dataframe: pd.DataFrame) -> atable.Table:
738 """Convert a pandas dataframe to an astropy table, preserving indexes.
740 Parameters
741 ----------
742 dataframe : `pandas.DataFrame`
743 Input pandas dataframe.
745 Returns
746 -------
747 astropy_table : `astropy.table.Table`
748 Converted astropy table.
749 """
750 import pandas as pd
752 if isinstance(dataframe.columns, pd.MultiIndex):
753 raise ValueError("Cannot convert a multi-index dataframe to an astropy table.")
755 return arrow_to_astropy(pandas_to_arrow(dataframe))
758def pandas_to_numpy(dataframe: pd.DataFrame) -> np.ndarray | np.ma.MaskedArray:
759 """Convert a pandas dataframe to a numpy recarray.
761 Parameters
762 ----------
763 dataframe : `pandas.DataFrame`
764 Input pandas dataframe.
766 Returns
767 -------
768 array : `numpy.ndarray` or `numpy.ma.MaskedArray` (N,)
769 Numpy array table with N rows and the same column names
770 as the input dataframe. Will be masked records if any values
771 in the table are null.
772 """
773 # This conversion ensures strings are handled properly.
774 return arrow_to_numpy(pandas_to_arrow(dataframe))
777def _pandas_to_numpy_dict(dataframe: pd.DataFrame) -> dict[str, np.ndarray]:
778 """Convert a pandas dataframe to an dict of numpy arrays.
780 Parameters
781 ----------
782 dataframe : `pandas.DataFrame`
783 Input pandas dataframe.
785 Returns
786 -------
787 numpy_dict : `dict` [`str`, `numpy.ndarray`]
788 Dict with keys as the column names, values as the arrays.
789 """
790 return arrow_to_numpy_dict(pandas_to_arrow(dataframe))
793def numpy_to_astropy(np_array: np.ndarray) -> atable.Table:
794 """Convert a numpy table to an astropy table.
796 Parameters
797 ----------
798 np_array : `numpy.ndarray`
799 Input numpy array with multiple fields.
801 Returns
802 -------
803 astropy_table : `astropy.table.Table`
804 Converted astropy table.
805 """
806 from astropy.table import Table
808 return Table(data=np_array, copy=False)
811def arrow_schema_to_pandas_index(schema: pa.Schema) -> pd.Index | pd.MultiIndex:
812 """Convert an arrow schema to a pandas index/multiindex.
814 Parameters
815 ----------
816 schema : `pyarrow.Schema`
817 Input pyarrow schema.
819 Returns
820 -------
821 index : `pandas.Index` or `pandas.MultiIndex`
822 Converted pandas index.
823 """
824 import pandas as pd
826 index_columns = []
827 if b"pandas" in schema.metadata:
828 md = json.loads(schema.metadata[b"pandas"])
829 indexes = md["column_indexes"]
830 len_indexes = len(indexes)
832 if len_indexes > 0: 832 ↛ 837line 832 didn't jump to line 837 because the condition on line 832 was always true
833 index_columns = _get_pandas_index_columns(md)
834 else:
835 len_indexes = 0
837 if len_indexes <= 1:
838 columns = {name for name in schema.names if not name.startswith("__")}
839 columns = columns.union(set(index_columns))
840 return pd.Index(columns)
841 else:
842 raw_columns = _split_multi_index_column_names(len(indexes), schema.names)
843 return pd.MultiIndex.from_tuples(raw_columns, names=[f["name"] for f in indexes])
846def arrow_schema_to_column_list(schema: pa.Schema) -> list[str]:
847 """Convert an arrow schema to a list of string column names.
849 Parameters
850 ----------
851 schema : `pyarrow.Schema`
852 Input pyarrow schema.
854 Returns
855 -------
856 column_list : `list` [`str`]
857 Converted list of column names.
858 """
859 return list(schema.names)
862class DataFrameSchema:
863 """Wrapper class for a schema for a pandas DataFrame.
865 Parameters
866 ----------
867 dataframe : `pandas.DataFrame`
868 Dataframe to turn into a schema.
869 """
871 def __init__(self, dataframe: pd.DataFrame) -> None:
872 self._schema = dataframe.loc[[False] * len(dataframe)]
874 @classmethod
875 def from_arrow(cls, schema: pa.Schema) -> DataFrameSchema:
876 """Convert an arrow schema into a `DataFrameSchema`.
878 Parameters
879 ----------
880 schema : `pyarrow.Schema`
881 The pyarrow schema to convert.
883 Returns
884 -------
885 dataframe_schema : `DataFrameSchema`
886 Converted dataframe schema.
887 """
888 empty_table = pa.Table.from_pylist([] * len(schema.names), schema=schema)
890 return cls(empty_table.to_pandas())
892 def to_arrow_schema(self) -> pa.Schema:
893 """Convert to an arrow schema.
895 Returns
896 -------
897 arrow_schema : `pyarrow.Schema`
898 Converted pyarrow schema.
899 """
900 arrow_table = pa.Table.from_pandas(self._schema, preserve_index=True)
902 return arrow_table.schema
904 def to_arrow_numpy_schema(self) -> ArrowNumpySchema:
905 """Convert to an `ArrowNumpySchema`.
907 Returns
908 -------
909 arrow_numpy_schema : `ArrowNumpySchema`
910 Converted arrow numpy schema.
911 """
912 return ArrowNumpySchema.from_arrow(self.to_arrow_schema())
914 def to_arrow_astropy_schema(self) -> ArrowAstropySchema:
915 """Convert to an ArrowAstropySchema.
917 Returns
918 -------
919 arrow_astropy_schema : `ArrowAstropySchema`
920 Converted arrow astropy schema.
921 """
922 return ArrowAstropySchema.from_arrow(self.to_arrow_schema())
924 @property
925 def schema(self) -> np.dtype:
926 return self._schema
928 def __repr__(self) -> str:
929 return repr(self._schema)
931 def __eq__(self, other: object) -> bool:
932 if not isinstance(other, DataFrameSchema):
933 return NotImplemented
935 return self._schema.equals(other._schema)
938class ArrowAstropySchema:
939 """Wrapper class for a schema for an astropy table.
941 Parameters
942 ----------
943 astropy_table : `astropy.table.Table`
944 Input astropy table.
945 """
947 def __init__(self, astropy_table: atable.Table) -> None:
948 self._schema = astropy_table[:0]
950 @classmethod
951 def from_arrow(cls, schema: pa.Schema) -> ArrowAstropySchema:
952 """Convert an arrow schema into a ArrowAstropySchema.
954 Parameters
955 ----------
956 schema : `pyarrow.Schema`
957 Input pyarrow schema.
959 Returns
960 -------
961 astropy_schema : `ArrowAstropySchema`
962 Converted arrow astropy schema.
963 """
964 import numpy as np
965 from astropy.table import Table
967 dtype = _schema_to_dtype_list(schema)
969 data = np.zeros(0, dtype=dtype)
970 astropy_table = Table(data=data)
972 _apply_astropy_metadata(astropy_table, schema)
974 return cls(astropy_table)
976 def to_arrow_schema(self) -> pa.Schema:
977 """Convert to an arrow schema.
979 Returns
980 -------
981 arrow_schema : `pyarrow.Schema`
982 Converted pyarrow schema.
983 """
984 return astropy_to_arrow(self._schema).schema
986 def to_dataframe_schema(self) -> DataFrameSchema:
987 """Convert to a DataFrameSchema.
989 Returns
990 -------
991 dataframe_schema : `DataFrameSchema`
992 Converted dataframe schema.
993 """
994 return DataFrameSchema.from_arrow(astropy_to_arrow(self._schema).schema)
996 def to_arrow_numpy_schema(self) -> ArrowNumpySchema:
997 """Convert to an `ArrowNumpySchema`.
999 Returns
1000 -------
1001 arrow_numpy_schema : `ArrowNumpySchema`
1002 Converted arrow numpy schema.
1003 """
1004 return ArrowNumpySchema.from_arrow(astropy_to_arrow(self._schema).schema)
1006 @property
1007 def schema(self) -> atable.Table:
1008 return self._schema
1010 def __repr__(self) -> str:
1011 return repr(self._schema)
1013 def __eq__(self, other: object) -> bool:
1014 if not isinstance(other, ArrowAstropySchema):
1015 return NotImplemented
1017 # If this comparison passes then the two tables have the
1018 # same column names.
1019 if self._schema.dtype != other._schema.dtype:
1020 return False
1022 for name in self._schema.columns:
1023 if not self._schema[name].unit == other._schema[name].unit:
1024 return False
1025 if not self._schema[name].description == other._schema[name].description:
1026 return False
1027 if not self._schema[name].format == other._schema[name].format:
1028 return False
1030 return True
1033class ArrowNumpySchema:
1034 """Wrapper class for a schema for a numpy ndarray.
1036 Parameters
1037 ----------
1038 numpy_dtype : `numpy.dtype`
1039 Numpy dtype to convert.
1040 """
1042 def __init__(self, numpy_dtype: np.dtype) -> None:
1043 self._dtype = numpy_dtype
1045 @classmethod
1046 def from_arrow(cls, schema: pa.Schema) -> ArrowNumpySchema:
1047 """Convert an arrow schema into an `ArrowNumpySchema`.
1049 Parameters
1050 ----------
1051 schema : `pyarrow.Schema`
1052 Pyarrow schema to convert.
1054 Returns
1055 -------
1056 numpy_schema : `ArrowNumpySchema`
1057 Converted arrow numpy schema.
1058 """
1059 import numpy as np
1061 dtype = _schema_to_dtype_list(schema)
1063 return cls(np.dtype(dtype))
1065 def to_arrow_astropy_schema(self) -> ArrowAstropySchema:
1066 """Convert to an `ArrowAstropySchema`.
1068 Returns
1069 -------
1070 astropy_schema : `ArrowAstropySchema`
1071 Converted arrow astropy schema.
1072 """
1073 import numpy as np
1075 return ArrowAstropySchema.from_arrow(numpy_to_arrow(np.zeros(0, dtype=self._dtype)).schema)
1077 def to_dataframe_schema(self) -> DataFrameSchema:
1078 """Convert to a `DataFrameSchema`.
1080 Returns
1081 -------
1082 dataframe_schema : `DataFrameSchema`
1083 Converted dataframe schema.
1084 """
1085 import numpy as np
1087 return DataFrameSchema.from_arrow(numpy_to_arrow(np.zeros(0, dtype=self._dtype)).schema)
1089 def to_arrow_schema(self) -> pa.Schema:
1090 """Convert to a `pyarrow.Schema`.
1092 Returns
1093 -------
1094 arrow_schema : `pyarrow.Schema`
1095 Converted pyarrow schema.
1096 """
1097 import numpy as np
1099 return numpy_to_arrow(np.zeros(0, dtype=self._dtype)).schema
1101 @property
1102 def schema(self) -> np.dtype:
1103 return self._dtype
1105 def __repr__(self) -> str:
1106 return repr(self._dtype)
1108 def __eq__(self, other: object) -> bool:
1109 if not isinstance(other, ArrowNumpySchema):
1110 return NotImplemented
1112 if not self._dtype == other._dtype:
1113 return False
1115 return True
1118def _split_multi_index_column_names(n: int, names: Iterable[str]) -> list[Sequence[str]]:
1119 """Split a string that represents a multi-index column.
1121 PyArrow maps Pandas' multi-index column names (which are tuples in Python)
1122 to flat strings on disk. This routine exists to reconstruct the original
1123 tuple.
1125 Parameters
1126 ----------
1127 n : `int`
1128 Number of levels in the `pandas.MultiIndex` that is being
1129 reconstructed.
1130 names : `~collections.abc.Iterable` [`str`]
1131 Strings to be split.
1133 Returns
1134 -------
1135 column_names : `list` [`tuple` [`str`]]
1136 A list of multi-index column name tuples.
1137 """
1138 column_names: list[Sequence[str]] = []
1140 pattern = re.compile(r"\({}\)".format(", ".join(["'(.*)'"] * n)))
1141 for name in names:
1142 m = re.search(pattern, name)
1143 if m is not None:
1144 column_names.append(m.groups())
1146 return column_names
1149def _standardize_multi_index_columns(
1150 pd_index: pd.MultiIndex,
1151 columns: Any,
1152 stringify: bool = True,
1153) -> list[str | Sequence[Any]]:
1154 """Transform a dictionary/iterable index from a multi-index column list
1155 into a string directly understandable by PyArrow.
1157 Parameters
1158 ----------
1159 pd_index : `pandas.MultiIndex`
1160 Pandas multi-index.
1161 columns : `list` [`tuple`] or `dict` [`str`, `str` or `list` [`str`]]
1162 Columns to standardize.
1163 stringify : `bool`, optional
1164 Should the column names be stringified?
1166 Returns
1167 -------
1168 names : `list` [`str`]
1169 Stringified representation of a multi-index column name.
1170 """
1171 index_level_names = tuple(pd_index.names)
1173 names: list[str | Sequence[Any]] = []
1175 if isinstance(columns, list):
1176 for requested in columns:
1177 if not isinstance(requested, tuple):
1178 raise ValueError(
1179 "Columns parameter for multi-index data frame must be a dictionary or list of tuples. "
1180 f"Instead got a {get_full_type_name(requested)}."
1181 )
1182 if stringify:
1183 names.append(str(requested))
1184 else:
1185 names.append(requested)
1186 else:
1187 if not isinstance(columns, collections.abc.Mapping): 1187 ↛ 1188line 1187 didn't jump to line 1188 because the condition on line 1187 was never true
1188 raise ValueError(
1189 "Columns parameter for multi-index data frame must be a dictionary or list of tuples. "
1190 f"Instead got a {get_full_type_name(columns)}."
1191 )
1192 if not set(index_level_names).issuperset(columns.keys()): 1192 ↛ 1193line 1192 didn't jump to line 1193 because the condition on line 1192 was never true
1193 raise ValueError(
1194 f"Cannot use dict with keys {set(columns.keys())} to select columns from {index_level_names}."
1195 )
1196 factors = [
1197 ensure_iterable(columns.get(level, pd_index.levels[i]))
1198 for i, level in enumerate(index_level_names)
1199 ]
1200 for requested in itertools.product(*factors):
1201 for i, value in enumerate(requested):
1202 if value not in pd_index.levels[i]: 1202 ↛ 1203line 1202 didn't jump to line 1203 because the condition on line 1202 was never true
1203 raise ValueError(f"Unrecognized value {value!r} for index {index_level_names[i]!r}.")
1204 if stringify:
1205 names.append(str(requested))
1206 else:
1207 names.append(requested)
1209 return names
1212def _apply_astropy_metadata(astropy_table: atable.Table, arrow_schema: pa.Schema) -> None:
1213 """Apply any astropy metadata from the schema metadata.
1215 Parameters
1216 ----------
1217 astropy_table : `astropy.table.Table`
1218 Table to apply metadata.
1219 arrow_schema : `pyarrow.Schema`
1220 Arrow schema with metadata.
1221 """
1222 from astropy.table import meta
1224 metadata = arrow_schema.metadata if arrow_schema.metadata is not None else {}
1226 # Check if we have a special astropy metadata header yaml.
1227 meta_yaml = metadata.get(b"table_meta_yaml", None)
1228 if meta_yaml:
1229 meta_yaml = meta_yaml.decode("UTF8").split("\n")
1230 meta_hdr = meta.get_header_from_yaml(meta_yaml)
1232 # Set description, format, unit, meta from the column
1233 # metadata that was serialized with the table.
1234 header_cols = {x["name"]: x for x in meta_hdr["datatype"]}
1235 for col in astropy_table.columns.values():
1236 for attr in ("description", "format", "unit", "meta"):
1237 if attr in header_cols[col.name]:
1238 setattr(col, attr, header_cols[col.name][attr])
1240 if "meta" in meta_hdr:
1241 astropy_table.meta.update(meta_hdr["meta"])
1242 else:
1243 # If we don't have astropy header data, we may have arrow field
1244 # metadata.
1245 for name in arrow_schema.names:
1246 field_metadata = arrow_schema.field(name).metadata
1247 if field_metadata is None:
1248 continue
1249 if (
1250 b"description" in field_metadata
1251 and (description := field_metadata[b"description"].decode("UTF-8")) != ""
1252 ):
1253 astropy_table[name].description = description
1254 if b"unit" in field_metadata and (unit := field_metadata[b"unit"].decode("UTF-8")) != "":
1255 astropy_table[name].unit = unit
1257 # Ensure that the special ASTROPY_PANDAS_INDEX_KEY is propagated to
1258 # the table metadata.
1259 if index_key := metadata.get(ASTROPY_PANDAS_INDEX_KEY.encode(), None):
1260 astropy_table.meta[ASTROPY_PANDAS_INDEX_KEY] = index_key.decode("UTF-8")
1263def _arrow_string_to_numpy_dtype(
1264 schema: pa.Schema, name: str, numpy_column: np.ndarray | None = None, default_length: int = 10
1265) -> str:
1266 """Get the numpy dtype string associated with an arrow column.
1268 Parameters
1269 ----------
1270 schema : `pyarrow.Schema`
1271 Arrow table schema.
1272 name : `str`
1273 Column name.
1274 numpy_column : `numpy.ndarray`, optional
1275 Column to determine numpy string dtype.
1276 default_length : `int`, optional
1277 Default string length when not in metadata or can be inferred
1278 from column.
1280 Returns
1281 -------
1282 dtype_str : `str`
1283 Numpy dtype string.
1284 """
1285 # Special-case for string and binary columns
1286 md_name = f"lsst::arrow::len::{name}"
1287 strlen = default_length
1288 metadata = schema.metadata if schema.metadata is not None else {}
1289 if (encoded := md_name.encode("UTF-8")) in metadata:
1290 # String/bytes length from header.
1291 strlen = int(schema.metadata[encoded])
1292 elif numpy_column is not None and len(numpy_column) > 0:
1293 lengths = [len(row) for row in numpy_column if row]
1294 strlen = max(lengths) if lengths else 0
1296 dtype = f"U{strlen}" if _is_string(schema.field(name).type) else f"|S{strlen}"
1298 return dtype
1301def _append_numpy_string_metadata(metadata: dict[bytes, str], name: str, dtype: np.dtype) -> None:
1302 """Append numpy string length keys to arrow metadata.
1304 All column types are handled, but the metadata is only modified for
1305 string and byte columns.
1307 Parameters
1308 ----------
1309 metadata : `dict` [`bytes`, `str`]
1310 Metadata dictionary; modified in place.
1311 name : `str`
1312 Column name.
1313 dtype : `np.dtype`
1314 Numpy dtype.
1315 """
1316 import numpy as np
1318 if dtype.type is np.str_:
1319 metadata[f"lsst::arrow::len::{name}".encode()] = str(dtype.itemsize // 4)
1320 metadata[f"table::len::{name}".encode()] = str(dtype.itemsize // 4)
1321 elif dtype.type is np.bytes_:
1322 metadata[f"lsst::arrow::len::{name}".encode()] = str(dtype.itemsize)
1323 metadata[f"table::len::{name}".encode()] = str(dtype.itemsize)
1326def _append_numpy_multidim_metadata(metadata: dict[bytes, str], name: str, dtype: np.dtype) -> None:
1327 """Append numpy multi-dimensional shapes to arrow metadata.
1329 All column types are handled, but the metadata is only modified for
1330 multi-dimensional columns.
1332 Parameters
1333 ----------
1334 metadata : `dict` [`bytes`, `str`]
1335 Metadata dictionary; modified in place.
1336 name : `str`
1337 Column name.
1338 dtype : `np.dtype`
1339 Numpy dtype.
1340 """
1341 if len(dtype.shape) > 1:
1342 metadata[f"lsst::arrow::shape::{name}".encode()] = str(dtype.shape)
1345def _multidim_shape_from_metadata(metadata: dict[bytes, bytes], list_size: int, name: str) -> tuple[int, ...]:
1346 """Retrieve the shape from the metadata, if available.
1348 Parameters
1349 ----------
1350 metadata : `dict` [`bytes`, `bytes`]
1351 Metadata dictionary.
1352 list_size : `int`
1353 Size of the list datatype.
1354 name : `str`
1355 Column name.
1357 Returns
1358 -------
1359 shape : `tuple` [`int`]
1360 Shape associated with the column.
1362 Raises
1363 ------
1364 RuntimeError
1365 Raised if metadata is found but has incorrect format.
1366 """
1367 md_name = f"lsst::arrow::shape::{name}"
1368 if (encoded := md_name.encode("UTF-8")) in metadata:
1369 groups = re.search(r"\((.*)\)", metadata[encoded].decode("UTF-8"))
1370 if groups is None: 1370 ↛ 1371line 1370 didn't jump to line 1371 because the condition on line 1370 was never true
1371 raise RuntimeError("Illegal value found in metadata.")
1372 shape = tuple(int(x) for x in groups[1].split(",") if x != "")
1373 else:
1374 shape = (list_size,)
1376 return shape
1379def _schema_to_dtype_list(schema: pa.Schema) -> list[tuple[str, tuple[Any] | str]]:
1380 """Convert a pyarrow schema to a numpy dtype.
1382 Parameters
1383 ----------
1384 schema : `pyarrow.Schema`
1385 Input pyarrow schema.
1387 Returns
1388 -------
1389 dtype_list: `list` [`tuple`]
1390 A list with name, type pairs.
1391 """
1392 metadata = schema.metadata if schema.metadata is not None else {}
1394 dtype: list[Any] = []
1395 for name in schema.names:
1396 t = schema.field(name).type
1397 if isinstance(t, pa.FixedSizeListType):
1398 shape = _multidim_shape_from_metadata(metadata, t.list_size, name)
1399 dtype.append((name, (t.value_type.to_pandas_dtype(), shape)))
1400 elif not (_is_string(t) or _is_binary(t)):
1401 dtype.append((name, t.to_pandas_dtype()))
1402 else:
1403 dtype.append((name, _arrow_string_to_numpy_dtype(schema, name)))
1405 return dtype
1408def _numpy_dtype_to_arrow_types(dtype: np.dtype) -> list[Any]:
1409 """Convert a numpy dtype to a list of arrow types.
1411 Parameters
1412 ----------
1413 dtype : `numpy.dtype`
1414 Numpy dtype to convert.
1416 Returns
1417 -------
1418 type_list : `list` [`object`]
1419 Converted list of arrow types.
1420 """
1421 from math import prod
1423 import numpy as np
1425 type_list: list[Any] = []
1426 if dtype.names is None: 1426 ↛ 1427line 1426 didn't jump to line 1427 because the condition on line 1426 was never true
1427 return type_list
1429 for name in dtype.names:
1430 dt = dtype[name]
1431 arrow_type: Any
1432 if len(dt.shape) > 0:
1433 arrow_type = pa.list_(
1434 pa.from_numpy_dtype(cast(tuple[np.dtype, tuple[int, ...]], dt.subdtype)[0].type),
1435 prod(dt.shape),
1436 )
1437 elif dt.type == np.datetime64:
1438 time_unit = "ns" if "ns" in dt.str else "us"
1439 # The pa.timestamp() is the correct datatype to round-trip
1440 # a numpy datetime64[ns] or datetime[us] array.
1441 arrow_type = pa.timestamp(time_unit)
1442 else:
1443 try:
1444 arrow_type = pa.from_numpy_dtype(dt.type)
1445 except pa.ArrowNotImplementedError as e:
1446 msg = f"Could not serialize column {name} (type {str(dt)}) to Parquet."
1447 if dt == np.dtype("O"): 1447 ↛ 1449line 1447 didn't jump to line 1449 because the condition on line 1447 was always true
1448 msg += " This is usually because the column is mixed type or has uneven length rows."
1449 e.add_note(msg)
1450 raise
1451 type_list.append((name, arrow_type))
1453 return type_list
1456def _numpy_dict_to_dtype(numpy_dict: dict[str, np.ndarray]) -> tuple[np.dtype, int]:
1457 """Extract equivalent table dtype from dict of numpy arrays.
1459 Parameters
1460 ----------
1461 numpy_dict : `dict` [`str`, `numpy.ndarray`]
1462 Dict with keys as the column names, values as the arrays.
1464 Returns
1465 -------
1466 dtype : `numpy.dtype`
1467 dtype of equivalent table.
1468 rowcount : `int`
1469 Number of rows in the table.
1471 Raises
1472 ------
1473 ValueError if columns in numpy_dict have unequal numbers of rows.
1474 """
1475 import numpy as np
1477 dtype_list: list[tuple] = []
1478 rowcount = 0
1479 for name, col in numpy_dict.items():
1480 if rowcount == 0:
1481 rowcount = len(col)
1482 if len(col) != rowcount:
1483 raise ValueError(f"Column {name} has a different number of rows.")
1484 if len(col.shape) == 1:
1485 dtype_list.append((name, col.dtype))
1486 else:
1487 dtype_list.append((name, (col.dtype, col.shape[1:])))
1488 dtype = np.dtype(dtype_list)
1490 return (dtype, rowcount)
1493def _numpy_style_arrays_to_arrow_arrays(
1494 dtype: np.dtype,
1495 rowcount: int,
1496 np_style_arrays: dict[str, np.ndarray] | np.ndarray | atable.Table,
1497 schema: pa.Schema,
1498) -> list[pa.Array]:
1499 """Convert numpy-style arrays to arrow arrays.
1501 Parameters
1502 ----------
1503 dtype : `numpy.dtype`
1504 Numpy dtype of input table/arrays.
1505 rowcount : `int`
1506 Number of rows in input table/arrays.
1507 np_style_arrays : `dict` [`str`, `np.ndarray`] or `np.ndarray`
1508 or `astropy.table.Table`
1509 Arrays to convert to arrow.
1510 schema : `pyarrow.Schema`
1511 Schema of arrow table.
1513 Returns
1514 -------
1515 arrow_arrays : `list` [`pyarrow.Array`]
1516 List of converted pyarrow arrays.
1517 """
1518 import numpy as np
1520 arrow_arrays: list[pa.Array] = []
1521 if dtype.names is None: 1521 ↛ 1522line 1521 didn't jump to line 1522 because the condition on line 1521 was never true
1522 return arrow_arrays
1524 for name in dtype.names:
1525 dt = dtype[name]
1526 val: Any
1527 if len(dt.shape) > 0:
1528 if rowcount > 0:
1529 val = np.split(np_style_arrays[name].ravel(), rowcount)
1530 else:
1531 val = []
1532 else:
1533 val = np_style_arrays[name]
1535 try:
1536 arrow_arrays.append(pa.array(val, type=schema.field(name).type))
1537 except pa.ArrowNotImplementedError as err:
1538 # Check if val is big-endian.
1539 if (np.little_endian and val.dtype.byteorder == ">") or ( 1539 ↛ 1548line 1539 didn't jump to line 1548 because the condition on line 1539 was always true
1540 not np.little_endian and val.dtype.byteorder == "="
1541 ):
1542 # We need to convert the array to little-endian.
1543 val2 = val.byteswap()
1544 val2.dtype = val2.dtype.newbyteorder("<")
1545 arrow_arrays.append(pa.array(val2, type=schema.field(name).type))
1546 else:
1547 # This failed for some other reason so raise the exception.
1548 raise err
1550 return arrow_arrays
1553def compute_row_group_size(schema: pa.Schema, target_size: int = TARGET_ROW_GROUP_BYTES) -> int:
1554 """Compute approximate row group size for a given arrow schema.
1556 Given a schema, this routine will compute the number of rows in a row group
1557 that targets the persisted size on disk (or smaller). The exact size on
1558 disk depends on the compression settings and ratios; typical binary data
1559 tables will have around 15-20% compression with the pyarrow default
1560 ``snappy`` compression algorithm.
1562 Parameters
1563 ----------
1564 schema : `pyarrow.Schema`
1565 Arrow table schema.
1566 target_size : `int`, optional
1567 The target size (in bytes).
1569 Returns
1570 -------
1571 row_group_size : `int`
1572 Number of rows per row group to hit the target size.
1573 """
1574 bit_width = 0
1576 metadata = schema.metadata if schema.metadata is not None else {}
1578 for name in schema.names:
1579 t = schema.field(name).type
1581 if _is_string(t) or _is_binary(t):
1582 md_name = f"lsst::arrow::len::{name}"
1584 if (encoded := md_name.encode("UTF-8")) in metadata:
1585 # String/bytes length from header.
1586 strlen = int(schema.metadata[encoded])
1587 else:
1588 # We don't know the string width, so guess something.
1589 strlen = 10
1591 # Assuming UTF-8 encoding, and very few wide characters.
1592 t_width = 8 * strlen
1593 elif isinstance(t, pa.FixedSizeListType):
1594 if t.value_type == pa.null(): 1594 ↛ 1595line 1594 didn't jump to line 1595 because the condition on line 1594 was never true
1595 t_width = 0
1596 else:
1597 t_width = t.list_size * t.value_type.bit_width
1598 elif t == pa.null():
1599 t_width = 0
1600 elif isinstance(t, pa.ListType):
1601 if t.value_type == pa.null():
1602 t_width = 0
1603 else:
1604 # This is a variable length list, just choose
1605 # something arbitrary.
1606 t_width = 10 * t.value_type.bit_width
1607 else:
1608 t_width = t.bit_width
1610 bit_width += t_width
1612 # Insist it is at least 1 byte wide to avoid any divide-by-zero errors.
1613 if bit_width < 8:
1614 bit_width = 8
1616 byte_width = bit_width // 8
1618 return target_size // byte_width
1621def _is_string(t: pa.DataType) -> bool:
1622 return pa.types.is_string(t) or pa.types.is_large_string(t) or pa.types.is_string_view(t)
1625def _is_binary(t: pa.DataType) -> bool:
1626 return (
1627 pa.types.is_binary(t)
1628 or pa.types.is_large_binary(t)
1629 or pa.types.is_binary_view(t)
1630 or pa.types.is_fixed_size_binary(t)
1631 )
1634def _get_pandas_index_columns(md: dict) -> list[str]:
1635 if isinstance(md["index_columns"][0], dict): 1635 ↛ 1637line 1635 didn't jump to line 1637 because the condition on line 1635 was never true
1636 # For parquet files written with pandas3 default parquet settings.
1637 index_columns = [col["name"] for col in md["index_columns"]]
1638 else:
1639 # For parquet files written with pandas2 default parquet settings
1640 # or this parquet writer.
1641 index_columns = md["index_columns"]
1643 return index_columns