Coverage for python/lsst/daf/butler/formatters/parquet.py: 96%

562 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-29 22:12 +0000

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/>. 

27 

28from __future__ import annotations 

29 

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) 

50 

51import collections.abc 

52import contextlib 

53import itertools 

54import json 

55import logging 

56import re 

57from collections.abc import Generator, Iterable, Sequence 

58from fnmatch import fnmatchcase 

59from typing import IO, TYPE_CHECKING, Any, cast 

60 

61import pyarrow as pa 

62import pyarrow.parquet as pq 

63 

64from lsst.daf.butler import DatasetProvenance, FormatterV2 

65from lsst.daf.butler.delegates.arrowtable import _add_arrow_provenance, _checkArrowCompatibleType 

66from lsst.resources import ResourcePath 

67from lsst.utils.introspection import get_full_type_name 

68from lsst.utils.iteration import ensure_iterable 

69 

70log = logging.getLogger(__name__) 

71 

72if TYPE_CHECKING: 

73 import astropy.table as atable 

74 import numpy as np 

75 import pandas as pd 

76 

77 try: 

78 import fsspec 

79 from fsspec.spec import AbstractFileSystem 

80 except ImportError: 

81 fsspec = None 

82 AbstractFileSystem = type 

83 

84TARGET_ROW_GROUP_BYTES = 1_000_000_000 

85ASTROPY_PANDAS_INDEX_KEY = "lsst::arrow::astropy_pandas_index" 

86 

87 

88@contextlib.contextmanager 

89def generic_open(path: str, fs: AbstractFileSystem | None) -> Generator[IO]: 

90 if fs is None: 

91 with open(path, "rb") as fh: 

92 yield fh 

93 else: 

94 with fs.open(path) as fh: 

95 yield fh 

96 

97 

98class ParquetFormatter(FormatterV2): 

99 """Interface for reading and writing Arrow Table objects to and from 

100 Parquet files. 

101 """ 

102 

103 default_extension = ".parq" 

104 can_read_from_uri = True 

105 can_read_from_local_file = True 

106 

107 def can_accept(self, in_memory_dataset: Any) -> bool: 

108 # Docstring inherited. 

109 return _checkArrowCompatibleType(in_memory_dataset) is not None 

110 

111 def read_from_uri(self, uri: ResourcePath, component: str | None = None, expected_size: int = -1) -> Any: 

112 # Docstring inherited from Formatter.read. 

113 try: 

114 fs, path = uri.to_fsspec() 

115 except ImportError: 

116 log.debug("fsspec not available; falling back to local file access.") 

117 # This signals to the formatter to use the read_from_local_file 

118 # code path. 

119 return NotImplemented 

120 

121 return self._read_parquet(path=path, fs=fs, component=component, expected_size=expected_size) 

122 

123 def read_from_local_file(self, path: str, component: str | None = None, expected_size: int = -1) -> Any: 

124 # Docstring inherited from Formatter.read. 

125 return self._read_parquet(path=path, component=component, expected_size=expected_size) 

126 

127 def _read_parquet( 

128 self, 

129 path: str, 

130 fs: AbstractFileSystem | None = None, 

131 component: str | None = None, 

132 expected_size: int = -1, 

133 ) -> Any: 

134 with generic_open(path, fs) as handle: 

135 schema = pq.read_schema(handle) 

136 

137 schema_names = ["ArrowSchema", "DataFrameSchema", "ArrowAstropySchema", "ArrowNumpySchema"] 

138 

139 if component in ("columns", "schema") or self.file_descriptor.readStorageClass.name in schema_names: 

140 # The schema will be translated to column format 

141 # depending on the input type. 

142 return schema 

143 elif component == "rowcount": 

144 # Get the rowcount from the metadata if possible, otherwise count. 

145 if b"lsst::arrow::rowcount" in schema.metadata: 

146 return int(schema.metadata[b"lsst::arrow::rowcount"]) 

147 

148 with generic_open(path, fs) as handle: 

149 temp_table = pq.read_table( 

150 handle, 

151 columns=[schema.names[0]], 

152 use_threads=False, 

153 use_pandas_metadata=False, 

154 ) 

155 

156 return len(temp_table[schema.names[0]]) 

157 

158 par_columns = None 

159 strip_astropy_meta_yaml = True 

160 if self.file_descriptor.parameters: 

161 par_columns = self.file_descriptor.parameters.pop("columns", None) 

162 if par_columns: 

163 has_pandas_multi_index = False 

164 if schema.metadata and b"pandas" in schema.metadata: 

165 md = json.loads(schema.metadata[b"pandas"]) 

166 if len(md["column_indexes"]) > 1: 

167 has_pandas_multi_index = True 

168 

169 if not has_pandas_multi_index: 

170 # Ensure uniqueness, keeping order. 

171 par_columns_in = list(dict.fromkeys(ensure_iterable(par_columns))) 

172 file_columns = [name for name in schema.names if not name.startswith("__")] 

173 

174 # Do case-sensitive glob-style matching, again ensuring 

175 # uniqueness and ordering. 

176 par_columns = {} 

177 for par_column in par_columns_in: 

178 found = False 

179 for file_column in file_columns: 

180 if fnmatchcase(file_column, par_column): 

181 found = True 

182 par_columns[file_column] = True 

183 if not found: 

184 raise ValueError( 

185 f"Column {par_column} specified in parameters not available in parquet file." 

186 ) 

187 par_columns = list(par_columns.keys()) 

188 else: 

189 par_columns = _standardize_multi_index_columns( 

190 arrow_schema_to_pandas_index(schema), 

191 par_columns, 

192 ) 

193 

194 strip_astropy_meta_yaml = self.file_descriptor.parameters.pop( 

195 "strip_astropy_meta_yaml", 

196 True, 

197 ) 

198 

199 if len(self.file_descriptor.parameters): 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true

200 raise ValueError( 

201 f"Unsupported parameters {self.file_descriptor.parameters} in ArrowTable read." 

202 ) 

203 

204 metadata = schema.metadata if schema.metadata is not None else {} 

205 with generic_open(path, fs) as handle: 

206 arrow_table = pq.read_table( 

207 handle, 

208 columns=par_columns, 

209 use_threads=False, 

210 use_pandas_metadata=(b"pandas" in metadata), 

211 ) 

212 

213 if strip_astropy_meta_yaml: 

214 metadata = arrow_table.schema.metadata 

215 # Only strip if (a) we have metadata; (b) it contains 

216 # ``table_meta_yaml``; (c) it contains ``lsst::arrow::rowcount`` 

217 # to avoid stripping data from pure astropy tables (not written 

218 # by the butler). 

219 if metadata and metadata.pop(b"table_meta_yaml", None) and b"lsst::arrow::rowcount" in metadata: 

220 arrow_table = arrow_table.replace_schema_metadata(metadata) 

221 

222 return arrow_table 

223 

224 def add_provenance(self, in_memory_dataset: Any, provenance: DatasetProvenance | None = None) -> Any: 

225 return _add_arrow_provenance(in_memory_dataset, self.dataset_ref, provenance) 

226 

227 def write_local_file(self, in_memory_dataset: Any, uri: ResourcePath) -> None: 

