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

580 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-05 01:26 -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/>. 

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 "pandas_to_numpy", 

50) 

51 

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 

61 

62import pyarrow as pa 

63import pyarrow.parquet as pq 

64 

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 

70 

71log = logging.getLogger(__name__) 

72 

73if TYPE_CHECKING: 

74 import astropy.table as atable 

75 import numpy as np 

76 import pandas as pd 

77 

78 try: 

79 import fsspec 

80 from fsspec.spec import AbstractFileSystem 

81 except ImportError: 

82 fsspec = None 

83 AbstractFileSystem = type 

84 

85TARGET_ROW_GROUP_BYTES = 1_000_000_000 

86ASTROPY_PANDAS_INDEX_KEY = "lsst::arrow::astropy_pandas_index" 

87 

88 

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 

97 

98 

99class ParquetFormatter(FormatterV2): 

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

101 Parquet files. 

102 """ 

103 

104 default_extension = ".parq" 

105 can_read_from_uri = True 

106 can_read_from_local_file = True 

107 

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

109 # Docstring inherited. 

110 return _checkArrowCompatibleType(in_memory_dataset) is not None 

111 

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 

121 

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

123 

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) 

127 

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) 

137 

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

139 

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"]) 

148 

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 ) 

156 

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

158 

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 if schema.metadata and b"pandas" in schema.metadata: 

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

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

168 has_pandas_multi_index = True 

169 

170 if not has_pandas_multi_index: 

171 # Ensure uniqueness, keeping order. 

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

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

174 

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

176 # uniqueness and ordering. 

177 par_columns = {} 

178 for par_column in par_columns_in: 

179 found = False 

180 for file_column in file_columns: 

181 if fnmatchcase(file_column, par_column): 

182 found = True 

183 par_columns[file_column] = True 

184 if not found: 

185 raise ValueError( 

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

187 ) 

188 par_columns = list(par_columns.keys()) 

189 else: 

190 par_columns = _standardize_multi_index_columns( 

191 arrow_schema_to_pandas_index(schema), 

192 par_columns, 

193 ) 

194 

195 strip_astropy_meta_yaml = self.file_descriptor.parameters.pop( 

196 "strip_astropy_meta_yaml", 

197 True, 

198 ) 

199 

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

201 raise ValueError( 

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

203 ) 

204 

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

206 with generic_open(path, fs) as handle: 

207 arrow_table = pq.read_table( 

208 handle, 

209 columns=par_columns, 

210 use_threads=False, 

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

212 ) 

213 

214 if strip_astropy_meta_yaml: 

215 metadata = arrow_table.schema.metadata 

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

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

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

219 # by the butler). 

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

221 arrow_table = arrow_table.replace_schema_metadata(metadata) 

222 

223 return arrow_table 

224 

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

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

227 

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

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

230 

231 Parameters 

232 ---------- 

233 in_memory_dataset : `typing.Any` 

234 The Python object to serialize. 

235 uri : `lsst.resources.ResourcePath` 

236 The location to write the local file. 

237 """ 

238 if isinstance(in_memory_dataset, pa.Schema): 

239 pq.write_metadata(in_memory_dataset, uri.ospath) 

240 return 

241 

242 type_string = _checkArrowCompatibleType(in_memory_dataset) 

243 

244 if type_string is None: 

245 raise ValueError( 

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

247 "inMemoryDataset for ParquetFormatter." 

248 ) 

249 

250 if type_string == "arrow": 

251 arrow_table = in_memory_dataset 

252 elif type_string == "astropy": 

253 arrow_table = astropy_to_arrow(in_memory_dataset) 

254 elif type_string == "numpy": 

255 arrow_table = numpy_to_arrow(in_memory_dataset) 

256 elif type_string == "numpydict": 

257 arrow_table = numpy_dict_to_arrow(in_memory_dataset) 

258 else: 

259 arrow_table = pandas_to_arrow(in_memory_dataset) 

260 

261 row_group_size = compute_row_group_size(arrow_table.schema) 

262 

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

264 

265 

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

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

268 

269 Parameters 

270 ---------- 

271 arrow_table : `pyarrow.Table` 

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

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

274 ``DataFrame``. 

275 

276 Returns 

277 ------- 

278 dataframe : `pandas.DataFrame` 

