parquet.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  1. import io
  2. import json
  3. import warnings
  4. from typing import Literal
  5. import fsspec
  6. from .core import url_to_fs
  7. from .spec import AbstractBufferedFile
  8. from .utils import merge_offset_ranges
  9. # Parquet-Specific Utilities for fsspec
  10. #
  11. # Most of the functions defined in this module are NOT
  12. # intended for public consumption. The only exception
  13. # to this is `open_parquet_file`, which should be used
  14. # place of `fs.open()` to open parquet-formatted files
  15. # on remote file systems.
  16. class AlreadyBufferedFile(AbstractBufferedFile):
  17. def _fetch_range(self, start, end):
  18. raise NotImplementedError
  19. def open_parquet_files(
  20. path: list[str],
  21. mode: Literal["rb"] = "rb",
  22. fs: None | fsspec.AbstractFileSystem = None,
  23. metadata=None,
  24. columns: None | list[str] = None,
  25. row_groups: None | list[int] = None,
  26. storage_options: None | dict = None,
  27. engine: str = "auto",
  28. max_gap: int = 64_000,
  29. max_block: int = 256_000_000,
  30. footer_sample_size: int = 1_000_000,
  31. filters: None | list[list[list[str]]] = None,
  32. **kwargs,
  33. ):
  34. """
  35. Return a file-like object for a single Parquet file.
  36. The specified parquet `engine` will be used to parse the
  37. footer metadata, and determine the required byte ranges
  38. from the file. The target path will then be opened with
  39. the "parts" (`KnownPartsOfAFile`) caching strategy.
  40. Note that this method is intended for usage with remote
  41. file systems, and is unlikely to improve parquet-read
  42. performance on local file systems.
  43. Parameters
  44. ----------
  45. path: str
  46. Target file path.
  47. mode: str, optional
  48. Mode option to be passed through to `fs.open`. Default is "rb".
  49. metadata: Any, optional
  50. Parquet metadata object. Object type must be supported
  51. by the backend parquet engine. For now, only the "fastparquet"
  52. engine supports an explicit `ParquetFile` metadata object.
  53. If a metadata object is supplied, the remote footer metadata
  54. will not need to be transferred into local memory.
  55. fs: AbstractFileSystem, optional
  56. Filesystem object to use for opening the file. If nothing is
  57. specified, an `AbstractFileSystem` object will be inferred.
  58. engine : str, default "auto"
  59. Parquet engine to use for metadata parsing. Allowed options
  60. include "fastparquet", "pyarrow", and "auto". The specified
  61. engine must be installed in the current environment. If
  62. "auto" is specified, and both engines are installed,
  63. "fastparquet" will take precedence over "pyarrow".
  64. columns: list, optional
  65. List of all column names that may be read from the file.
  66. row_groups : list, optional
  67. List of all row-groups that may be read from the file. This
  68. may be a list of row-group indices (integers), or it may be
  69. a list of `RowGroup` metadata objects (if the "fastparquet"
  70. engine is used).
  71. storage_options : dict, optional
  72. Used to generate an `AbstractFileSystem` object if `fs` was
  73. not specified.
  74. max_gap : int, optional
  75. Neighboring byte ranges will only be merged when their
  76. inter-range gap is <= `max_gap`. Default is 64KB.
  77. max_block : int, optional
  78. Neighboring byte ranges will only be merged when the size of
  79. the aggregated range is <= `max_block`. Default is 256MB.
  80. footer_sample_size : int, optional
  81. Number of bytes to read from the end of the path to look
  82. for the footer metadata. If the sampled bytes do not contain
  83. the footer, a second read request will be required, and
  84. performance will suffer. Default is 1MB.
  85. filters : list[list], optional
  86. List of filters to apply to prevent reading row groups, of the
  87. same format as accepted by the loading engines. Ignored if
  88. ``row_groups`` is specified.
  89. **kwargs :
  90. Optional key-word arguments to pass to `fs.open`
  91. """
  92. # Make sure we have an `AbstractFileSystem` object
  93. # to work with
  94. if fs is None:
  95. path0 = path
  96. if isinstance(path, (list, tuple)):
  97. path = path[0]
  98. fs, path = url_to_fs(path, **(storage_options or {}))
  99. else:
  100. path0 = path
  101. # For now, `columns == []` not supported, is the same
  102. # as all columns
  103. if columns is not None and len(columns) == 0:
  104. columns = None
  105. # Set the engine
  106. engine = _set_engine(engine)
  107. if isinstance(path0, (list, tuple)):
  108. paths = path0
  109. elif "*" in path:
  110. paths = fs.glob(path)
  111. elif path0.endswith("/"): # or fs.isdir(path):
  112. paths = [
  113. _
  114. for _ in fs.find(path, withdirs=False, detail=False)
  115. if _.endswith((".parquet", ".parq"))
  116. ]
  117. else:
  118. paths = [path]
  119. data = _get_parquet_byte_ranges(
  120. paths,
  121. fs,
  122. metadata=metadata,
  123. columns=columns,
  124. row_groups=row_groups,
  125. engine=engine,
  126. max_gap=max_gap,
  127. max_block=max_block,
  128. footer_sample_size=footer_sample_size,
  129. filters=filters,
  130. )
  131. # Call self.open with "parts" caching
  132. options = kwargs.pop("cache_options", {}).copy()
  133. return [
  134. AlreadyBufferedFile(
  135. fs=None,
  136. path=fn,
  137. mode=mode,
  138. cache_type="parts",
  139. cache_options={
  140. **options,
  141. "data": data.get(fn, {}),
  142. },
  143. size=max(_[1] for _ in data.get(fn, {})),
  144. **kwargs,
  145. )
  146. for fn in data
  147. ]
  148. def open_parquet_file(*args, **kwargs):
  149. """Create files tailed to reading specific parts of parquet files
  150. Please see ``open_parquet_files`` for details of the arguments. The
  151. difference is, this function always returns a single ``AleadyBufferedFile``,
  152. whereas `open_parquet_files`` always returns a list of files, even if
  153. there are one or zero matching parquet files.
  154. """
  155. return open_parquet_files(*args, **kwargs)[0]
  156. def _get_parquet_byte_ranges(
  157. paths,
  158. fs,
  159. metadata=None,
  160. columns=None,
  161. row_groups=None,
  162. max_gap=64_000,
  163. max_block=256_000_000,
  164. footer_sample_size=1_000_000,
  165. engine="auto",
  166. filters=None,
  167. ):
  168. """Get a dictionary of the known byte ranges needed
  169. to read a specific column/row-group selection from a
  170. Parquet dataset. Each value in the output dictionary
  171. is intended for use as the `data` argument for the
  172. `KnownPartsOfAFile` caching strategy of a single path.
  173. """
  174. # Set engine if necessary
  175. if isinstance(engine, str):
  176. engine = _set_engine(engine)
  177. # Pass to specialized function if metadata is defined
  178. if metadata is not None:
  179. # Use the provided parquet metadata object
  180. # to avoid transferring/parsing footer metadata
  181. return _get_parquet_byte_ranges_from_metadata(
  182. metadata,
  183. fs,
  184. engine,
  185. columns=columns,
  186. row_groups=row_groups,
  187. max_gap=max_gap,
  188. max_block=max_block,
  189. filters=filters,
  190. )
  191. # Get file sizes asynchronously
  192. file_sizes = fs.sizes(paths)
  193. # Populate global paths, starts, & ends
  194. result = {}
  195. data_paths = []
  196. data_starts = []
  197. data_ends = []
  198. add_header_magic = True
  199. if columns is None and row_groups is None and filters is None:
  200. # We are NOT selecting specific columns or row-groups.
  201. #
  202. # We can avoid sampling the footers, and just transfer
  203. # all file data with cat_ranges
  204. for i, path in enumerate(paths):
  205. result[path] = {}
  206. data_paths.append(path)
  207. data_starts.append(0)
  208. data_ends.append(file_sizes[i])
  209. add_header_magic = False # "Magic" should already be included
  210. else:
  211. # We ARE selecting specific columns or row-groups.
  212. #
  213. # Gather file footers.
  214. # We just take the last `footer_sample_size` bytes of each
  215. # file (or the entire file if it is smaller than that)
  216. footer_starts = []
  217. footer_ends = []
  218. for i, path in enumerate(paths):
  219. footer_ends.append(file_sizes[i])
  220. sample_size = max(0, file_sizes[i] - footer_sample_size)
  221. footer_starts.append(sample_size)
  222. footer_samples = fs.cat_ranges(paths, footer_starts, footer_ends)
  223. # Check our footer samples and re-sample if necessary.
  224. missing_footer_starts = footer_starts.copy()
  225. large_footer = 0
  226. for i, path in enumerate(paths):
  227. footer_size = int.from_bytes(footer_samples[i][-8:-4], "little")
  228. real_footer_start = file_sizes[i] - (footer_size + 8)
  229. if real_footer_start < footer_starts[i]:
  230. missing_footer_starts[i] = real_footer_start
  231. large_footer = max(large_footer, (footer_size + 8))
  232. if large_footer:
  233. warnings.warn(
  234. f"Not enough data was used to sample the parquet footer. "
  235. f"Try setting footer_sample_size >= {large_footer}."
  236. )
  237. for i, block in enumerate(
  238. fs.cat_ranges(
  239. paths,
  240. missing_footer_starts,
  241. footer_starts,
  242. )
  243. ):
  244. footer_samples[i] = block + footer_samples[i]
  245. footer_starts[i] = missing_footer_starts[i]
  246. # Calculate required byte ranges for each path
  247. for i, path in enumerate(paths):
  248. # Use "engine" to collect data byte ranges
  249. path_data_starts, path_data_ends = engine._parquet_byte_ranges(
  250. columns,
  251. row_groups=row_groups,
  252. footer=footer_samples[i],
  253. footer_start=footer_starts[i],
  254. filters=filters,
  255. )
  256. data_paths += [path] * len(path_data_starts)
  257. data_starts += path_data_starts
  258. data_ends += path_data_ends
  259. result.setdefault(path, {})[(footer_starts[i], file_sizes[i])] = (
  260. footer_samples[i]
  261. )
  262. # Merge adjacent offset ranges
  263. data_paths, data_starts, data_ends = merge_offset_ranges(
  264. data_paths,
  265. data_starts,
  266. data_ends,
  267. max_gap=max_gap,
  268. max_block=max_block,
  269. sort=False, # Should already be sorted
  270. )
  271. # Start by populating `result` with footer samples
  272. for i, path in enumerate(paths):
  273. result[path] = {(footer_starts[i], footer_ends[i]): footer_samples[i]}
  274. # Transfer the data byte-ranges into local memory
  275. _transfer_ranges(fs, result, data_paths, data_starts, data_ends)
  276. # Add b"PAR1" to header if necessary
  277. if add_header_magic:
  278. _add_header_magic(result)
  279. return result
  280. def _get_parquet_byte_ranges_from_metadata(
  281. metadata,
  282. fs,
  283. engine,
  284. columns=None,
  285. row_groups=None,
  286. max_gap=64_000,
  287. max_block=256_000_000,
  288. filters=None,
  289. ):
  290. """Simplified version of `_get_parquet_byte_ranges` for
  291. the case that an engine-specific `metadata` object is
  292. provided, and the remote footer metadata does not need to
  293. be transferred before calculating the required byte ranges.
  294. """
  295. # Use "engine" to collect data byte ranges
  296. data_paths, data_starts, data_ends = engine._parquet_byte_ranges(
  297. columns, row_groups=row_groups, metadata=metadata, filters=filters
  298. )
  299. # Merge adjacent offset ranges
  300. data_paths, data_starts, data_ends = merge_offset_ranges(
  301. data_paths,
  302. data_starts,
  303. data_ends,
  304. max_gap=max_gap,
  305. max_block=max_block,
  306. sort=False, # Should be sorted
  307. )
  308. # Transfer the data byte-ranges into local memory
  309. result = {fn: {} for fn in list(set(data_paths))}
  310. _transfer_ranges(fs, result, data_paths, data_starts, data_ends)
  311. # Add b"PAR1" to header
  312. _add_header_magic(result)
  313. return result
  314. def _transfer_ranges(fs, blocks, paths, starts, ends):
  315. # Use cat_ranges to gather the data byte_ranges
  316. ranges = (paths, starts, ends)
  317. for path, start, stop, data in zip(*ranges, fs.cat_ranges(*ranges)):
  318. blocks[path][(start, stop)] = data
  319. def _add_header_magic(data):
  320. # Add b"PAR1" to file headers
  321. for path in list(data.keys()):
  322. add_magic = True
  323. for k in data[path]:
  324. if k[0] == 0 and k[1] >= 4:
  325. add_magic = False
  326. break
  327. if add_magic:
  328. data[path][(0, 4)] = b"PAR1"
  329. def _set_engine(engine_str):
  330. # Define a list of parquet engines to try
  331. if engine_str == "auto":
  332. try_engines = ("fastparquet", "pyarrow")
  333. elif not isinstance(engine_str, str):
  334. raise ValueError(
  335. "Failed to set parquet engine! "
  336. "Please pass 'fastparquet', 'pyarrow', or 'auto'"
  337. )
  338. elif engine_str not in ("fastparquet", "pyarrow"):
  339. raise ValueError(f"{engine_str} engine not supported by `fsspec.parquet`")
  340. else:
  341. try_engines = [engine_str]
  342. # Try importing the engines in `try_engines`,
  343. # and choose the first one that succeeds
  344. for engine in try_engines:
  345. try:
  346. if engine == "fastparquet":
  347. return FastparquetEngine()
  348. elif engine == "pyarrow":
  349. return PyarrowEngine()
  350. except ImportError:
  351. pass
  352. # Raise an error if a supported parquet engine
  353. # was not found
  354. raise ImportError(
  355. f"The following parquet engines are not installed "
  356. f"in your python environment: {try_engines}."
  357. f"Please install 'fastparquert' or 'pyarrow' to "
  358. f"utilize the `fsspec.parquet` module."
  359. )
  360. class FastparquetEngine:
  361. # The purpose of the FastparquetEngine class is
  362. # to check if fastparquet can be imported (on initialization)
  363. # and to define a `_parquet_byte_ranges` method. In the
  364. # future, this class may also be used to define other
  365. # methods/logic that are specific to fastparquet.
  366. def __init__(self):
  367. import fastparquet as fp
  368. self.fp = fp
  369. def _row_group_filename(self, row_group, pf):
  370. return pf.row_group_filename(row_group)
  371. def _parquet_byte_ranges(
  372. self,
  373. columns,
  374. row_groups=None,
  375. metadata=None,
  376. footer=None,
  377. footer_start=None,
  378. filters=None,
  379. ):
  380. # Initialize offset ranges and define ParqetFile metadata
  381. pf = metadata
  382. data_paths, data_starts, data_ends = [], [], []
  383. if filters and row_groups:
  384. raise ValueError("filters and row_groups cannot be used together")
  385. if pf is None:
  386. pf = self.fp.ParquetFile(io.BytesIO(footer))
  387. # Convert columns to a set and add any index columns
  388. # specified in the pandas metadata (just in case)
  389. column_set = None if columns is None else {c.split(".", 1)[0] for c in columns}
  390. if column_set is not None and hasattr(pf, "pandas_metadata"):
  391. md_index = [
  392. ind
  393. for ind in pf.pandas_metadata.get("index_columns", [])
  394. # Ignore RangeIndex information
  395. if not isinstance(ind, dict)
  396. ]
  397. column_set |= set(md_index)
  398. # Check if row_groups is a list of integers
  399. # or a list of row-group metadata
  400. if filters:
  401. from fastparquet.api import filter_row_groups
  402. row_group_indices = None
  403. row_groups = filter_row_groups(pf, filters)
  404. elif row_groups and not isinstance(row_groups[0], int):
  405. # Input row_groups contains row-group metadata
  406. row_group_indices = None
  407. else:
  408. # Input row_groups contains row-group indices
  409. row_group_indices = row_groups
  410. row_groups = pf.row_groups
  411. # Loop through column chunks to add required byte ranges
  412. for r, row_group in enumerate(row_groups):
  413. # Skip this row-group if we are targeting
  414. # specific row-groups
  415. if row_group_indices is None or r in row_group_indices:
  416. # Find the target parquet-file path for `row_group`
  417. fn = self._row_group_filename(row_group, pf)
  418. for column in row_group.columns:
  419. name = column.meta_data.path_in_schema[0]
  420. # Skip this column if we are targeting a
  421. # specific columns
  422. if column_set is None or name in column_set:
  423. file_offset0 = column.meta_data.dictionary_page_offset
  424. if file_offset0 is None:
  425. file_offset0 = column.meta_data.data_page_offset
  426. num_bytes = column.meta_data.total_compressed_size
  427. if footer_start is None or file_offset0 < footer_start:
  428. data_paths.append(fn)
  429. data_starts.append(file_offset0)
  430. data_ends.append(
  431. min(
  432. file_offset0 + num_bytes,
  433. footer_start or (file_offset0 + num_bytes),
  434. )
  435. )
  436. if metadata:
  437. # The metadata in this call may map to multiple
  438. # file paths. Need to include `data_paths`
  439. return data_paths, data_starts, data_ends
  440. return data_starts, data_ends
  441. class PyarrowEngine:
  442. # The purpose of the PyarrowEngine class is
  443. # to check if pyarrow can be imported (on initialization)
  444. # and to define a `_parquet_byte_ranges` method. In the
  445. # future, this class may also be used to define other
  446. # methods/logic that are specific to pyarrow.
  447. def __init__(self):
  448. import pyarrow.parquet as pq
  449. self.pq = pq
  450. def _row_group_filename(self, row_group, metadata):
  451. raise NotImplementedError
  452. def _parquet_byte_ranges(
  453. self,
  454. columns,
  455. row_groups=None,
  456. metadata=None,
  457. footer=None,
  458. footer_start=None,
  459. filters=None,
  460. ):
  461. if metadata is not None:
  462. raise ValueError("metadata input not supported for PyarrowEngine")
  463. if filters:
  464. raise NotImplementedError
  465. data_starts, data_ends = [], []
  466. md = self.pq.ParquetFile(io.BytesIO(footer)).metadata
  467. # Convert columns to a set and add any index columns
  468. # specified in the pandas metadata (just in case)
  469. column_set = None if columns is None else set(columns)
  470. if column_set is not None:
  471. schema = md.schema.to_arrow_schema()
  472. has_pandas_metadata = (
  473. schema.metadata is not None and b"pandas" in schema.metadata
  474. )
  475. if has_pandas_metadata:
  476. md_index = [
  477. ind
  478. for ind in json.loads(
  479. schema.metadata[b"pandas"].decode("utf8")
  480. ).get("index_columns", [])
  481. # Ignore RangeIndex information
  482. if not isinstance(ind, dict)
  483. ]
  484. column_set |= set(md_index)
  485. # Loop through column chunks to add required byte ranges
  486. for r in range(md.num_row_groups):
  487. # Skip this row-group if we are targeting
  488. # specific row-groups
  489. if row_groups is None or r in row_groups:
  490. row_group = md.row_group(r)
  491. for c in range(row_group.num_columns):
  492. column = row_group.column(c)
  493. name = column.path_in_schema
  494. # Skip this column if we are targeting a
  495. # specific columns
  496. split_name = name.split(".")[0]
  497. if (
  498. column_set is None
  499. or name in column_set
  500. or split_name in column_set
  501. ):
  502. file_offset0 = column.dictionary_page_offset
  503. if file_offset0 is None:
  504. file_offset0 = column.data_page_offset
  505. num_bytes = column.total_compressed_size
  506. if file_offset0 < footer_start:
  507. data_starts.append(file_offset0)
  508. data_ends.append(
  509. min(file_offset0 + num_bytes, footer_start)
  510. )
  511. return data_starts, data_ends