228 """Serialize the in memory dataset to a local parquet file. 

229 

230 Parameters 

231 ---------- 

232 in_memory_dataset : `typing.Any` 

233 The Python object to serialize. 

234 uri : `lsst.resources.ResourcePath` 

235 The location to write the local file. 

236 """ 

237 if isinstance(in_memory_dataset, pa.Schema): 

238 pq.write_metadata(in_memory_dataset, uri.ospath) 

239 return 

240 

241 type_string = _checkArrowCompatibleType(in_memory_dataset) 

242 

243 if type_string is None: 

244 raise ValueError( 

245 f"Unsupported type {get_full_type_name(in_memory_dataset)} of " 

246 "inMemoryDataset for ParquetFormatter." 

247 ) 

248 

249 if type_string == "arrow": 

250 arrow_table = in_memory_dataset 

251 elif type_string == "astropy": 

252 arrow_table = astropy_to_arrow(in_memory_dataset) 

253 elif type_string == "numpy": 

254 arrow_table = numpy_to_arrow(in_memory_dataset) 

255 elif type_string == "numpydict": 

256 arrow_table = numpy_dict_to_arrow(in_memory_dataset) 

257 else: 

258 arrow_table = pandas_to_arrow(in_memory_dataset) 

259 

260 row_group_size = compute_row_group_size(arrow_table.schema) 

261 

262 pq.write_table(arrow_table, uri.ospath, row_group_size=row_group_size) 

263 

264 

265def arrow_to_pandas(arrow_table: pa.Table) -> pd.DataFrame: 

266 """Convert a pyarrow table to a pandas DataFrame. 

267 

268 Parameters 

269 ---------- 

270 arrow_table : `pyarrow.Table` 

271 Input arrow table to convert. If the table has ``pandas`` metadata 

272 in the schema it will be used in the construction of the 

273 ``DataFrame``. 

274 

275 Returns 

276 ------- 

277 dataframe : `pandas.DataFrame` 

278 Converted pandas dataframe. 

279 """ 

280 dataframe = arrow_table.to_pandas(use_threads=False, integer_object_nulls=True) 

281 

282 metadata = arrow_table.schema.metadata if arrow_table.schema.metadata is not None else {} 

283 if (key := ASTROPY_PANDAS_INDEX_KEY.encode()) in metadata: 

284 pandas_index = metadata[key].decode("UTF8") 

285 if pandas_index in arrow_table.schema.names: 

286 dataframe.set_index(pandas_index, inplace=True) 

287 else: 

288 log.warning( 

289 "Index column ``%s`` not available for arrow table conversion to DataFrame", 

290 pandas_index, 

291 ) 

292 

293 return dataframe 

294 

295 

296def arrow_to_astropy(arrow_table: pa.Table) -> atable.Table: 

297 """Convert a pyarrow table to an `astropy.table.Table`. 

298 

299 Parameters 

300 ---------- 

301 arrow_table : `pyarrow.Table` 

302 Input arrow table to convert. If the table has astropy unit 

303 metadata in the schema it will be used in the construction 

304 of the ``astropy.table.Table``. 

305 

306 Returns 

307 ------- 

308 table : `astropy.table.Table` 

309 Converted astropy table. 

310 """ 

311 from astropy.table import Table 

312 

313 astropy_table = Table(arrow_to_numpy_dict(arrow_table)) 

314 

315 _apply_astropy_metadata(astropy_table, arrow_table.schema) 

316 

317 if (key := ASTROPY_PANDAS_INDEX_KEY) in astropy_table.meta: 

318 if astropy_table.meta[key] not in astropy_table.columns: 

319 astropy_table.meta.pop(key) 

320 

321 return astropy_table 

322 

323 

324def arrow_to_numpy(arrow_table: pa.Table) -> np.ndarray | np.ma.MaskedArray: 

325 """Convert a pyarrow table to a structured numpy array. 

326 

327 Parameters 

328 ---------- 

329 arrow_table : `pyarrow.Table` 

330 Input arrow table. 

331 

332 Returns 

333 ------- 

334 array : `numpy.ndarray` or `numpy.ma.MaskedArray` (N,) 

335 Numpy array table with N rows and the same column names 

336 as the input arrow table. Will be masked records if any values 

337 in the table are null. 

338 """ 

339 import numpy as np 

340 

341 numpy_dict = arrow_to_numpy_dict(arrow_table) 

342 

343 has_mask = False 

344 dtype: list[tuple] = [] 

345 for name, col in numpy_dict.items(): 

346 if len(shape := numpy_dict[name].shape) <= 1: 

347 dtype.append((name, col.dtype)) 

348 else: 

349 dtype.append((name, (col.dtype, shape[1:]))) 

350 

351 if not has_mask and isinstance(col, np.ma.MaskedArray): 

352 has_mask = True 

353 

354 array: Any 

355 

356 if has_mask: 

357 import numpy.ma.mrecords as mrecords 

358 

359 array = mrecords.fromarrays(list(numpy_dict.values()), dtype=dtype) 

360 else: 

361 array = np.rec.fromarrays(numpy_dict.values(), dtype=dtype) 

362 return array 

363 

364 

365def arrow_to_numpy_dict(arrow_table: pa.Table) -> dict[str, np.ndarray]: 

366 """Convert a pyarrow table to a dict of numpy arrays. 

367 

368 Parameters 

369 ---------- 

370 arrow_table : `pyarrow.Table` 

371 Input arrow table. 

372 

373 Returns 

374 ------- 

375 numpy_dict : `dict` [`str`, `numpy.ndarray`] 

376 Dict with keys as the column names, values as the arrays. 

377 """ 

378 import numpy as np 

379 

380 schema = arrow_table.schema 

381 metadata = schema.metadata if schema.metadata is not None else {} 

382 

383 numpy_dict = {} 

384 

385 for name in schema.names: 

386 t = schema.field(name).type 

387 

388 if arrow_table[name].null_count == 0: 

389 # Regular non-masked column 

390 col = arrow_table[name].to_numpy() 

391 else: 

392 # For a masked column, we need to ask arrow to fill the null 

393 # values with an appropriately typed value before conversion. 

394 # Then we apply the mask to get a masked array of the correct type. 

395 null_value: Any 

396 match t: 

397 case t if t in (pa.float64(), pa.float32(), pa.float16()): 

398 null_value = np.nan 

399 case t if t in (pa.int64(), pa.int32(), pa.int16(), pa.int8()): 

400 null_value = -1 

401 case t if t in (pa.bool_(),): 

402 null_value = True 

403 case t if t in (pa.string(), pa.binary()): 

404 null_value = "" 

405 case _: 

406 # This is the fallback for unsigned ints in particular. 

407 null_value = 0 

408 

409 col = np.ma.MaskedArray( 

410 data=arrow_table[name].fill_null(null_value).to_numpy(), 

411 mask=arrow_table[name].is_null().to_numpy(), 

412 fill_value=null_value, 

413 ) 

414 

415 if t in (pa.string(), pa.binary()): 

416 col = col.astype(_arrow_string_to_numpy_dtype(schema, name, col)) 

417 elif isinstance(t, pa.FixedSizeListType): 

418 if len(col) > 0: 

419 col = np.stack(col) 

420 else: 

421 # this is an empty column, and needs to be coerced to type. 

422 col = col.astype(t.value_type.to_pandas_dtype()) 

423 

424 shape = _multidim_shape_from_metadata(metadata, t.list_size, name) 