279 Converted pandas dataframe. 

280 """ 

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

282 

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

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

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

286 if pandas_index in arrow_table.schema.names: 

287 dataframe.set_index(pandas_index, inplace=True) 

288 else: 

289 log.warning( 

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

291 pandas_index, 

292 ) 

293 

294 return dataframe 

295 

296 

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

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

299 

300 Parameters 

301 ---------- 

302 arrow_table : `pyarrow.Table` 

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

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

305 of the ``astropy.table.Table``. 

306 

307 Returns 

308 ------- 

309 table : `astropy.table.Table` 

310 Converted astropy table. 

311 """ 

312 from astropy.table import Table 

313 

314 astropy_table = Table(arrow_to_numpy_dict(arrow_table)) 

315 

316 _apply_astropy_metadata(astropy_table, arrow_table.schema) 

317 

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

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

320 astropy_table.meta.pop(key) 

321 

322 return astropy_table 

323 

324 

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

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

327 

328 Parameters 

329 ---------- 

330 arrow_table : `pyarrow.Table` 

331 Input arrow table. 

332 

333 Returns 

334 ------- 

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

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

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

338 in the table are null. 

339 """ 

340 import numpy as np 

341 

342 numpy_dict = arrow_to_numpy_dict(arrow_table) 

343 

344 has_mask = False 

345 dtype: list[tuple] = [] 

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

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

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

349 else: 

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

351 

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

353 has_mask = True 

354 

355 array: Any 

356 

357 if has_mask: 

358 import numpy.ma.mrecords as mrecords 

359 

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

361 else: 

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

363 return array 

364 

365 

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

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

368 

369 Parameters 

370 ---------- 

371 arrow_table : `pyarrow.Table` 

372 Input arrow table. 

373 

374 Returns 

375 ------- 

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

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

378 """ 

379 import numpy as np 

380 

381 schema = arrow_table.schema 

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

383 

384 numpy_dict = {} 

385 

386 for name in schema.names: 

387 t = schema.field(name).type 

388 

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

390 # Regular non-masked column 

391 col = arrow_table[name].to_numpy() 

392 else: 

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

394 # values with an appropriately typed value before conversion. 

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

396 null_value: Any 

397 match t: 

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

399 null_value = np.nan 

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

401 null_value = -1 

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

403 null_value = True 

404 case t if _is_string(t) or _is_binary(t): 

405 null_value = "" 

406 case _: 

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

408 null_value = 0 

409 

410 col = np.ma.MaskedArray( 

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

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

413 fill_value=null_value, 

414 ) 

415 

416 if _is_string(t) or _is_binary(t): 

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

418 elif isinstance(t, pa.FixedSizeListType): 

419 if len(col) > 0: 

420 col = np.stack(col) 

421 else: 

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

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

424 

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

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

427 

428 numpy_dict[name] = col 

429 

430 return numpy_dict 

431 

432 

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

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

435 

436 Parameters 

437 ---------- 

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

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

440 

441 Returns 

442 ------- 

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

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

445 """ 

446 return arrow_to_numpy(numpy_dict_to_arrow(numpy_dict)) 

447 

448 

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

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

451 

452 Parameters 

453 ---------- 

454 np_array : `numpy.ndarray` 

455 Input numpy array with multiple fields. 

456 

457 Returns 

458 ------- 

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

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

461 """ 

462 return arrow_to_numpy_dict(numpy_to_arrow(np_array)) 

463 

464 

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

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

467 

468 Parameters 

469 ---------- 

470 np_array : `numpy.ndarray` 

471 Input numpy array with multiple fields. 

472 

473 Returns 

474 ------- 

475 arrow_table : `pyarrow.Table` 

476 Converted arrow table. 

477 """ 

478 type_list = _numpy_dtype_to_arrow_types(np_array.dtype) 

479 

480 md = {} 

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

482 

483 names = np_array.dtype.names 

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

485 names = () 

486 

487 for name in names: 

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

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

490 

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

492 

493 arrays = _numpy_style_arrays_to_arrow_arrays( 

494 np_array.dtype, 

495 len(np_array), 

496 np_array, 

497 schema, 

498 ) 

499 

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

501 

502 return arrow_table 

503 

504 

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

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

507 

508 Parameters 

509 ---------- 

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

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

512 

513 Returns 

514 ------- 

515 arrow_table : `pyarrow.Table` 

516 Converted arrow table. 

517 

518 Raises 

519 ------ 

520 ValueError 

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

522 rows. 

523 """ 