425 col = col.reshape((len(arrow_table), *shape)) 

426 

427 numpy_dict[name] = col 

428 

429 return numpy_dict 

430 

431 

432def _numpy_dict_to_numpy(numpy_dict: dict[str, np.ndarray]) -> np.ndarray: 

433 """Convert a dict of numpy arrays to a structured numpy array. 

434 

435 Parameters 

436 ---------- 

437 numpy_dict : `dict` [`str`, `numpy.ndarray`] 

438 Dict with keys as the column names, values as the arrays. 

439 

440 Returns 

441 ------- 

442 array : `numpy.ndarray` (N,) 

443 Numpy array table with N rows and columns names from the dict keys. 

444 """ 

445 return arrow_to_numpy(numpy_dict_to_arrow(numpy_dict)) 

446 

447 

448def _numpy_to_numpy_dict(np_array: np.ndarray) -> dict[str, np.ndarray]: 

449 """Convert a structured numpy array to a dict of numpy arrays. 

450 

451 Parameters 

452 ---------- 

453 np_array : `numpy.ndarray` 

454 Input numpy array with multiple fields. 

455 

456 Returns 

457 ------- 

458 numpy_dict : `dict` [`str`, `numpy.ndarray`] 

459 Dict with keys as the column names, values as the arrays. 

460 """ 

461 return arrow_to_numpy_dict(numpy_to_arrow(np_array)) 

462 

463 

464def numpy_to_arrow(np_array: np.ndarray) -> pa.Table: 

465 """Convert a numpy array table to an arrow table. 

466 

467 Parameters 

468 ---------- 

469 np_array : `numpy.ndarray` 

470 Input numpy array with multiple fields. 

471 

472 Returns 

473 ------- 

474 arrow_table : `pyarrow.Table` 

475 Converted arrow table. 

476 """ 

477 type_list = _numpy_dtype_to_arrow_types(np_array.dtype) 

478 

479 md = {} 

480 md[b"lsst::arrow::rowcount"] = str(len(np_array)) 

481 

482 names = np_array.dtype.names 

483 if names is None: 483 ↛ 484line 483 didn't jump to line 484 because the condition on line 483 was never true

484 names = () 

485 

486 for name in names: 

487 _append_numpy_string_metadata(md, name, np_array.dtype[name]) 

488 _append_numpy_multidim_metadata(md, name, np_array.dtype[name]) 

489 

490 schema = pa.schema(type_list, metadata=md) 

491 

492 arrays = _numpy_style_arrays_to_arrow_arrays( 

493 np_array.dtype, 

494 len(np_array), 

495 np_array, 

496 schema, 

497 ) 

498 

499 arrow_table = pa.Table.from_arrays(arrays, schema=schema) 

500 

501 return arrow_table 

502 

503 

504def numpy_dict_to_arrow(numpy_dict: dict[str, np.ndarray]) -> pa.Table: 

505 """Convert a dict of numpy arrays to an arrow table. 

506 

507 Parameters 

508 ---------- 

509 numpy_dict : `dict` [`str`, `numpy.ndarray`] 

510 Dict with keys as the column names, values as the arrays. 

511 

512 Returns 

513 ------- 

514 arrow_table : `pyarrow.Table` 

515 Converted arrow table. 

516 

517 Raises 

518 ------ 

519 ValueError 

520 Raised if columns in ``numpy_dict`` have unequal numbers of 

521 rows. 

522 """ 

523 dtype, rowcount = _numpy_dict_to_dtype(numpy_dict) 

524 type_list = _numpy_dtype_to_arrow_types(dtype) 

525 

526 md = {} 

527 md[b"lsst::arrow::rowcount"] = str(rowcount) 

528 

529 if dtype.names is not None: 529 ↛ 534line 529 didn't jump to line 534 because the condition on line 529 was always true

530 for name in dtype.names: 

531 _append_numpy_string_metadata(md, name, dtype[name]) 

532 _append_numpy_multidim_metadata(md, name, dtype[name]) 

533 

534 schema = pa.schema(type_list, metadata=md) 

535 

536 arrays = _numpy_style_arrays_to_arrow_arrays( 

537 dtype, 

538 rowcount, 

539 numpy_dict, 

540 schema, 

541 ) 

542 

543 arrow_table = pa.Table.from_arrays(arrays, schema=schema) 

544 

545 return arrow_table 

546 

547 

548def astropy_to_arrow(astropy_table: atable.Table) -> pa.Table: 

549 """Convert an astropy table to an arrow table. 

550 

551 Parameters 

552 ---------- 

553 astropy_table : `astropy.table.Table` 

554 Input astropy table. 

555 

556 Returns 

557 ------- 

558 arrow_table : `pyarrow.Table` 

559 Converted arrow table. 

560 """ 

561 from astropy.table import meta 

562 

563 type_list = _numpy_dtype_to_arrow_types(astropy_table.dtype) 

564 

565 md = {} 

566 md[b"lsst::arrow::rowcount"] = str(len(astropy_table)) 

567 

568 if (key := ASTROPY_PANDAS_INDEX_KEY) in astropy_table.meta: 

569 md[key.encode()] = astropy_table.meta[key] 

570 

571 for name in astropy_table.dtype.names: 

572 _append_numpy_string_metadata(md, name, astropy_table.dtype[name]) 

573 _append_numpy_multidim_metadata(md, name, astropy_table.dtype[name]) 

574 

575 meta_yaml = meta.get_yaml_from_table(astropy_table) 

576 meta_yaml_str = "\n".join(meta_yaml) 

577 md[b"table_meta_yaml"] = meta_yaml_str 

578 

579 # Convert type list to fields with metadata. 

580 fields = [] 

581 for name, pa_type in type_list: 

582 field_metadata = {} 

583 if description := astropy_table[name].description: 

584 field_metadata["description"] = description 

585 if unit := astropy_table[name].unit: 

586 field_metadata["unit"] = str(unit) 

587 fields.append( 

588 pa.field( 

589 name, 

590 pa_type, 

591 metadata=field_metadata, 

592 ) 

593 ) 

594 

595 schema = pa.schema(fields, metadata=md) 

596 

597 arrays = _numpy_style_arrays_to_arrow_arrays( 

598 astropy_table.dtype, 

599 len(astropy_table), 

600 astropy_table, 

601 schema, 

602 ) 

603 

604 arrow_table = pa.Table.from_arrays(arrays, schema=schema) 

605 

606 return arrow_table 

607 

608 

609def astropy_to_pandas(astropy_table: atable.Table, index: str | None = None) -> pd.DataFrame: 

610 """Convert an astropy table to a pandas dataframe via arrow. 

611 

612 By going via arrow we avoid pandas masked column bugs (e.g. 

613 https://github.com/pandas-dev/pandas/issues/58173) 

614 

615 Parameters 

616 ---------- 

617 astropy_table : `astropy.table.Table` 

618 Input astropy table. 

619 index : `str`, optional 

620 Name of column to set as index. 

621 

622 Returns 

623 ------- 

624 dataframe : `pandas.DataFrame` 

625 Output pandas dataframe. 

626 """ 

627 index_requested = False 

628 if (key := ASTROPY_PANDAS_INDEX_KEY) in astropy_table.meta: 

629 _index = astropy_table.meta[key] 

630 if _index not in astropy_table.columns: 

631 log.warning( 

632 "Index column ``%s`` not available for astropy table conversion to DataFrame", 

633 _index, 

634 ) 

635 _index = None 

636 else: 

637 index_requested = True 

638 _index = index 

639 

640 dataframe = arrow_to_pandas(astropy_to_arrow(astropy_table)) 

641 

642 # Set the index if we have a valid index name, and either the 

643 # index was requested in the call to the function or the dataframe 

644 # was not previously indexed with the call to arrow_to_pandas. 

645 if isinstance(_index, str) and (index_requested or dataframe.index.name is None): 

646 dataframe.set_index(_index, inplace=True) 

647 elif _index and index_requested: 647 ↛ 648line 647 didn't jump to line 648 because the condition on line 647 was never true

648 raise RuntimeError("index must be a string or None.") 

649 

650 return dataframe 

651 

652 

653def add_pandas_index_to_astropy(astropy_table: atable.Table, index: str) -> None: 

654 """Add special metadata to an astropy table to indicate a pandas index. 

655 

656 Parameters 

657 ---------- 

658 astropy_table : `astropy.table.Table` 

659 Input astropy table. 

660 index : `str` 

661 Name of column for pandas to set as index, if read as DataFrame. 

662 """ 

663 if index not in astropy_table.columns: 

664 raise ValueError("Column ``%s`` not in astropy table columns to use as pandas index.", index) 

665 astropy_table.meta[ASTROPY_PANDAS_INDEX_KEY] = index 

666 

667 

668def _astropy_to_numpy_dict(astropy_table: atable.Table) -> dict[str, np.ndarray]: 

669 """Convert an astropy table to an arrow table. 

670 

671 Parameters 

672 ---------- 

673 astropy_table : `astropy.table.Table` 

674 Input astropy table. 

675 

676 Returns 

677 ------- 

678 numpy_dict : `dict` [`str`, `numpy.ndarray`] 

679 Dict with keys as the column names, values as the arrays. 

680 """ 

681 return arrow_to_numpy_dict(astropy_to_arrow(astropy_table)) 

682 

683 

684def pandas_to_arrow(dataframe: pd.DataFrame, default_length: int = 10) -> pa.Table: 

685 """Convert a pandas dataframe to an arrow table. 

686 

687 Parameters 

688 ---------- 

689 dataframe : `pandas.DataFrame` 

690 Input pandas dataframe. 

691 default_length : `int`, optional 

692 Default string length when not in metadata or can be inferred 

693 from column. 

694 

695 Returns 

696 ------- 

697 arrow_table : `pyarrow.Table` 

698 Converted arrow table. 

699 """ 

700 try: 

701 arrow_table = pa.Table.from_pandas(dataframe) 

702 except pa.ArrowInvalid as e: 

703 msg = "; ".join(e.args) 

704 msg += "; This is usually because the column is mixed type or has uneven length rows." 

705 e.add_note(msg) 

706 raise 

707 

708 # Update the metadata 

709 md = arrow_table.schema.metadata 

710 

711 md[b"lsst::arrow::rowcount"] = str(arrow_table.num_rows) 

712 

713 # We loop through the arrow table columns because the datatypes have 

714 # been checked and converted from pandas objects. 

715 for name in arrow_table.column_names: 

716 if not name.startswith("__") and arrow_table[name].type == pa.string(): 

717 if len(arrow_table[name]) > 0: 717 ↛ 720line 717 didn't jump to line 720 because the condition on line 717 was always true

718 strlen = max(len(row.as_py()) for row in arrow_table[name] if row.is_valid) 

719 else: 

720 strlen = default_length 

721 md[f"lsst::arrow::len::{name}".encode()] = str(strlen) 

722 

723 arrow_table = arrow_table.replace_schema_metadata(md) 

724 

725 return arrow_table 

726 

727 

728def pandas_to_astropy(dataframe: pd.DataFrame) -> atable.Table: 

729 """Convert a pandas dataframe to an astropy table, preserving indexes. 

730 

731 Parameters 

732 ---------- 

733 dataframe : `pandas.DataFrame` 

734 Input pandas dataframe. 

735 

736 Returns 

737 ------- 

738 astropy_table : `astropy.table.Table` 

739 Converted astropy table. 

740 """ 

741 import pandas as pd 

742 

743 if isinstance(dataframe.columns, pd.MultiIndex): 

744 raise ValueError("Cannot convert a multi-index dataframe to an astropy table.") 

745 

746 return arrow_to_astropy(pandas_to_arrow(dataframe)) 

747 

748 

749def _pandas_to_numpy_dict(dataframe: pd.DataFrame) -> dict[str, np.ndarray]: 

750 """Convert a pandas dataframe to an dict of numpy arrays. 

751 

752 Parameters 

753 ---------- 

754 dataframe : `pandas.DataFrame` 

755 Input pandas dataframe. 

756 

757 Returns 

758 ------- 

759 numpy_dict : `dict` [`str`, `numpy.ndarray`] 

760 Dict with keys as the column names, values as the arrays. 

761 """ 

762 return arrow_to_numpy_dict(pandas_to_arrow(dataframe)) 

763 

764 

765def numpy_to_astropy(np_array: np.ndarray) -> atable.Table: 

766 """Convert a numpy table to an astropy table. 

767 

768 Parameters 

769 ---------- 

770 np_array : `numpy.ndarray` 

771 Input numpy array with multiple fields. 

772 

773 Returns 

774 ------- 

775 astropy_table : `astropy.table.Table` 

776 Converted astropy table. 

777 """ 

778 from astropy.table import Table 

779 

780 return Table(data=np_array, copy=False) 

781 

782 

783def arrow_schema_to_pandas_index(schema: pa.Schema) -> pd.Index | pd.MultiIndex: 

784 """Convert an arrow schema to a pandas index/multiindex. 

785 

786 Parameters 

787 ---------- 

788 schema : `pyarrow.Schema` 

789 Input pyarrow schema. 

790 

791 Returns 

792 ------- 

793 index : `pandas.Index` or `pandas.MultiIndex` 

794 Converted pandas index. 

795 """ 

796 import pandas as pd 

797 

798 if b"pandas" in schema.metadata: 

799 md = json.loads(schema.metadata[b"pandas"]) 

800 indexes = md["column_indexes"] 

801 len_indexes = len(indexes) 

802 else: 

803 len_indexes = 0 

804 

805 if len_indexes <= 1: 

806 return pd.Index(name for name in schema.names if not name.startswith("__")) 

807 else: 

808 raw_columns = _split_multi_index_column_names(len(indexes), schema.names) 

809 return pd.MultiIndex.from_tuples(raw_columns, names=[f["name"] for f in indexes]) 

810 

811 

812def arrow_schema_to_column_list(schema: pa.Schema) -> list[str]: 

813 """Convert an arrow schema to a list of string column names. 

814 

815 Parameters 

816 ---------- 

817 schema : `pyarrow.Schema` 

818 Input pyarrow schema. 

819 

820 Returns 

821 ------- 

822 column_list : `list` [`str`] 

823 Converted list of column names. 

824 """ 

825 return list(schema.names) 

826 

827 

828class DataFrameSchema: 

829 """Wrapper class for a schema for a pandas DataFrame. 

830 

831 Parameters 

832 ---------- 

833 dataframe : `pandas.DataFrame` 

834 Dataframe to turn into a schema. 

835 """ 