524 dtype, rowcount = _numpy_dict_to_dtype(numpy_dict) 

525 type_list = _numpy_dtype_to_arrow_types(dtype) 

526 

527 md = {} 

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

529 

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

531 for name in dtype.names: 

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

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

534 

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

536 

537 arrays = _numpy_style_arrays_to_arrow_arrays( 

538 dtype, 

539 rowcount, 

540 numpy_dict, 

541 schema, 

542 ) 

543 

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

545 

546 return arrow_table 

547 

548 

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

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

551 

552 Parameters 

553 ---------- 

554 astropy_table : `astropy.table.Table` 

555 Input astropy table. 

556 

557 Returns 

558 ------- 

559 arrow_table : `pyarrow.Table` 

560 Converted arrow table. 

561 """ 

562 from astropy.table import meta 

563 

564 type_list = _numpy_dtype_to_arrow_types(astropy_table.dtype) 

565 

566 md = {} 

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

568 

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

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

571 

572 for name in astropy_table.dtype.names: 

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

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

575 

576 meta_yaml = meta.get_yaml_from_table(astropy_table) 

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

578 md[b"table_meta_yaml"] = meta_yaml_str 

579 

580 # Convert type list to fields with metadata. 

581 fields = [] 

582 for name, pa_type in type_list: 

583 field_metadata = {} 

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

585 field_metadata["description"] = description 

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

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

588 fields.append( 

589 pa.field( 

590 name, 

591 pa_type, 

592 metadata=field_metadata, 

593 ) 

594 ) 

595 

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

597 

598 arrays = _numpy_style_arrays_to_arrow_arrays( 

599 astropy_table.dtype, 

600 len(astropy_table), 

601 astropy_table, 

602 schema, 

603 ) 

604 

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

606 

607 return arrow_table 

608 

609 

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

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

612 

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

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

615 

616 Parameters 

617 ---------- 

618 astropy_table : `astropy.table.Table` 

619 Input astropy table. 

620 index : `str`, optional 

621 Name of column to set as index. 

622 

623 Returns 

624 ------- 

625 dataframe : `pandas.DataFrame` 

626 Output pandas dataframe. 

627 """ 

628 index_requested = False 

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

630 _index = astropy_table.meta[key] 

631 if _index not in astropy_table.columns: 

632 log.warning( 

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

634 _index, 

635 ) 

636 _index = None 

637 else: 

638 index_requested = True 

639 _index = index 

640 

641 dataframe = arrow_to_pandas(astropy_to_arrow(astropy_table)) 

642 

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

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

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

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

647 dataframe.set_index(_index, inplace=True) 

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

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

650 

651 return dataframe 

652 

653 

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

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

656 

657 Parameters 

658 ---------- 

659 astropy_table : `astropy.table.Table` 

660 Input astropy table. 

661 index : `str` 

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

663 """ 

664 if index not in astropy_table.columns: 

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

666 astropy_table.meta[ASTROPY_PANDAS_INDEX_KEY] = index 

667 

668 

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

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

671 

672 Parameters 

673 ---------- 

674 astropy_table : `astropy.table.Table` 

675 Input astropy table. 

676 

677 Returns 

678 ------- 

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

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

681 """ 

682 return arrow_to_numpy_dict(astropy_to_arrow(astropy_table)) 

683 

684 

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

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

687 

688 Parameters 

689 ---------- 

690 dataframe : `pandas.DataFrame` 

691 Input pandas dataframe. 

692 default_length : `int`, optional 

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

694 from column. 

695 

696 Returns 

697 ------- 

698 arrow_table : `pyarrow.Table` 

699 Converted arrow table. 

700 """ 

701 import pandas as pd 

702 

703 old_index = None 

704 

705 if isinstance(dataframe.index, pd.RangeIndex) and dataframe.index.name is not None: 705 ↛ 708line 705 didn't jump to line 708 because the condition on line 705 was never true

706 # Turn the RangeIndex into a regular index, or it won't serialize 

707 # interoperably via arrow. 

708 old_index = dataframe.index 

709 

710 dataframe.index = pd.Index(dataframe.index.to_numpy(), name=dataframe.index.name) 

711 

712 try: 

713 arrow_table = pa.Table.from_pandas(dataframe) 

714 except pa.ArrowInvalid as e: 

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

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

717 e.add_note(msg) 

718 raise 

719 finally: 

720 if old_index is not None: 720 ↛ 721line 720 didn't jump to line 721 because the condition on line 720 was never true

721 dataframe.index = old_index 

722 

723 # Update the metadata 

724 md = arrow_table.schema.metadata 

725 

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

727 

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

729 # been checked and converted from pandas objects. 

730 for name in arrow_table.column_names: 

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

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

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

734 else: 

735 strlen = default_length 

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

737 

738 arrow_table = arrow_table.replace_schema_metadata(md) 

739 

740 return arrow_table 

741 

742 

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

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

745 

746 Parameters 

747 ---------- 

748 dataframe : `pandas.DataFrame` 

749 Input pandas dataframe. 

750 

751 Returns 

752 ------- 

753 astropy_table : `astropy.table.Table` 

754 Converted astropy table. 

755 """ 

756 import pandas as pd 

757 

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

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

760 

761 return arrow_to_astropy(pandas_to_arrow(dataframe)) 

762 

763 

764def pandas_to_numpy(dataframe: pd.DataFrame) -> np.ndarray | np.ma.MaskedArray: 

765 """Convert a pandas dataframe to a numpy recarray. 

766 

767 Parameters 

768 ---------- 

769 dataframe : `pandas.DataFrame` 

770 Input pandas dataframe. 

771 

772 Returns 

773 ------- 

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

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

776 as the input dataframe. Will be masked records if any values 

777 in the table are null. 

778 """ 

779 # This conversion ensures strings are handled properly. 

780 return arrow_to_numpy(pandas_to_arrow(dataframe)) 

781 

782 

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

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

785 

786 Parameters 

787 ---------- 

788 dataframe : `pandas.DataFrame` 

789 Input pandas dataframe. 

790 

791 Returns 

792 ------- 

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

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

795 """ 

796 return arrow_to_numpy_dict(pandas_to_arrow(dataframe)) 

797 

798 

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

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

801 

802 Parameters 

803 ---------- 

804 np_array : `numpy.ndarray` 

805 Input numpy array with multiple fields. 

806 

807 Returns 

808 ------- 

809 astropy_table : `astropy.table.Table` 

810 Converted astropy table. 

811 """ 

812 from astropy.table import Table 

813 

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

815 

816 

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

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

819 

820 Parameters 

821 ---------- 

822 schema : `pyarrow.Schema` 

823 Input pyarrow schema. 

824 

825 Returns 

826 ------- 

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

828 Converted pandas index. 

829 """ 

830 import pandas as pd 

831 

832 if b"pandas" in schema.metadata: 

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

834 indexes = md["column_indexes"] 

835 len_indexes = len(indexes) 

836 else: 

837 len_indexes = 0 

838 

839 if len_indexes <= 1: 

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

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]) 

844 

845 

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

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

848 

849 Parameters 

850 ---------- 

851 schema : `pyarrow.Schema` 

852 Input pyarrow schema. 

853 

854 Returns 

855 ------- 

856 column_list : `list` [`str`] 

857 Converted list of column names. 

858 """ 

859 return list(schema.names) 

860 

861 

862class DataFrameSchema: 

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

864 

865 Parameters 

866 ---------- 

867 dataframe : `pandas.DataFrame` 

868 Dataframe to turn into a schema. 

869 """ 

870 

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

872 import pandas as pd 

873 

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

875 

876 if isinstance(self._schema.index, pd.RangeIndex) and self._schema.index.name is not None: 876 ↛ 879line 876 didn't jump to line 879 because the condition on line 876 was never true

877 # Turn the RangeIndex into a regular index or it won't 

878 # give us all the columns via arrow. 

879 self._schema.index = pd.Index(self._schema.index.to_numpy(), name=self._schema.index.name) 

880 

881 @classmethod 

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

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

884 

885 Parameters 

886 ---------- 

887 schema : `pyarrow.Schema` 

888 The pyarrow schema to convert. 

889 

890 Returns 

891 ------- 

892 dataframe_schema : `DataFrameSchema` 

893 Converted dataframe schema. 

894 """ 

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