836 

837 def __init__(self, dataframe: pd.DataFrame) -> None: 

838 self._schema = dataframe.loc[[False] * len(dataframe)] 

839 

840 @classmethod 

841 def from_arrow(cls, schema: pa.Schema) -> DataFrameSchema: 

842 """Convert an arrow schema into a `DataFrameSchema`. 

843 

844 Parameters 

845 ---------- 

846 schema : `pyarrow.Schema` 

847 The pyarrow schema to convert. 

848 

849 Returns 

850 ------- 

851 dataframe_schema : `DataFrameSchema` 

852 Converted dataframe schema. 

853 """ 

854 empty_table = pa.Table.from_pylist([] * len(schema.names), schema=schema) 

855 

856 return cls(empty_table.to_pandas()) 

857 

858 def to_arrow_schema(self) -> pa.Schema: 

859 """Convert to an arrow schema. 

860 

861 Returns 

862 ------- 

863 arrow_schema : `pyarrow.Schema` 

864 Converted pyarrow schema. 

865 """ 

866 arrow_table = pa.Table.from_pandas(self._schema) 

867 

868 return arrow_table.schema 

869 

870 def to_arrow_numpy_schema(self) -> ArrowNumpySchema: 

871 """Convert to an `ArrowNumpySchema`. 

872 

873 Returns 

874 ------- 

875 arrow_numpy_schema : `ArrowNumpySchema` 

876 Converted arrow numpy schema. 

877 """ 

878 return ArrowNumpySchema.from_arrow(self.to_arrow_schema()) 

879 

880 def to_arrow_astropy_schema(self) -> ArrowAstropySchema: 

881 """Convert to an ArrowAstropySchema. 

882 

883 Returns 

884 ------- 

885 arrow_astropy_schema : `ArrowAstropySchema` 

886 Converted arrow astropy schema. 

887 """ 

888 return ArrowAstropySchema.from_arrow(self.to_arrow_schema()) 

889 

890 @property 

891 def schema(self) -> np.dtype: 

892 return self._schema 

893 

894 def __repr__(self) -> str: 

895 return repr(self._schema) 

896 

897 def __eq__(self, other: object) -> bool: 

898 if not isinstance(other, DataFrameSchema): 

899 return NotImplemented 

900 

901 return self._schema.equals(other._schema) 

902 

903 

904class ArrowAstropySchema: 

905 """Wrapper class for a schema for an astropy table. 

906 

907 Parameters 

908 ---------- 

909 astropy_table : `astropy.table.Table` 

910 Input astropy table. 

911 """ 

912 

913 def __init__(self, astropy_table: atable.Table) -> None: 

914 self._schema = astropy_table[:0] 

915 

916 @classmethod 

917 def from_arrow(cls, schema: pa.Schema) -> ArrowAstropySchema: 

918 """Convert an arrow schema into a ArrowAstropySchema. 

919 

920 Parameters 

921 ---------- 

922 schema : `pyarrow.Schema` 

923 Input pyarrow schema. 

924 

925 Returns 

926 ------- 

927 astropy_schema : `ArrowAstropySchema` 

928 Converted arrow astropy schema. 

929 """ 

930 import numpy as np 

931 from astropy.table import Table 

932 

933 dtype = _schema_to_dtype_list(schema) 

934 

935 data = np.zeros(0, dtype=dtype) 

936 astropy_table = Table(data=data) 

937 

938 _apply_astropy_metadata(astropy_table, schema) 

939 

940 return cls(astropy_table) 

941 

942 def to_arrow_schema(self) -> pa.Schema: 

943 """Convert to an arrow schema. 

944 

945 Returns 

946 ------- 

947 arrow_schema : `pyarrow.Schema` 

948 Converted pyarrow schema. 

949 """ 

950 return astropy_to_arrow(self._schema).schema 

951 

952 def to_dataframe_schema(self) -> DataFrameSchema: 

953 """Convert to a DataFrameSchema. 

954 

955 Returns 

956 ------- 

957 dataframe_schema : `DataFrameSchema` 

958 Converted dataframe schema. 

959 """ 

960 return DataFrameSchema.from_arrow(astropy_to_arrow(self._schema).schema) 

961 

962 def to_arrow_numpy_schema(self) -> ArrowNumpySchema: 

963 """Convert to an `ArrowNumpySchema`. 

964 

965 Returns 

966 ------- 

967 arrow_numpy_schema : `ArrowNumpySchema` 

968 Converted arrow numpy schema. 

969 """ 

970 return ArrowNumpySchema.from_arrow(astropy_to_arrow(self._schema).schema) 

971 

972 @property 

973 def schema(self) -> atable.Table: 

974 return self._schema 

975 

976 def __repr__(self) -> str: 

977 return repr(self._schema) 

978 

979 def __eq__(self, other: object) -> bool: 

980 if not isinstance(other, ArrowAstropySchema): 

981 return NotImplemented 

982 

983 # If this comparison passes then the two tables have the 

984 # same column names. 

985 if self._schema.dtype != other._schema.dtype: 

986 return False 

987 

988 for name in self._schema.columns: 

989 if not self._schema[name].unit == other._schema[name].unit: 

990 return False 

991 if not self._schema[name].description == other._schema[name].description: 

992 return False 

993 if not self._schema[name].format == other._schema[name].format: 

994 return False 

995 

996 return True 

997 

998 

999class ArrowNumpySchema: 

1000 """Wrapper class for a schema for a numpy ndarray. 

1001 

1002 Parameters 

1003 ---------- 

1004 numpy_dtype : `numpy.dtype` 

1005 Numpy dtype to convert. 

1006 """ 

1007 

1008 def __init__(self, numpy_dtype: np.dtype) -> None: 

1009 self._dtype = numpy_dtype 

1010 

1011 @classmethod 

1012 def from_arrow(cls, schema: pa.Schema) -> ArrowNumpySchema: 

1013 """Convert an arrow schema into an `ArrowNumpySchema`. 

1014 

1015 Parameters 

1016 ---------- 

1017 schema : `pyarrow.Schema` 

1018 Pyarrow schema to convert. 

1019 

1020 Returns 

1021 ------- 

1022 numpy_schema : `ArrowNumpySchema` 

1023 Converted arrow numpy schema. 

1024 """ 

1025 import numpy as np 

1026 

1027 dtype = _schema_to_dtype_list(schema) 

1028 

1029 return cls(np.dtype(dtype)) 

1030 

1031 def to_arrow_astropy_schema(self) -> ArrowAstropySchema: 

1032 """Convert to an `ArrowAstropySchema`. 

1033 

1034 Returns 

1035 ------- 

1036 astropy_schema : `ArrowAstropySchema` 

1037 Converted arrow astropy schema. 

1038 """ 

1039 import numpy as np 

1040 

1041 return ArrowAstropySchema.from_arrow(numpy_to_arrow(np.zeros(0, dtype=self._dtype)).schema) 

1042 

1043 def to_dataframe_schema(self) -> DataFrameSchema: 

1044 """Convert to a `DataFrameSchema`. 

1045 

1046 Returns 

1047 ------- 

1048 dataframe_schema : `DataFrameSchema` 

1049 Converted dataframe schema. 

1050 """ 