896 

897 return cls(empty_table.to_pandas()) 

898 

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

900 """Convert to an arrow schema. 

901 

902 Returns 

903 ------- 

904 arrow_schema : `pyarrow.Schema` 

905 Converted pyarrow schema. 

906 """ 

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

908 

909 return arrow_table.schema 

910 

911 def to_arrow_numpy_schema(self) -> ArrowNumpySchema: 

912 """Convert to an `ArrowNumpySchema`. 

913 

914 Returns 

915 ------- 

916 arrow_numpy_schema : `ArrowNumpySchema` 

917 Converted arrow numpy schema. 

918 """ 

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

920 

921 def to_arrow_astropy_schema(self) -> ArrowAstropySchema: 

922 """Convert to an ArrowAstropySchema. 

923 

924 Returns 

925 ------- 

926 arrow_astropy_schema : `ArrowAstropySchema` 

927 Converted arrow astropy schema. 

928 """ 

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

930 

931 @property 

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

933 return self._schema 

934 

935 def __repr__(self) -> str: 

936 return repr(self._schema) 

937 

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

939 if not isinstance(other, DataFrameSchema): 

940 return NotImplemented 

941 

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

943 

944 

945class ArrowAstropySchema: 

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

947 

948 Parameters 

949 ---------- 

950 astropy_table : `astropy.table.Table` 

951 Input astropy table. 

952 """ 

953 

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

955 self._schema = astropy_table[:0] 

956 

957 @classmethod 

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

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

960 

961 Parameters 

962 ---------- 

963 schema : `pyarrow.Schema` 

964 Input pyarrow schema. 

965 

966 Returns 

967 ------- 

968 astropy_schema : `ArrowAstropySchema` 

969 Converted arrow astropy schema. 

970 """ 

971 import numpy as np 

972 from astropy.table import Table 

973 

974 dtype = _schema_to_dtype_list(schema) 

975 

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

977 astropy_table = Table(data=data) 

978 

979 _apply_astropy_metadata(astropy_table, schema) 

980 

981 return cls(astropy_table) 

982 

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

984 """Convert to an arrow schema. 

985 

986 Returns 

987 ------- 

988 arrow_schema : `pyarrow.Schema` 

989 Converted pyarrow schema. 

990 """ 

991 return astropy_to_arrow(self._schema).schema 

992 

993 def to_dataframe_schema(self) -> DataFrameSchema: 

994 """Convert to a DataFrameSchema. 

995 

996 Returns 

997 ------- 

998 dataframe_schema : `DataFrameSchema` 

999 Converted dataframe schema. 

1000 """ 

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

1002 

1003 def to_arrow_numpy_schema(self) -> ArrowNumpySchema: 

1004 """Convert to an `ArrowNumpySchema`. 

1005 

1006 Returns 

1007 ------- 

1008 arrow_numpy_schema : `ArrowNumpySchema` 

1009 Converted arrow numpy schema. 

1010 """ 

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

1012 

1013 @property 

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

1015 return self._schema 

1016 

1017 def __repr__(self) -> str: 

1018 return repr(self._schema) 

1019 

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

1021 if not isinstance(other, ArrowAstropySchema): 

1022 return NotImplemented 

1023 

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

1025 # same column names. 

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

1027 return False 

1028 

1029 for name in self._schema.columns: 

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

1031 return False 

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

1033 return False 

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

1035 return False 

1036 

1037 return True 

1038 

1039 

1040class ArrowNumpySchema: 

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

1042 

1043 Parameters 

1044 ---------- 

1045 numpy_dtype : `numpy.dtype` 

1046 Numpy dtype to convert. 

1047 """ 

1048 

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

1050 self._dtype = numpy_dtype 

1051 

1052 @classmethod 

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

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

1055 

1056 Parameters 

1057 ---------- 

1058 schema : `pyarrow.Schema` 

1059 Pyarrow schema to convert. 

1060 

1061 Returns 

1062 ------- 

1063 numpy_schema : `ArrowNumpySchema` 

1064 Converted arrow numpy schema. 

1065 """ 

1066 import numpy as np 

1067 

1068 dtype = _schema_to_dtype_list(schema) 

1069 

1070 return cls(np.dtype(dtype)) 