1051 import numpy as np 

1052 

1053 return DataFrameSchema.from_arrow(numpy_to_arrow(np.zeros(0, dtype=self._dtype)).schema) 

1054 

1055 def to_arrow_schema(self) -> pa.Schema: 

1056 """Convert to a `pyarrow.Schema`. 

1057 

1058 Returns 

1059 ------- 

1060 arrow_schema : `pyarrow.Schema` 

1061 Converted pyarrow schema. 

1062 """ 

1063 import numpy as np 

1064 

1065 return numpy_to_arrow(np.zeros(0, dtype=self._dtype)).schema 

1066 

1067 @property 

1068 def schema(self) -> np.dtype: 

1069 return self._dtype 

1070 

1071 def __repr__(self) -> str: 

1072 return repr(self._dtype) 

1073 

1074 def __eq__(self, other: object) -> bool: 

1075 if not isinstance(other, ArrowNumpySchema): 

1076 return NotImplemented 

1077 

1078 if not self._dtype == other._dtype: 

1079 return False 

1080 

1081 return True 

1082 

1083 

1084def _split_multi_index_column_names(n: int, names: Iterable[str]) -> list[Sequence[str]]: 

1085 """Split a string that represents a multi-index column. 

1086 

1087 PyArrow maps Pandas' multi-index column names (which are tuples in Python) 

1088 to flat strings on disk. This routine exists to reconstruct the original 

1089 tuple. 

1090 

1091 Parameters 

1092 ---------- 

1093 n : `int` 

1094 Number of levels in the `pandas.MultiIndex` that is being 

1095 reconstructed. 

1096 names : `~collections.abc.Iterable` [`str`] 

1097 Strings to be split. 

1098 

1099 Returns 

1100 ------- 

1101 column_names : `list` [`tuple` [`str`]] 

1102 A list of multi-index column name tuples. 

1103 """ 

1104 column_names: list[Sequence[str]] = [] 

1105 

1106 pattern = re.compile(r"\({}\)".format(", ".join(["'(.*)'"] * n))) 

1107 for name in names: 

1108 m = re.search(pattern, name) 

1109 if m is not None: 

1110 column_names.append(m.groups()) 

1111 

1112 return column_names 

1113 

1114 

1115def _standardize_multi_index_columns( 

1116 pd_index: pd.MultiIndex, 

1117 columns: Any, 

1118 stringify: bool = True, 

1119) -> list[str | Sequence[Any]]: 

1120 """Transform a dictionary/iterable index from a multi-index column list 

1121 into a string directly understandable by PyArrow. 

1122 

1123 Parameters 

1124 ---------- 

1125 pd_index : `pandas.MultiIndex` 

1126 Pandas multi-index. 

1127 columns : `list` [`tuple`] or `dict` [`str`, `str` or `list` [`str`]] 

1128 Columns to standardize. 

1129 stringify : `bool`, optional 

1130 Should the column names be stringified? 

1131 

1132 Returns 

1133 ------- 

1134 names : `list` [`str`] 

1135 Stringified representation of a multi-index column name. 

1136 """ 

1137 index_level_names = tuple(pd_index.names) 

1138 

1139 names: list[str | Sequence[Any]] = [] 

1140 

1141 if isinstance(columns, list): 

1142 for requested in columns: 

1143 if not isinstance(requested, tuple): 

1144 raise ValueError( 

1145 "Columns parameter for multi-index data frame must be a dictionary or list of tuples. " 

1146 f"Instead got a {get_full_type_name(requested)}." 

1147 ) 

1148 if stringify: 

1149 names.append(str(requested)) 

1150 else: 

1151 names.append(requested) 

1152 else: 

1153 if not isinstance(columns, collections.abc.Mapping): 1153 ↛ 1154line 1153 didn't jump to line 1154 because the condition on line 1153 was never true

1154 raise ValueError( 

1155 "Columns parameter for multi-index data frame must be a dictionary or list of tuples. " 

1156 f"Instead got a {get_full_type_name(columns)}." 

1157 ) 

1158 if not set(index_level_names).issuperset(columns.keys()): 1158 ↛ 1159line 1158 didn't jump to line 1159 because the condition on line 1158 was never true

1159 raise ValueError( 

1160 f"Cannot use dict with keys {set(columns.keys())} to select columns from {index_level_names}." 

1161 ) 

1162 factors = [ 

1163 ensure_iterable(columns.get(level, pd_index.levels[i])) 

1164 for i, level in enumerate(index_level_names) 

1165 ] 

1166 for requested in itertools.product(*factors): 

1167 for i, value in enumerate(requested): 

1168 if value not in pd_index.levels[i]: 1168 ↛ 1169line 1168 didn't jump to line 1169 because the condition on line 1168 was never true

1169 raise ValueError(f"Unrecognized value {value!r} for index {index_level_names[i]!r}.") 

1170 if stringify: 

1171 names.append(str(requested)) 

1172 else: 

1173 names.append(requested) 

1174 

1175 return names 

1176 

1177 

1178def _apply_astropy_metadata(astropy_table: atable.Table, arrow_schema: pa.Schema) -> None: 

1179 """Apply any astropy metadata from the schema metadata. 

1180 

1181 Parameters 

1182 ---------- 

1183 astropy_table : `astropy.table.Table` 

1184 Table to apply metadata. 

1185 arrow_schema : `pyarrow.Schema` 

1186 Arrow schema with metadata. 

1187 """ 

1188 from astropy.table import meta 

1189 

1190 metadata = arrow_schema.metadata if arrow_schema.metadata is not None else {} 

1191 

1192 # Check if we have a special astropy metadata header yaml. 

1193 meta_yaml = metadata.get(b"table_meta_yaml", None) 

1194 if meta_yaml: 

1195 meta_yaml = meta_yaml.decode("UTF8").split("\n") 

1196 meta_hdr = meta.get_header_from_yaml(meta_yaml) 

1197 

1198 # Set description, format, unit, meta from the column 

1199 # metadata that was serialized with the table. 

1200 header_cols = {x["name"]: x for x in meta_hdr["datatype"]} 

1201 for col in astropy_table.columns.values(): 

1202 for attr in ("description", "format", "unit", "meta"): 

1203 if attr in header_cols[col.name]: 

1204 setattr(col, attr, header_cols[col.name][attr]) 

1205 

1206 if "meta" in meta_hdr: 

1207 astropy_table.meta.update(meta_hdr["meta"]) 

1208 else: 

1209 # If we don't have astropy header data, we may have arrow field 

1210 # metadata. 

1211 for name in arrow_schema.names: 

1212 field_metadata = arrow_schema.field(name).metadata 

1213 if field_metadata is None: 

1214 continue 

1215 if ( 

1216 b"description" in field_metadata 

1217 and (description := field_metadata[b"description"].decode("UTF-8")) != "" 

1218 ): 

1219 astropy_table[name].description = description 

1220 if b"unit" in field_metadata and (unit := field_metadata[b"unit"].decode("UTF-8")) != "": 

1221 astropy_table[name].unit = unit 

1222 

1223 # Ensure that the special ASTROPY_PANDAS_INDEX_KEY is propagated to 

1224 # the table metadata. 

1225 if index_key := metadata.get(ASTROPY_PANDAS_INDEX_KEY.encode(), None): 