1071 

1072 def to_arrow_astropy_schema(self) -> ArrowAstropySchema: 

1073 """Convert to an `ArrowAstropySchema`. 

1074 

1075 Returns 

1076 ------- 

1077 astropy_schema : `ArrowAstropySchema` 

1078 Converted arrow astropy schema. 

1079 """ 

1080 import numpy as np 

1081 

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

1083 

1084 def to_dataframe_schema(self) -> DataFrameSchema: 

1085 """Convert to a `DataFrameSchema`. 

1086 

1087 Returns 

1088 ------- 

1089 dataframe_schema : `DataFrameSchema` 

1090 Converted dataframe schema. 

1091 """ 

1092 import numpy as np 

1093 

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

1095 

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

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

1098 

1099 Returns 

1100 ------- 

1101 arrow_schema : `pyarrow.Schema` 

1102 Converted pyarrow schema. 

1103 """ 

1104 import numpy as np 

1105 

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

1107 

1108 @property 

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

1110 return self._dtype 

1111 

1112 def __repr__(self) -> str: 

1113 return repr(self._dtype) 

1114 

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

1116 if not isinstance(other, ArrowNumpySchema): 

1117 return NotImplemented 

1118 

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

1120 return False 

1121 

1122 return True 

1123 

1124 

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

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

1127 

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

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

1130 tuple. 

1131 

1132 Parameters 

1133 ---------- 

1134 n : `int` 

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

1136 reconstructed. 

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

1138 Strings to be split. 

1139 

1140 Returns 

1141 ------- 

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

1143 A list of multi-index column name tuples. 

1144 """ 

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

1146 

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

1148 for name in names: 

1149 m = re.search(pattern, name) 

1150 if m is not None: 

1151 column_names.append(m.groups()) 

1152 

1153 return column_names 

1154 

1155 

1156def _standardize_multi_index_columns( 

1157 pd_index: pd.MultiIndex, 

1158 columns: Any, 

1159 stringify: bool = True, 

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

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

1162 into a string directly understandable by PyArrow. 

1163 

1164 Parameters 

1165 ---------- 

1166 pd_index : `pandas.MultiIndex` 

1167 Pandas multi-index. 

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

1169 Columns to standardize. 

1170 stringify : `bool`, optional 

1171 Should the column names be stringified? 

1172 

1173 Returns 

1174 ------- 

1175 names : `list` [`str`] 

1176 Stringified representation of a multi-index column name. 

1177 """ 

1178 index_level_names = tuple(pd_index.names) 

1179 

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

1181 

1182 if isinstance(columns, list): 

1183 for requested in columns: 

1184 if not isinstance(requested, tuple): 

1185 raise ValueError( 

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

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

1188 ) 

1189 if stringify: 

1190 names.append(str(requested)) 

1191 else: 

1192 names.append(requested) 

1193 else: 

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

1195 raise ValueError( 

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

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

1198 ) 

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

1200 raise ValueError( 

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

1202 ) 

1203 factors = [ 

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

1205 for i, level in enumerate(index_level_names) 

1206 ] 

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

1208 for i, value in enumerate(requested): 

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

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

1211 if stringify: 

1212 names.append(str(requested)) 

1213 else: 

1214 names.append(requested) 

1215 

1216 return names 

1217 

1218 

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

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

1221 

1222 Parameters 

1223 ---------- 

1224 astropy_table : `astropy.table.Table` 

1225 Table to apply metadata. 

1226 arrow_schema : `pyarrow.Schema` 

1227 Arrow schema with metadata. 

1228 """ 

1229 from astropy.table import meta 

1230 

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

1232 

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

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

1235 if meta_yaml: 

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

1237 meta_hdr = meta.get_header_from_yaml(meta_yaml) 

1238 

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

1240 # metadata that was serialized with the table. 

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

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

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

1244 if attr in header_cols[col.name]: 

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

1246 

1247 if "meta" in meta_hdr: 

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

1249 else: 

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

1251 # metadata. 

1252 for name in arrow_schema.names: 

1253 field_metadata = arrow_schema.field(name).metadata 

1254 if field_metadata is None: 

1255 continue 

1256 if ( 

1257 b"description" in field_metadata 

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

1259 ): 

1260 astropy_table[name].description = description 

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

1262 astropy_table[name].unit = unit 

1263 

1264 # Ensure that the special ASTROPY_PANDAS_INDEX_KEY is propagated to 

1265 # the table metadata. 

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

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

1268 

1269 

1270def _arrow_string_to_numpy_dtype( 

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

1272) -> str: 

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

1274 

1275 Parameters 

1276 ---------- 

1277 schema : `pyarrow.Schema` 

1278 Arrow table schema. 

1279 name : `str` 

1280 Column name. 

1281 numpy_column : `numpy.ndarray`, optional 

1282 Column to determine numpy string dtype. 

1283 default_length : `int`, optional 

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

1285 from column. 

1286 

1287 Returns 

1288 ------- 

1289 dtype_str : `str` 

1290 Numpy dtype string. 

1291 """ 

1292 # Special-case for string and binary columns 

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

1294 strlen = default_length 

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

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

1297 # String/bytes length from header. 

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

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

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

1301 strlen = max(lengths) if lengths else 0 

1302 

1303 dtype = f"U{strlen}" if _is_string(schema.field(name).type) else f"|S{strlen}" 

1304 

1305 return dtype 

1306 

1307 

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

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

1310 

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

1312 string and byte columns. 

1313 

1314 Parameters 

1315 ---------- 

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

1317 Metadata dictionary; modified in place. 

1318 name : `str` 

1319 Column name. 

1320 dtype : `np.dtype` 

1321 Numpy dtype. 

1322 """ 

1323 import numpy as np 

1324 

1325 if dtype.type is np.str_: 

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

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

1328 elif dtype.type is np.bytes_: 

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

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

1331 

1332 

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

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

1335 

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

1337 multi-dimensional columns. 

1338 

1339 Parameters 

1340 ---------- 

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

1342 Metadata dictionary; modified in place. 

1343 name : `str` 

1344 Column name. 

1345 dtype : `np.dtype` 

1346 Numpy dtype. 

1347 """ 

1348 if len(dtype.shape) > 1: 

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

1350 

1351 

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

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

1354 

1355 Parameters 

1356 ---------- 

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

1358 Metadata dictionary. 

1359 list_size : `int` 

1360 Size of the list datatype. 

1361 name : `str` 

1362 Column name. 

1363 

1364 Returns 

1365 ------- 

1366 shape : `tuple` [`int`] 

1367 Shape associated with the column. 

1368 

1369 Raises 

1370 ------ 

1371 RuntimeError 

1372 Raised if metadata is found but has incorrect format. 

1373 """ 

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

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

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

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

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

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

1380 else: 

1381 shape = (list_size,) 

1382 

1383 return shape 

1384 

1385 

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

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

1388 

1389 Parameters 

1390 ---------- 

1391 schema : `pyarrow.Schema` 

1392 Input pyarrow schema. 

1393 

1394 Returns 

1395 ------- 

1396 dtype_list: `list` [`tuple`] 

1397 A list with name, type pairs. 

1398 """ 

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

1400 

1401 dtype: list[Any] = [] 

1402 for name in schema.names: 

1403 t = schema.field(name).type 

1404 if isinstance(t, pa.FixedSizeListType): 

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

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

1407 elif not (_is_string(t) or _is_binary(t)): 

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

1409 else: 

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

1411 

1412 return dtype 

1413 

1414 

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

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

1417 

1418 Parameters 

1419 ---------- 

1420 dtype : `numpy.dtype` 

1421 Numpy dtype to convert. 

1422 

1423 Returns 

1424 ------- 

1425 type_list : `list` [`object`] 

1426 Converted list of arrow types. 

1427 """ 

1428 from math import prod 

1429 

1430 import numpy as np 

1431 

1432 type_list: list[Any] = [] 

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

1434 return type_list 

1435 

1436 for name in dtype.names: 

1437 dt = dtype[name] 

1438 arrow_type: Any 

1439 if len(dt.shape) > 0: 

1440 arrow_type = pa.list_( 

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

1442 prod(dt.shape), 

1443 ) 

1444 elif dt.type == np.datetime64: 

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

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

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

1448 arrow_type = pa.timestamp(time_unit) 

1449 else: 

1450 try: 

1451 arrow_type = pa.from_numpy_dtype(dt.type) 

1452 except pa.ArrowNotImplementedError as e: 

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

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

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

1456 e.add_note(msg) 

1457 raise 

1458 type_list.append((name, arrow_type)) 

1459 

1460 return type_list 

1461 

1462 

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

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

1465 

1466 Parameters 

1467 ---------- 

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

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

1470 

1471 Returns 

1472 ------- 

1473 dtype : `numpy.dtype` 

1474 dtype of equivalent table. 

1475 rowcount : `int` 

1476 Number of rows in the table. 

1477 

1478 Raises 

1479 ------ 

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

1481 """ 

1482 import numpy as np 

1483 

1484 dtype_list: list[tuple] = [] 

1485 rowcount = 0 

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

1487 if rowcount == 0: 

1488 rowcount = len(col) 

1489 if len(col) != rowcount: 

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

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

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

1493 else: 

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

1495 dtype = np.dtype(dtype_list) 

1496 

1497 return (dtype, rowcount) 

1498 

1499 

1500def _numpy_style_arrays_to_arrow_arrays( 

1501 dtype: np.dtype, 

1502 rowcount: int, 

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

1504 schema: pa.Schema, 

1505) -> list[pa.Array]: 

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

1507 

1508 Parameters 

1509 ---------- 

1510 dtype : `numpy.dtype` 

1511 Numpy dtype of input table/arrays. 

1512 rowcount : `int` 

1513 Number of rows in input table/arrays. 

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

1515 or `astropy.table.Table` 

1516 Arrays to convert to arrow. 

1517 schema : `pyarrow.Schema` 

1518 Schema of arrow table. 

1519 

1520 Returns 

1521 ------- 

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

1523 List of converted pyarrow arrays. 

1524 """ 

1525 import numpy as np 

1526 

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

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

1529 return arrow_arrays 

1530 

1531 for name in dtype.names: 

1532 dt = dtype[name] 

1533 val: Any 

1534 if len(dt.shape) > 0: 

1535 if rowcount > 0: 

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

1537 else: 

1538 val = [] 

1539 else: 

1540 val = np_style_arrays[name] 

1541 

1542 try: 

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

1544 except pa.ArrowNotImplementedError as err: 

1545 # Check if val is big-endian. 

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

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

1548 ): 

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