1226 astropy_table.meta[ASTROPY_PANDAS_INDEX_KEY] = index_key.decode("UTF-8") 

1227 

1228 

1229def _arrow_string_to_numpy_dtype( 

1230 schema: pa.Schema, name: str, numpy_column: np.ndarray | None = None, default_length: int = 10 

1231) -> str: 

1232 """Get the numpy dtype string associated with an arrow column. 

1233 

1234 Parameters 

1235 ---------- 

1236 schema : `pyarrow.Schema` 

1237 Arrow table schema. 

1238 name : `str` 

1239 Column name. 

1240 numpy_column : `numpy.ndarray`, optional 

1241 Column to determine numpy string dtype. 

1242 default_length : `int`, optional 

1243 Default string length when not in metadata or can be inferred 

1244 from column. 

1245 

1246 Returns 

1247 ------- 

1248 dtype_str : `str` 

1249 Numpy dtype string. 

1250 """ 

1251 # Special-case for string and binary columns 

1252 md_name = f"lsst::arrow::len::{name}" 

1253 strlen = default_length 

1254 metadata = schema.metadata if schema.metadata is not None else {} 

1255 if (encoded := md_name.encode("UTF-8")) in metadata: 

1256 # String/bytes length from header. 

1257 strlen = int(schema.metadata[encoded]) 

1258 elif numpy_column is not None and len(numpy_column) > 0: 

1259 lengths = [len(row) for row in numpy_column if row] 

1260 strlen = max(lengths) if lengths else 0 

1261 

1262 dtype = f"U{strlen}" if schema.field(name).type == pa.string() else f"|S{strlen}" 

1263 

1264 return dtype 

1265 

1266 

1267def _append_numpy_string_metadata(metadata: dict[bytes, str], name: str, dtype: np.dtype) -> None: 

1268 """Append numpy string length keys to arrow metadata. 

1269 

1270 All column types are handled, but the metadata is only modified for 

1271 string and byte columns. 

1272 

1273 Parameters 

1274 ---------- 

1275 metadata : `dict` [`bytes`, `str`] 

1276 Metadata dictionary; modified in place. 

1277 name : `str` 

1278 Column name. 

1279 dtype : `np.dtype` 

1280 Numpy dtype. 

1281 """ 

1282 import numpy as np 

1283 

1284 if dtype.type is np.str_: 