1550 val2 = val.byteswap() 

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

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

1553 else: 

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

1555 raise err 

1556 

1557 return arrow_arrays 

1558 

1559 

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

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

1562 

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

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

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

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

1567 ``snappy`` compression algorithm. 

1568 

1569 Parameters 

1570 ---------- 

1571 schema : `pyarrow.Schema` 

1572 Arrow table schema. 

1573 target_size : `int`, optional 

1574 The target size (in bytes). 

1575 

1576 Returns 

1577 ------- 

1578 row_group_size : `int` 

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

1580 """ 

1581 bit_width = 0 

1582 

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

1584 

1585 for name in schema.names: 

1586 if name.startswith("__"): 

1587 continue 

1588 

1589 t = schema.field(name).type 

1590 

1591 if _is_string(t) or _is_binary(t): 

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

1593 

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

1595 # String/bytes length from header. 

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

1597 else: 

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

1599 strlen = 10 

1600 

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

1602 t_width = 8 * strlen 

1603 elif isinstance(t, pa.FixedSizeListType): 

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

1605 t_width = 0 

1606 else: 

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

1608 elif t == pa.null(): 

1609 t_width = 0 

1610 elif isinstance(t, pa.ListType): 

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

1612 t_width = 0 

1613 else: 

1614 # This is a variable length list, just choose 

1615 # something arbitrary. 

1616 t_width = 10 * t.value_type.bit_width 

1617 else: 

1618 t_width = t.bit_width 

1619 

1620 bit_width += t_width 

1621 

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

1623 if bit_width < 8: 

1624 bit_width = 8 

1625 

1626 byte_width = bit_width // 8 

1627 

1628 return target_size // byte_width 

1629 

1630 

1631def _is_string(t: pa.DataType) -> bool: 

1632 return pa.types.is_string(t) or pa.types.is_large_string(t) or pa.types.is_string_view(t) 

1633 

1634 

1635def _is_binary(t: pa.DataType) -> bool: 

1636 return pa.types.is_binary(t) or pa.types.is_large_binary(t) or pa.types.is_binary_view(t)