1285 metadata[f"lsst::arrow::len::{name}".encode()] = str(dtype.itemsize // 4) 

1286 metadata[f"table::len::{name}".encode()] = str(dtype.itemsize // 4) 

1287 elif dtype.type is np.bytes_: 

1288 metadata[f"lsst::arrow::len::{name}".encode()] = str(dtype.itemsize) 

1289 metadata[f"table::len::{name}".encode()] = str(dtype.itemsize) 

1290 

1291 

1292def _append_numpy_multidim_metadata(metadata: dict[bytes, str], name: str, dtype: np.dtype) -> None: 

1293 """Append numpy multi-dimensional shapes to arrow metadata. 

1294 

1295 All column types are handled, but the metadata is only modified for 

1296 multi-dimensional columns. 

1297 

1298 Parameters 

1299 ---------- 

1300 metadata : `dict` [`bytes`, `str`] 

1301 Metadata dictionary; modified in place. 

1302 name : `str` 

1303 Column name. 

1304 dtype : `np.dtype` 

1305 Numpy dtype. 

1306 """ 

1307 if len(dtype.shape) > 1: 

1308 metadata[f"lsst::arrow::shape::{name}".encode()] = str(dtype.shape) 

1309 

1310 

1311def _multidim_shape_from_metadata(metadata: dict[bytes, bytes], list_size: int, name: str) -> tuple[int, ...]: 

1312 """Retrieve the shape from the metadata, if available. 

1313 

1314 Parameters 

1315 ---------- 

1316 metadata : `dict` [`bytes`, `bytes`] 

1317 Metadata dictionary. 

1318 list_size : `int` 

1319 Size of the list datatype. 

1320 name : `str` 

1321 Column name. 

1322 

1323 Returns 

1324 ------- 

1325 shape : `tuple` [`int`] 

1326 Shape associated with the column. 

1327 

1328 Raises 

1329 ------ 

1330 RuntimeError 

1331 Raised if metadata is found but has incorrect format. 

1332 """ 

1333 md_name = f"lsst::arrow::shape::{name}" 

1334 if (encoded := md_name.encode("UTF-8")) in metadata: 

1335 groups = re.search(r"\((.*)\)", metadata[encoded].decode("UTF-8")) 

1336 if groups is None: 1336 ↛ 1337line 1336 didn't jump to line 1337 because the condition on line 1336 was never true

1337 raise RuntimeError("Illegal value found in metadata.") 

1338 shape = tuple(int(x) for x in groups[1].split(",") if x != "") 

1339 else: 

1340 shape = (list_size,) 

1341 

1342 return shape 

1343 

1344 

1345def _schema_to_dtype_list(schema: pa.Schema) -> list[tuple[str, tuple[Any] | str]]: 

1346 """Convert a pyarrow schema to a numpy dtype. 

1347 

1348 Parameters 

1349 ---------- 

1350 schema : `pyarrow.Schema` 

1351 Input pyarrow schema. 

1352 

1353 Returns 

1354 ------- 

1355 dtype_list: `list` [`tuple`] 

1356 A list with name, type pairs. 

1357 """ 

1358 metadata = schema.metadata if schema.metadata is not None else {} 

1359 

1360 dtype: list[Any] = [] 

1361 for name in schema.names: 

1362 t = schema.field(name).type 

1363 if isinstance(t, pa.FixedSizeListType): 

1364 shape = _multidim_shape_from_metadata(metadata, t.list_size, name) 

1365 dtype.append((name, (t.value_type.to_pandas_dtype(), shape))) 

1366 elif t not in (pa.string(), pa.binary()): 

1367 dtype.append((name, t.to_pandas_dtype())) 

1368 else: 

1369 dtype.append((name, _arrow_string_to_numpy_dtype(schema, name))) 

1370 

1371 return dtype 

1372 

1373 

1374def _numpy_dtype_to_arrow_types(dtype: np.dtype) -> list[Any]: 

1375 """Convert a numpy dtype to a list of arrow types. 

1376 

1377 Parameters 

1378 ---------- 

1379 dtype : `numpy.dtype` 

1380 Numpy dtype to convert. 

1381 

1382 Returns 

1383 ------- 

1384 type_list : `list` [`object`] 

1385 Converted list of arrow types. 

1386 """ 

1387 from math import prod 

1388 

1389 import numpy as np 

1390 

1391 type_list: list[Any] = [] 

1392 if dtype.names is None: 1392 ↛ 1393line 1392 didn't jump to line 1393 because the condition on line 1392 was never true

1393 return type_list 

1394 

1395 for name in dtype.names: 

1396 dt = dtype[name] 

1397 arrow_type: Any 

1398 if len(dt.shape) > 0: 

1399 arrow_type = pa.list_( 

1400 pa.from_numpy_dtype(cast(tuple[np.dtype, tuple[int, ...]], dt.subdtype)[0].type), 

1401 prod(dt.shape), 

1402 ) 

1403 elif dt.type == np.datetime64: 

1404 time_unit = "ns" if "ns" in dt.str else "us" 

1405 # The pa.timestamp() is the correct datatype to round-trip 

1406 # a numpy datetime64[ns] or datetime[us] array. 

1407 arrow_type = pa.timestamp(time_unit) 

1408 else: 

1409 try: 

1410 arrow_type = pa.from_numpy_dtype(dt.type) 

1411 except pa.ArrowNotImplementedError as e: 

1412 msg = f"Could not serialize column {name} (type {str(dt)}) to Parquet." 

1413 if dt == np.dtype("O"): 1413 ↛ 1415line 1413 didn't jump to line 1415 because the condition on line 1413 was always true

1414 msg += " This is usually because the column is mixed type or has uneven length rows." 

1415 e.add_note(msg) 

1416 raise 

1417 type_list.append((name, arrow_type)) 

1418 

1419 return type_list 

1420 

1421 

1422def _numpy_dict_to_dtype(numpy_dict: dict[str, np.ndarray]) -> tuple[np.dtype, int]: 

1423 """Extract equivalent table dtype from dict of numpy arrays. 

1424 

1425 Parameters 

1426 ---------- 

1427 numpy_dict : `dict` [`str`, `numpy.ndarray`] 

1428 Dict with keys as the column names, values as the arrays. 

1429 

1430 Returns 

1431 ------- 

1432 dtype : `numpy.dtype` 

1433 dtype of equivalent table. 

1434 rowcount : `int` 

1435 Number of rows in the table. 

1436 

1437 Raises 

1438 ------ 

1439 ValueError if columns in numpy_dict have unequal numbers of rows. 

1440 """ 

1441 import numpy as np 

1442 

1443 dtype_list: list[tuple] = [] 

1444 rowcount = 0 

1445 for name, col in numpy_dict.items(): 

1446 if rowcount == 0: 

1447 rowcount = len(col) 

1448 if len(col) != rowcount: 

1449 raise ValueError(f"Column {name} has a different number of rows.") 

1450 if len(col.shape) == 1: 

1451 dtype_list.append((name, col.dtype)) 

1452 else: 

1453 dtype_list.append((name, (col.dtype, col.shape[1:]))) 

1454 dtype = np.dtype(dtype_list) 

1455 

1456 return (dtype, rowcount) 

1457 

1458 

1459def _numpy_style_arrays_to_arrow_arrays( 

1460 dtype: np.dtype, 

1461 rowcount: int, 

1462 np_style_arrays: dict[str, np.ndarray] | np.ndarray | atable.Table, 

1463 schema: pa.Schema, 

1464) -> list[pa.Array]: 

1465 """Convert numpy-style arrays to arrow arrays. 

1466 

1467 Parameters 

1468 ---------- 

1469 dtype : `numpy.dtype` 

1470 Numpy dtype of input table/arrays. 

1471 rowcount : `int` 

1472 Number of rows in input table/arrays. 

1473 np_style_arrays : `dict` [`str`, `np.ndarray`] or `np.ndarray` 

1474 or `astropy.table.Table` 

1475 Arrays to convert to arrow. 

1476 schema : `pyarrow.Schema` 

1477 Schema of arrow table. 

1478 

1479 Returns 

1480 ------- 

1481 arrow_arrays : `list` [`pyarrow.Array`] 

1482 List of converted pyarrow arrays. 

1483 """ 

1484 import numpy as np 

1485 

1486 arrow_arrays: list[pa.Array] = [] 

1487 if dtype.names is None: 1487 ↛ 1488line 1487 didn't jump to line 1488 because the condition on line 1487 was never true

1488 return arrow_arrays 

1489 

1490 for name in dtype.names: 

1491 dt = dtype[name] 

1492 val: Any 

1493 if len(dt.shape) > 0: 

1494 if rowcount > 0: 

1495 val = np.split(np_style_arrays[name].ravel(), rowcount) 

1496 else: 

1497 val = [] 

1498 else: 

1499 val = np_style_arrays[name] 

1500 

1501 try: 

1502 arrow_arrays.append(pa.array(val, type=schema.field(name).type)) 

1503 except pa.ArrowNotImplementedError as err: 

1504 # Check if val is big-endian. 

1505 if (np.little_endian and val.dtype.byteorder == ">") or ( 1505 ↛ 1514line 1505 didn't jump to line 1514 because the condition on line 1505 was always true

1506 not np.little_endian and val.dtype.byteorder == "=" 

1507 ): 

1508 # We need to convert the array to little-endian. 

1509 val2 = val.byteswap() 

1510 val2.dtype = val2.dtype.newbyteorder("<") 

1511 arrow_arrays.append(pa.array(val2, type=schema.field(name).type)) 

1512 else: 

1513 # This failed for some other reason so raise the exception. 

1514 raise err 

1515 

1516 return arrow_arrays 

1517 

1518 

1519def compute_row_group_size(schema: pa.Schema, target_size: int = TARGET_ROW_GROUP_BYTES) -> int: 

1520 """Compute approximate row group size for a given arrow schema. 

1521 

1522 Given a schema, this routine will compute the number of rows in a row group 

1523 that targets the persisted size on disk (or smaller). The exact size on 

1524 disk depends on the compression settings and ratios; typical binary data 

1525 tables will have around 15-20% compression with the pyarrow default 

1526 ``snappy`` compression algorithm. 

1527 

1528 Parameters 

1529 ---------- 

1530 schema : `pyarrow.Schema` 

1531 Arrow table schema. 

1532 target_size : `int`, optional 

1533 The target size (in bytes). 

1534 

1535 Returns 

1536 ------- 

1537 row_group_size : `int` 

1538 Number of rows per row group to hit the target size. 

1539 """ 

1540 bit_width = 0 

1541 

1542 metadata = schema.metadata if schema.metadata is not None else {} 

1543 

1544 for name in schema.names: 

1545 t = schema.field(name).type 

1546 

1547 if t in (pa.string(), pa.binary()): 

1548 md_name = f"lsst::arrow::len::{name}" 

1549 

1550 if (encoded := md_name.encode("UTF-8")) in metadata: 

1551 # String/bytes length from header. 

1552 strlen = int(schema.metadata[encoded]) 

1553 else: 

1554 # We don't know the string width, so guess something. 

1555 strlen = 10 

1556 

1557 # Assuming UTF-8 encoding, and very few wide characters. 

1558 t_width = 8 * strlen 

1559 elif isinstance(t, pa.FixedSizeListType): 

1560 if t.value_type == pa.null(): 1560 ↛ 1561line 1560 didn't jump to line 1561 because the condition on line 1560 was never true

1561 t_width = 0 

1562 else: 

1563 t_width = t.list_size * t.value_type.bit_width 

1564 elif t == pa.null(): 

1565 t_width = 0 

1566 elif isinstance(t, pa.ListType): 

1567 if t.value_type == pa.null(): 

1568 t_width = 0 

1569 else: 

1570 # This is a variable length list, just choose 

1571 # something arbitrary. 

1572 t_width = 10 * t.value_type.bit_width 

1573 else: 

1574 t_width = t.bit_width 

1575 

1576 bit_width += t_width 

1577 

1578 # Insist it is at least 1 byte wide to avoid any divide-by-zero errors. 

1579 if bit_width < 8: 

1580 bit_width = 8 

1581 

1582 byte_width = bit_width // 8 

1583 

1584 return target_size // byte_width