Skip to main content

nautilus_persistence/python/backend/parquet/
catalog.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16#![expect(
17    clippy::too_many_arguments,
18    reason = "PyO3 catalog wrapper mirrors Python API dispatch surface"
19)]
20
21use std::collections::HashMap;
22
23use nautilus_common::{
24    config::ConfigError, enums::Environment, python::config_error_to_pyvalue_err,
25};
26use nautilus_core::{
27    UnixNanos,
28    python::{to_pytype_err, to_pyvalue_err},
29};
30use nautilus_model::{
31    data::{
32        Bar, Data, FundingRateUpdate, HasTsInit, IndexPriceUpdate, InstrumentStatus,
33        MarkPriceUpdate, NautilusDataType, OptionGreeks, OrderBookDelta, OrderBookDepth, QuoteTick,
34        TradeTick, close::InstrumentClose,
35    },
36    python::{
37        data::data_to_pyobject,
38        instruments::{
39            PyNautilusInstrumentType, instrument_any_to_pyobject, pyobject_to_instrument_any,
40        },
41    },
42};
43use nautilus_serialization::{
44    arrow::{
45        DecodeTypedFromRecordBatch, EncodeToRecordBatch, display::instrument::encode_instruments,
46    },
47    python::arrow::{arrow_record_batches_to_pyarrow_stream, arrow_record_batches_to_pybytes},
48};
49use pyo3::{
50    exceptions::PyIOError,
51    prelude::*,
52    types::{PyBytes, PyDict, PyList},
53};
54
55use crate::{
56    backend::{migration::build_catalog_migration_plan, parquet::catalog::ParquetDataCatalog},
57    catalog::{
58        traits::{CatalogQuery, CatalogReader, CatalogRecordQuery, CatalogWriter},
59        types::{HasCatalogDataType, parquet_catalog_data_type_path_prefixes},
60    },
61    config::CatalogCompression,
62    python::backend::{
63        PyCatalogDataType, arrow_ipc_batches, arrow_ipc_data_schema, arrow_ipc_record_schema,
64        arrow_record_batches_from_pybytes, catalog_metadata_to_pydict, catalog_record_type_from_py,
65        nautilus_data_type_from_py, to_pyio_err, write_record_params_from_py,
66    },
67};
68
69#[expect(
70    clippy::needless_pass_by_value,
71    reason = "PyO3 supplies owned Python data at the catalog write boundary"
72)]
73fn write_parquet_data<T>(
74    catalog: &ParquetDataCatalog,
75    data: Vec<T>,
76    start: Option<u64>,
77    end: Option<u64>,
78    skip_disjoint_check: bool,
79    label: &str,
80) -> PyResult<String>
81where
82    T: HasTsInit + EncodeToRecordBatch + HasCatalogDataType,
83{
84    catalog
85        .write_to_parquet(
86            &data,
87            start.map(UnixNanos::from),
88            end.map(UnixNanos::from),
89            Some(skip_disjoint_check),
90        )
91        .map(|path| path.to_string_lossy().to_string())
92        .map_err(|e| PyIOError::new_err(format!("Failed to write {label}: {e}")))
93}
94
95fn query_parquet_data<T>(
96    catalog: &mut ParquetDataCatalog,
97    identifiers: Option<Vec<String>>,
98    start: Option<u64>,
99    end: Option<u64>,
100    where_clause: Option<&str>,
101    files: Option<Vec<String>>,
102    optimize_file_loading: bool,
103    error_context: &str,
104) -> PyResult<Vec<T>>
105where
106    T: DecodeTypedFromRecordBatch + HasCatalogDataType + HasTsInit,
107{
108    catalog
109        .query::<T>(
110            identifiers,
111            start.map(UnixNanos::from),
112            end.map(UnixNanos::from),
113            where_clause,
114            files,
115            optimize_file_loading,
116        )
117        .map_err(|e| PyIOError::new_err(format!("{error_context}: {e}")))
118}
119
120fn reject_parquet_as_of(as_of: Option<&Bound<'_, PyAny>>) -> PyResult<()> {
121    if as_of.is_some() {
122        return Err(to_pyvalue_err("ParquetDataCatalog does not support as_of"));
123    }
124
125    Ok(())
126}
127
128/// A catalog for writing data to Parquet files.
129#[pyclass(name = "ParquetDataCatalog", module = "nautilus_trader.persistence")]
130#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.persistence")]
131pub struct PyParquetDataCatalog {
132    inner: ParquetDataCatalog,
133}
134
135#[pymethods]
136#[pyo3_stub_gen::derive::gen_stub_pymethods]
137impl PyParquetDataCatalog {
138    /// Create a new `ParquetCatalog` with the given base path and optional parameters.
139    ///
140    /// # Parameters
141    ///
142    /// - `base_path`: The base path for the catalog
143    /// - `storage_options`: Optional storage configuration for cloud backends
144    /// - `batch_size`: Optional batch size for processing (default: 10,000)
145    /// - `compression`: Optional compression code (0=UNCOMPRESSED, 1=SNAPPY, 2=GZIP, 4=BROTLI, 5=`LZ4_RAW`, 6=ZSTD)
146    /// - `max_row_group_size`: Optional maximum row group size (default: 131,072)
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if `compression` is not one of the listed codes, `batch_size` or
151    /// `max_row_group_size` is zero, or the underlying [`ParquetDataCatalog`] cannot be created.
152    #[new]
153    #[pyo3(signature = (base_path, storage_options=None, batch_size=None, compression=None, max_row_group_size=None))]
154    pub fn py_new(
155        base_path: &str,
156        storage_options: Option<HashMap<String, String>>,
157        batch_size: Option<usize>,
158        compression: Option<u8>,
159        max_row_group_size: Option<usize>,
160    ) -> PyResult<Self> {
161        let compression = compression
162            .map(catalog_compression_from_code)
163            .transpose()?
164            .map(parquet::basic::Compression::from);
165
166        // Convert HashMap to AHashMap for internal use
167        let storage_options = storage_options.map(|m| m.into_iter().collect());
168
169        let inner = ParquetDataCatalog::from_uri(
170            base_path,
171            storage_options,
172            batch_size,
173            compression,
174            max_row_group_size,
175        )
176        .map_err(|e| match e.downcast::<ConfigError>() {
177            Ok(e) => config_error_to_pyvalue_err(e),
178            Err(e) => PyIOError::new_err(format!("Failed to create ParquetDataCatalog: {e}")),
179        })?;
180
181        Ok(Self { inner })
182    }
183
184    /// Rewrites a legacy Parquet catalog into this current Parquet catalog.
185    #[pyo3(signature = (parquet_path, storage_options=None, dry_run=false))]
186    pub fn migrate_from_legacy_parquet_path(
187        mut slf: PyRefMut<'_, Self>,
188        parquet_path: &str,
189        storage_options: Option<HashMap<String, String>>,
190        dry_run: bool,
191    ) -> PyResult<usize> {
192        let storage_options = storage_options.map(|m| m.into_iter().collect());
193        let source = ParquetDataCatalog::from_uri(parquet_path, storage_options, None, None, None)
194            .map_err(to_pyio_err)?;
195        let py = slf.py();
196        if dry_run {
197            return py
198                .detach(|| {
199                    let plan = build_catalog_migration_plan(&source)?;
200                    plan.ensure_ready()?;
201                    Ok::<usize, anyhow::Error>(0)
202                })
203                .map_err(to_pyio_err);
204        }
205
206        let inner = &mut slf.inner;
207        py.detach(|| {
208            inner
209                .migrate_from_legacy_parquet_catalog(&source)
210                .map(|report| report.migrated_rows)
211        })
212        .map_err(to_pyio_err)
213    }
214
215    /// Write quote tick data to Parquet files.
216    ///
217    /// # Parameters
218    ///
219    /// - `data`: Vector of quote ticks to write
220    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
221    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
222    ///
223    /// # Returns
224    ///
225    /// Returns the path of the created file as a string.
226    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
227    pub fn write_quote_ticks(
228        &self,
229        data: Vec<QuoteTick>,
230        start: Option<u64>,
231        end: Option<u64>,
232        skip_disjoint_check: bool,
233    ) -> PyResult<String> {
234        write_parquet_data(
235            &self.inner,
236            data,
237            start,
238            end,
239            skip_disjoint_check,
240            "quote ticks",
241        )
242    }
243
244    /// Write trade tick data to Parquet files.
245    ///
246    /// # Parameters
247    ///
248    /// - `data`: Vector of trade ticks to write
249    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
250    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
251    ///
252    /// # Returns
253    ///
254    /// Returns the path of the created file as a string.
255    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
256    pub fn write_trade_ticks(
257        &self,
258        data: Vec<TradeTick>,
259        start: Option<u64>,
260        end: Option<u64>,
261        skip_disjoint_check: bool,
262    ) -> PyResult<String> {
263        write_parquet_data(
264            &self.inner,
265            data,
266            start,
267            end,
268            skip_disjoint_check,
269            "trade ticks",
270        )
271    }
272
273    /// Write order book delta data to Parquet files.
274    ///
275    /// # Parameters
276    ///
277    /// - `data`: Vector of order book deltas to write
278    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
279    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
280    ///
281    /// # Returns
282    ///
283    /// Returns the path of the created file as a string.
284    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
285    pub fn write_order_book_deltas(
286        &self,
287        data: Vec<OrderBookDelta>,
288        start: Option<u64>,
289        end: Option<u64>,
290        skip_disjoint_check: bool,
291    ) -> PyResult<String> {
292        write_parquet_data(
293            &self.inner,
294            data,
295            start,
296            end,
297            skip_disjoint_check,
298            "order book deltas",
299        )
300    }
301
302    /// Write bar data to Parquet files.
303    ///
304    /// # Parameters
305    ///
306    /// - `data`: Vector of bars to write
307    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
308    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
309    ///
310    /// # Returns
311    ///
312    /// Returns the path of the created file as a string.
313    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
314    pub fn write_bars(
315        &self,
316        data: Vec<Bar>,
317        start: Option<u64>,
318        end: Option<u64>,
319        skip_disjoint_check: bool,
320    ) -> PyResult<String> {
321        write_parquet_data(&self.inner, data, start, end, skip_disjoint_check, "bars")
322    }
323
324    /// Write order book depth data to Parquet files.
325    ///
326    /// # Parameters
327    ///
328    /// - `data`: Vector of order book depths to write
329    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
330    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
331    ///
332    /// # Returns
333    ///
334    /// Returns the path of the created file as a string.
335    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
336    pub fn write_order_book_depths(
337        &self,
338        data: Vec<OrderBookDepth>,
339        start: Option<u64>,
340        end: Option<u64>,
341        skip_disjoint_check: bool,
342    ) -> PyResult<String> {
343        write_parquet_data(
344            &self.inner,
345            data,
346            start,
347            end,
348            skip_disjoint_check,
349            "order book depths",
350        )
351    }
352
353    /// Write mark price update data to Parquet files.
354    ///
355    /// # Parameters
356    ///
357    /// - `data`: Vector of mark price updates to write
358    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
359    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
360    ///
361    /// # Returns
362    ///
363    /// Returns the path of the created file as a string.
364    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
365    pub fn write_mark_price_updates(
366        &self,
367        data: Vec<MarkPriceUpdate>,
368        start: Option<u64>,
369        end: Option<u64>,
370        skip_disjoint_check: bool,
371    ) -> PyResult<String> {
372        write_parquet_data(
373            &self.inner,
374            data,
375            start,
376            end,
377            skip_disjoint_check,
378            "mark price updates",
379        )
380    }
381
382    /// Write index price update data to Parquet files.
383    ///
384    /// # Parameters
385    ///
386    /// - `data`: Vector of index price updates to write
387    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
388    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
389    ///
390    /// # Returns
391    ///
392    /// Returns the path of the created file as a string.
393    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
394    pub fn write_index_price_updates(
395        &self,
396        data: Vec<IndexPriceUpdate>,
397        start: Option<u64>,
398        end: Option<u64>,
399        skip_disjoint_check: bool,
400    ) -> PyResult<String> {
401        write_parquet_data(
402            &self.inner,
403            data,
404            start,
405            end,
406            skip_disjoint_check,
407            "index price updates",
408        )
409    }
410
411    /// Write option greeks data to Parquet files.
412    ///
413    /// # Parameters
414    ///
415    /// - `data`: Vector of option greeks to write
416    /// - `start`: Optional start timestamp override (nanoseconds since Unix epoch)
417    /// - `end`: Optional end timestamp override (nanoseconds since Unix epoch)
418    ///
419    /// # Returns
420    ///
421    /// Returns the path of the created file as a string.
422    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
423    pub fn write_option_greeks(
424        &self,
425        data: Vec<OptionGreeks>,
426        start: Option<u64>,
427        end: Option<u64>,
428        skip_disjoint_check: bool,
429    ) -> PyResult<String> {
430        write_parquet_data(
431            &self.inner,
432            data,
433            start,
434            end,
435            skip_disjoint_check,
436            "option greeks",
437        )
438    }
439
440    /// Write instrument status data to Parquet files.
441    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
442    pub fn write_instrument_statuses(
443        &self,
444        data: Vec<InstrumentStatus>,
445        start: Option<u64>,
446        end: Option<u64>,
447        skip_disjoint_check: bool,
448    ) -> PyResult<String> {
449        write_parquet_data(
450            &self.inner,
451            data,
452            start,
453            end,
454            skip_disjoint_check,
455            "instrument statuses",
456        )
457    }
458
459    /// Write instrument close data to Parquet files.
460    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
461    pub fn write_instrument_closes(
462        &self,
463        data: Vec<InstrumentClose>,
464        start: Option<u64>,
465        end: Option<u64>,
466        skip_disjoint_check: bool,
467    ) -> PyResult<String> {
468        write_parquet_data(
469            &self.inner,
470            data,
471            start,
472            end,
473            skip_disjoint_check,
474            "instrument closes",
475        )
476    }
477
478    /// Write instruments to Parquet files in the catalog.
479    ///
480    /// Instruments are stored under `data/instruments/{instrument_id}/` using timestamp-ranged
481    /// parquet file names, allowing multiple historical versions of the same instrument to be
482    /// written across separate calls.
483    ///
484    /// # Parameters
485    ///
486    /// - `instruments`: A Python list of instrument objects (e.g. `CurrencyPair`, Equity).
487    ///
488    /// # Returns
489    ///
490    /// Returns a list of written file paths.
491    pub fn write_instruments(&self, instruments: &Bound<'_, PyAny>) -> PyResult<Vec<String>> {
492        let data = instruments;
493        let py = data.py();
494        let list = data.cast::<PyList>()?;
495        let mut instruments = Vec::with_capacity(list.len());
496        for item in list.iter() {
497            let py_item: Py<PyAny> = item.unbind();
498            let instrument = pyobject_to_instrument_any(py, py_item)?;
499            instruments.push(instrument);
500        }
501
502        self.inner
503            .write_instruments(instruments)
504            .map(|paths| {
505                paths
506                    .into_iter()
507                    .map(|p| p.to_string_lossy().to_string())
508                    .collect()
509            })
510            .map_err(|e| PyIOError::new_err(format!("Failed to write instruments: {e}")))
511    }
512
513    /// Query instruments from the catalog.
514    ///
515    /// # Parameters
516    ///
517    /// - `instrument_ids`: Optional list of instrument IDs to filter by. If `None`, returns all instruments.
518    /// - `start`: Optional inclusive lower bound for `ts_init` filtering.
519    /// - `end`: Optional inclusive upper bound for `ts_init` filtering.
520    /// - `where_clause`: Optional SQL WHERE clause for additional filtering.
521    ///
522    /// # Returns
523    ///
524    /// Returns a list of instrument objects (e.g. `CurrencyPair`, Equity).
525    #[pyo3(signature = (
526        instrument_ids=None,
527        start=None,
528        end=None,
529        where_clause=None,
530        instrument_type=None,
531    ))]
532    #[expect(clippy::needless_pass_by_value)]
533    pub fn instruments(
534        &mut self,
535        instrument_ids: Option<Vec<String>>,
536        start: Option<u64>,
537        end: Option<u64>,
538        where_clause: Option<&str>,
539        instrument_type: Option<PyRef<'_, PyNautilusInstrumentType>>,
540    ) -> PyResult<Vec<Py<PyAny>>> {
541        let instrument_type = instrument_type.map(|instrument_type| instrument_type.inner());
542        let rust_instruments = self
543            .inner
544            .query_instruments_filtered_with_where_and_type(
545                instrument_ids.as_deref(),
546                start.map(UnixNanos::from),
547                end.map(UnixNanos::from),
548                where_clause,
549                instrument_type.as_ref(),
550            )
551            .map_err(|e| PyIOError::new_err(format!("Failed to query instruments: {e}")))?;
552        Python::attach(|py| {
553            rust_instruments
554                .into_iter()
555                .map(|inst| instrument_any_to_pyobject(py, inst))
556                .collect()
557        })
558    }
559
560    /// Query instruments as display-friendly Arrow IPC stream bytes.
561    #[pyo3(signature = (
562        instrument_ids=None,
563        start=None,
564        end=None,
565        where_clause=None,
566        instrument_type=None,
567    ))]
568    #[expect(clippy::needless_pass_by_value)]
569    pub fn query_instrument_arrow_bytes(
570        &mut self,
571        py: Python<'_>,
572        instrument_ids: Option<Vec<String>>,
573        start: Option<u64>,
574        end: Option<u64>,
575        where_clause: Option<&str>,
576        instrument_type: Option<PyRef<'_, PyNautilusInstrumentType>>,
577    ) -> PyResult<Py<PyBytes>> {
578        let instrument_type = instrument_type.map(|instrument_type| instrument_type.inner());
579
580        let instruments = py
581            .detach(|| {
582                self.inner.query_instruments_filtered_with_where_and_type(
583                    instrument_ids.as_deref(),
584                    start.map(UnixNanos::from),
585                    end.map(UnixNanos::from),
586                    where_clause,
587                    instrument_type.as_ref(),
588                )
589            })
590            .map_err(|e| PyIOError::new_err(format!("Failed query instruments: {e}")))?;
591
592        let batch = encode_instruments(&instruments)
593            .map_err(|e| PyIOError::new_err(format!("Failed encode instruments: {e}")))?;
594        let schema = batch.schema().as_ref().clone();
595        arrow_record_batches_to_pybytes(py, &schema, &[batch])
596    }
597
598    /// Query instruments as an Arrow C stream `PyCapsule`.
599    #[pyo3(signature = (
600        instrument_ids=None,
601        start=None,
602        end=None,
603        where_clause=None,
604        instrument_type=None,
605    ))]
606    #[expect(clippy::needless_pass_by_value)]
607    pub fn query_instrument_arrow_stream(
608        &mut self,
609        py: Python<'_>,
610        instrument_ids: Option<Vec<String>>,
611        start: Option<u64>,
612        end: Option<u64>,
613        where_clause: Option<&str>,
614        instrument_type: Option<PyRef<'_, PyNautilusInstrumentType>>,
615    ) -> PyResult<Py<PyAny>> {
616        let instrument_type = instrument_type.map(|instrument_type| instrument_type.inner());
617
618        let instruments = py
619            .detach(|| {
620                self.inner.query_instruments_filtered_with_where_and_type(
621                    instrument_ids.as_deref(),
622                    start.map(UnixNanos::from),
623                    end.map(UnixNanos::from),
624                    where_clause,
625                    instrument_type.as_ref(),
626                )
627            })
628            .map_err(|e| PyIOError::new_err(format!("Failed query instruments: {e}")))?;
629
630        let batch = encode_instruments(&instruments)
631            .map_err(|e| PyIOError::new_err(format!("Failed encode instruments: {e}")))?;
632        let schema = batch.schema().as_ref().clone();
633        arrow_record_batches_to_pyarrow_stream(py, &schema, vec![batch])
634    }
635
636    /// Extend file names in the catalog with additional timestamp information.
637    ///
638    /// # Parameters
639    ///
640    /// - `data_type`: The stored family to target (data type, record type, or instrument type).
641    /// - `identifier`: Optional instrument ID or bar type filter
642    /// - `start`: Start timestamp (nanoseconds since Unix epoch)
643    /// - `end`: End timestamp (nanoseconds since Unix epoch)
644    #[pyo3(signature = (data_type, identifier=None, *, start, end))]
645    #[expect(clippy::needless_pass_by_value)]
646    pub fn extend_file_name(
647        &self,
648        data_type: PyCatalogDataType,
649        identifier: Option<String>,
650        start: u64,
651        end: u64,
652    ) -> PyResult<()> {
653        let data_type = data_type.into_inner();
654        let start_nanos = UnixNanos::from(start);
655        let end_nanos = UnixNanos::from(end);
656
657        self.inner
658            .extend_file_name(&data_type, identifier.as_deref(), start_nanos, end_nanos)
659            .map_err(|e| PyIOError::new_err(format!("Failed to extend file name: {e}")))
660    }
661
662    /// Consolidate all data files in the catalog within the specified time range.
663    ///
664    /// # Parameters
665    ///
666    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
667    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
668    /// - `ensure_contiguous_files`: Optional flag to ensure files are contiguous
669    /// - `deduplicate`: Optional flag to deduplicate rows when combining files
670    #[pyo3(signature = (start=None, end=None, ensure_contiguous_files=None, deduplicate=None))]
671    pub fn consolidate_catalog(
672        &self,
673        start: Option<u64>,
674        end: Option<u64>,
675        ensure_contiguous_files: Option<bool>,
676        deduplicate: Option<bool>,
677    ) -> PyResult<()> {
678        let start_nanos = start.map(UnixNanos::from);
679        let end_nanos = end.map(UnixNanos::from);
680
681        self.inner
682            .consolidate_catalog(start_nanos, end_nanos, ensure_contiguous_files, deduplicate)
683            .map_err(|e| PyIOError::new_err(format!("Failed to consolidate catalog: {e}")))
684    }
685
686    /// Consolidate data files for a specific data type within the specified time range.
687    ///
688    /// # Parameters
689    ///
690    /// - `data_type`: The stored family to target (data type, record type, or instrument type).
691    /// - `identifier`: Optional instrument ID or bar type filter
692    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
693    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
694    /// - `ensure_contiguous_files`: Optional flag to ensure files are contiguous
695    /// - `deduplicate`: Optional flag to deduplicate rows when combining files
696    #[pyo3(signature = (data_type, identifier=None, start=None, end=None, ensure_contiguous_files=None, deduplicate=None))]
697    #[expect(clippy::needless_pass_by_value)]
698    pub fn consolidate_data(
699        &mut self,
700        data_type: PyCatalogDataType,
701        identifier: Option<String>,
702        start: Option<u64>,
703        end: Option<u64>,
704        ensure_contiguous_files: Option<bool>,
705        deduplicate: Option<bool>,
706    ) -> PyResult<()> {
707        let data_type = data_type.into_inner();
708        let start_nanos = start.map(UnixNanos::from);
709        let end_nanos = end.map(UnixNanos::from);
710
711        self.inner
712            .consolidate_data(
713                &data_type,
714                identifier.as_deref(),
715                start_nanos,
716                end_nanos,
717                ensure_contiguous_files,
718                deduplicate,
719            )
720            .map_err(|e| PyIOError::new_err(format!("Failed to consolidate data: {e}")))
721    }
722
723    /// Consolidate all data files in the catalog by splitting them into fixed time periods.
724    ///
725    /// This method identifies all leaf directories in the catalog that contain parquet files
726    /// and consolidates them by period. A leaf directory is one that contains files but no subdirectories.
727    /// This is a convenience method that effectively calls `consolidate_data_by_period` for all data types
728    /// and instrument IDs in the catalog.
729    ///
730    /// # Parameters
731    ///
732    /// - `period_nanos`: Optional period duration for consolidation in nanoseconds. Default is 1 day (86400000000000).
733    ///   Examples: 3600000000000 (1 hour), 604800000000000 (7 days), 1800000000000 (30 minutes)
734    /// - `start`: Optional start timestamp for the consolidation range (nanoseconds since Unix epoch)
735    /// - `end`: Optional end timestamp for the consolidation range (nanoseconds since Unix epoch)
736    /// - `ensure_contiguous_files`: Optional flag to control file naming strategy
737    #[pyo3(signature = (period_nanos=None, start=None, end=None, ensure_contiguous_files=None))]
738    pub fn consolidate_catalog_by_period(
739        &mut self,
740        period_nanos: Option<u64>,
741        start: Option<u64>,
742        end: Option<u64>,
743        ensure_contiguous_files: Option<bool>,
744    ) -> PyResult<()> {
745        let start_nanos = start.map(UnixNanos::from);
746        let end_nanos = end.map(UnixNanos::from);
747
748        self.inner
749            .consolidate_catalog_by_period(
750                period_nanos,
751                start_nanos,
752                end_nanos,
753                ensure_contiguous_files,
754            )
755            .map_err(|e| {
756                PyIOError::new_err(format!("Failed to consolidate catalog by period: {e}"))
757            })
758    }
759
760    /// Consolidate data files by splitting them into fixed time periods.
761    ///
762    /// This method queries data by period and writes consolidated files immediately,
763    /// using efficient period-based consolidation logic. When start/end boundaries intersect existing files,
764    /// the function automatically splits those files to preserve all data.
765    ///
766    /// # Parameters
767    ///
768    /// - `data_type`: The stored family to target (data type, record type, or instrument type).
769    /// - `identifier`: Optional instrument ID to consolidate. If None, consolidates all instruments
770    /// - `period_nanos`: Optional period duration for consolidation in nanoseconds. Default is 1 day (86400000000000).
771    ///   Examples: 3600000000000 (1 hour), 604800000000000 (7 days), 1800000000000 (30 minutes)
772    /// - `start`: Optional start timestamp for consolidation range (nanoseconds since Unix epoch)
773    /// - `end`: Optional end timestamp for consolidation range (nanoseconds since Unix epoch)
774    /// - `ensure_contiguous_files`: Optional flag to control file naming strategy
775    #[pyo3(signature = (data_type, identifier=None, period_nanos=None, start=None, end=None, ensure_contiguous_files=None))]
776    #[expect(clippy::needless_pass_by_value)]
777    pub fn consolidate_data_by_period(
778        &mut self,
779        data_type: PyCatalogDataType,
780        identifier: Option<String>,
781        period_nanos: Option<u64>,
782        start: Option<u64>,
783        end: Option<u64>,
784        ensure_contiguous_files: Option<bool>,
785    ) -> PyResult<()> {
786        let data_type = data_type.into_inner();
787        let start_nanos = start.map(UnixNanos::from);
788        let end_nanos = end.map(UnixNanos::from);
789
790        self.inner
791            .consolidate_data_by_period(
792                &data_type,
793                identifier.as_deref(),
794                period_nanos,
795                start_nanos,
796                end_nanos,
797                ensure_contiguous_files,
798            )
799            .map_err(|e| PyIOError::new_err(format!("Failed to consolidate data by period: {e}")))
800    }
801
802    /// Reset all catalog file names to their canonical form.
803    pub fn reset_all_file_names(&self) -> PyResult<()> {
804        self.inner
805            .reset_all_file_names()
806            .map_err(|e| PyIOError::new_err(format!("Failed to reset catalog file names: {e}")))
807    }
808
809    /// Reset data file names for a specific data class to their canonical form.
810    ///
811    /// # Parameters
812    ///
813    /// - `data_type`: The stored family to target (data type, record type, or instrument type).
814    /// - `identifier`: Optional instrument ID or bar type filter
815    #[pyo3(signature = (data_type, identifier=None))]
816    #[expect(clippy::needless_pass_by_value)]
817    pub fn reset_data_file_names(
818        &self,
819        data_type: PyCatalogDataType,
820        identifier: Option<String>,
821    ) -> PyResult<()> {
822        let data_type = data_type.into_inner();
823        self.inner
824            .reset_data_file_names(&data_type, identifier.as_deref())
825            .map_err(|e| PyIOError::new_err(format!("Failed to reset data file names: {e}")))
826    }
827
828    /// Delete data within a specified time range across the entire catalog.
829    ///
830    /// This method identifies all leaf directories in the catalog that contain parquet files
831    /// and deletes data within the specified time range from each directory. A leaf directory
832    /// is one that contains files but no subdirectories. This is a convenience method that
833    /// effectively calls `delete_data_range` for all data types and instrument IDs in the catalog.
834    ///
835    /// # Parameters
836    ///
837    /// - `start`: Optional start timestamp for the deletion range (nanoseconds since Unix epoch)
838    /// - `end`: Optional end timestamp for the deletion range (nanoseconds since Unix epoch)
839    ///
840    /// # Notes
841    ///
842    /// - This operation permanently removes data and cannot be undone
843    /// - The deletion process handles file intersections intelligently by splitting files
844    ///   when they partially overlap with the deletion range
845    /// - Files completely within the deletion range are removed entirely
846    /// - Files partially overlapping the deletion range are split to preserve data outside the range
847    /// - This method is useful for bulk data cleanup operations across the entire catalog
848    /// - Empty directories are not automatically removed after deletion
849    #[pyo3(signature = (start=None, end=None))]
850    pub fn delete_catalog_range(&mut self, start: Option<u64>, end: Option<u64>) -> PyResult<()> {
851        let start_nanos = start.map(UnixNanos::from);
852        let end_nanos = end.map(UnixNanos::from);
853
854        self.inner
855            .delete_catalog_range(start_nanos, end_nanos)
856            .map_err(|e| PyIOError::new_err(format!("Failed to delete catalog range: {e}")))
857    }
858
859    /// Delete data within a specified time range for a specific data type and instrument.
860    ///
861    /// This method identifies all parquet files that intersect with the specified time range
862    /// and handles them appropriately:
863    /// - Files completely within the range are deleted
864    /// - Files partially overlapping the range are split to preserve data outside the range
865    /// - The original intersecting files are removed after processing
866    ///
867    /// # Parameters
868    ///
869    /// - `data_type`: The data type to delete from.
870    /// - `identifier`: Optional identifier to delete data for. If None, deletes data across all identifiers
871    /// - `start`: Optional start timestamp for the deletion range (nanoseconds since Unix epoch)
872    /// - `end`: Optional end timestamp for the deletion range (nanoseconds since Unix epoch)
873    ///
874    /// # Notes
875    ///
876    /// - This operation permanently removes data and cannot be undone
877    /// - Files that partially overlap the deletion range are split to preserve data outside the range
878    /// - The method ensures data integrity by using atomic operations where possible
879    /// - Empty directories are not automatically removed after deletion
880    #[pyo3(signature = (data_type, identifier=None, start=None, end=None))]
881    #[expect(clippy::needless_pass_by_value)]
882    pub fn delete_data_range(
883        &mut self,
884        #[gen_stub(override_type(type_repr = "model.NautilusDataType"))] data_type: &Bound<
885            '_,
886            PyAny,
887        >,
888        identifier: Option<String>,
889        start: Option<u64>,
890        end: Option<u64>,
891    ) -> PyResult<()> {
892        let data_type = nautilus_data_type_from_py(data_type)?;
893        let start_nanos = start.map(UnixNanos::from);
894        let end_nanos = end.map(UnixNanos::from);
895
896        self.inner
897            .delete_data_range(&data_type, identifier.as_deref(), start_nanos, end_nanos)
898            .map_err(|e| PyIOError::new_err(format!("Failed to delete data range: {e}")))
899    }
900
901    /// Writes custom data to Parquet files.
902    ///
903    /// Requires `CustomData` wrappers. Callers must wrap raw custom objects in
904    /// `CustomData(data_type=DataType(cls, metadata=...), data=...)` before writing.
905    ///
906    /// The registered Arrow schema must contain `ts_init`. Any `ts_event` or `ts_init` fields
907    /// must use `timestamp("ns", tz="UTC")`; incompatible schemas fail before writing.
908    #[pyo3(signature = (data, start=None, end=None, skip_disjoint_check=false))]
909    pub fn write_custom_data(
910        &self,
911        _py: Python<'_>,
912        data: Vec<Bound<'_, PyAny>>,
913        start: Option<u64>,
914        end: Option<u64>,
915        skip_disjoint_check: bool,
916    ) -> PyResult<String> {
917        use nautilus_model::data::CustomData;
918
919        let mut custom_items: Vec<CustomData> = Vec::with_capacity(data.len());
920        for obj in data {
921            let custom = obj.extract::<CustomData>().map_err(|_| {
922                to_pytype_err(
923                    "write_custom_data requires CustomData wrappers; wrap with CustomData(data_type=DataType(cls, metadata=...), data=...)",
924                )
925            })?;
926
927            custom_items.push(custom);
928        }
929
930        let start_nanos = start.map(UnixNanos::from);
931        let end_nanos = end.map(UnixNanos::from);
932
933        self.inner
934            .write_custom_data_batch(
935                &custom_items,
936                start_nanos,
937                end_nanos,
938                Some(skip_disjoint_check),
939            )
940            .map(|path| path.to_string_lossy().to_string())
941            .map_err(|e| PyIOError::new_err(format!("Failed to write custom data: {e}")))
942    }
943
944    /// List all instrument IDs available in the catalog for a given catalog type.
945    pub fn list_instruments(&self, data_type: PyCatalogDataType) -> PyResult<Vec<String>> {
946        let data_type = data_type.into_inner();
947        self.inner
948            .list_instruments(&data_type)
949            .map_err(|e| PyIOError::new_err(format!("Failed to list instruments: {e}")))
950    }
951
952    /// List all Parquet files in the catalog for a given data type and identifier.
953    pub fn list_parquet_files(
954        &self,
955        data_type: PyCatalogDataType,
956        identifier: &str,
957    ) -> PyResult<Vec<String>> {
958        let data_type = data_type.into_inner();
959        let list_files = |prefix: &str| -> PyResult<Vec<String>> {
960            let directory = self
961                .inner
962                .make_path(prefix, Some(identifier))
963                .map_err(|e| PyIOError::new_err(format!("Failed to list parquet files: {e}")))?;
964            self.inner
965                .list_parquet_files(&directory)
966                .map_err(|e| PyIOError::new_err(format!("Failed to list parquet files: {e}")))
967        };
968        let mut files = Vec::new();
969
970        for prefix in parquet_catalog_data_type_path_prefixes(&data_type) {
971            files.extend(list_files(prefix.as_ref())?);
972        }
973
974        Ok(files)
975    }
976
977    /// Query files in the catalog matching the specified criteria.
978    ///
979    /// # Parameters
980    ///
981    /// - `data_type`: The stored family to target (data type, record type, or instrument type).
982    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings
983    ///   (e.g., "EUR/USD.SIM") or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
984    ///   For bars, partial matching is supported.
985    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
986    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
987    ///
988    /// # Returns
989    ///
990    /// Returns a list of file paths matching the criteria.
991    #[pyo3(signature = (data_type, identifiers=None, start=None, end=None))]
992    pub fn query_files(
993        &self,
994        data_type: PyCatalogDataType,
995        identifiers: Option<Vec<String>>,
996        start: Option<u64>,
997        end: Option<u64>,
998    ) -> PyResult<Vec<String>> {
999        let data_type = data_type.into_inner();
1000        let start_nanos = start.map(UnixNanos::from);
1001        let end_nanos = end.map(UnixNanos::from);
1002
1003        self.inner
1004            .query_files(&data_type, identifiers, start_nanos, end_nanos)
1005            .map_err(|e| PyIOError::new_err(format!("Failed to query files list: {e}")))
1006    }
1007
1008    /// Query catalog metadata keyed by the first timestamp where each metadata is used.
1009    #[pyo3(signature = (data_type, identifiers=None, start=None, end=None, where_clause=None))]
1010    pub fn query_metadata(
1011        &mut self,
1012        py: Python<'_>,
1013        #[gen_stub(override_type(type_repr = "model.NautilusDataType"))] data_type: &Bound<
1014            '_,
1015            PyAny,
1016        >,
1017        identifiers: Option<Vec<String>>,
1018        start: Option<u64>,
1019        end: Option<u64>,
1020        where_clause: Option<&str>,
1021    ) -> PyResult<Py<PyDict>> {
1022        let data_type = nautilus_data_type_from_py(data_type)?;
1023
1024        let metadata = py
1025            .detach(|| {
1026                CatalogReader::query_metadata(
1027                    &mut self.inner,
1028                    &CatalogQuery::new(data_type)
1029                        .with_identifiers(identifiers)
1030                        .with_range(start.map(UnixNanos::from), end.map(UnixNanos::from))
1031                        .with_where_clause(where_clause.map(str::to_string)),
1032                )
1033            })
1034            .map_err(|e| PyIOError::new_err(format!("Metadata query failed: {e}")))?;
1035
1036        catalog_metadata_to_pydict(py, metadata)
1037    }
1038
1039    /// Query catalog data as display-friendly Arrow IPC stream bytes.
1040    #[pyo3(signature = (
1041        data_type,
1042        identifiers=None,
1043        start=None,
1044        end=None,
1045        where_clause=None,
1046        display=true,
1047        as_of=None,
1048    ))]
1049    pub fn query_data_arrow_bytes(
1050        &mut self,
1051        py: Python<'_>,
1052        #[gen_stub(override_type(type_repr = "model.NautilusDataType"))] data_type: &Bound<
1053            '_,
1054            PyAny,
1055        >,
1056        identifiers: Option<Vec<String>>,
1057        start: Option<u64>,
1058        end: Option<u64>,
1059        where_clause: Option<&str>,
1060        display: bool,
1061        as_of: Option<&Bound<'_, PyAny>>,
1062    ) -> PyResult<Py<PyBytes>> {
1063        let data_type = nautilus_data_type_from_py(data_type)?;
1064        reject_parquet_as_of(as_of)?;
1065        let query = CatalogQuery::new(data_type.clone())
1066            .with_identifiers(identifiers)
1067            .with_range(start.map(UnixNanos::from), end.map(UnixNanos::from))
1068            .with_where_clause(where_clause.map(str::to_string));
1069
1070        let batches = py
1071            .detach(|| {
1072                if display {
1073                    CatalogReader::query_display_record_batches(&mut self.inner, &query)
1074                } else {
1075                    let data = CatalogReader::query_batch(&mut self.inner, &query)?
1076                        .to_data_vec_for_compat();
1077                    crate::common::arrow::data_to_arrow_batches(&data_type, data)
1078                }
1079            })
1080            .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
1081
1082        let schema = arrow_ipc_data_schema(&data_type, &batches, display)?;
1083        let batches = arrow_ipc_batches(&schema, batches)?;
1084        arrow_record_batches_to_pybytes(py, &schema, &batches)
1085    }
1086
1087    /// Query catalog data as an Arrow C stream `PyCapsule`.
1088    #[pyo3(signature = (
1089        data_type,
1090        identifiers=None,
1091        start=None,
1092        end=None,
1093        where_clause=None,
1094        display=true,
1095        as_of=None,
1096    ))]
1097    pub fn query_data_arrow_stream(
1098        &mut self,
1099        py: Python<'_>,
1100        #[gen_stub(override_type(type_repr = "model.NautilusDataType"))] data_type: &Bound<
1101            '_,
1102            PyAny,
1103        >,
1104        identifiers: Option<Vec<String>>,
1105        start: Option<u64>,
1106        end: Option<u64>,
1107        where_clause: Option<&str>,
1108        display: bool,
1109        as_of: Option<&Bound<'_, PyAny>>,
1110    ) -> PyResult<Py<PyAny>> {
1111        let data_type = nautilus_data_type_from_py(data_type)?;
1112        reject_parquet_as_of(as_of)?;
1113        let query = CatalogQuery::new(data_type.clone())
1114            .with_identifiers(identifiers)
1115            .with_range(start.map(UnixNanos::from), end.map(UnixNanos::from))
1116            .with_where_clause(where_clause.map(str::to_string));
1117
1118        let batches = py
1119            .detach(|| {
1120                if display {
1121                    CatalogReader::query_display_record_batches(&mut self.inner, &query)
1122                } else {
1123                    let data = CatalogReader::query_batch(&mut self.inner, &query)?
1124                        .to_data_vec_for_compat();
1125                    crate::common::arrow::data_to_arrow_batches(&data_type, data)
1126                }
1127            })
1128            .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
1129
1130        let schema = arrow_ipc_data_schema(&data_type, &batches, display)?;
1131        let batches = arrow_ipc_batches(&schema, batches)?;
1132        arrow_record_batches_to_pyarrow_stream(py, &schema, batches)
1133    }
1134
1135    /// Write catalog records from Arrow IPC stream bytes.
1136    #[pyo3(signature = (record_type, data, identifier=None, params=None))]
1137    pub fn write_record_arrow_bytes(
1138        &mut self,
1139        py: Python<'_>,
1140        #[gen_stub(override_type(type_repr = "model.NautilusRecordType"))] record_type: &Bound<
1141            '_,
1142            PyAny,
1143        >,
1144        data: Vec<u8>,
1145        identifier: Option<String>,
1146        params: Option<Py<PyDict>>,
1147    ) -> PyResult<()> {
1148        let record_type = catalog_record_type_from_py(record_type)?;
1149        let batches = arrow_record_batches_from_pybytes(data)?;
1150        let params = write_record_params_from_py(py, identifier, params)?;
1151
1152        py.detach(|| CatalogWriter::write_records(&mut self.inner, record_type, &batches, params))
1153            .map_err(|e| PyIOError::new_err(format!("Failed write records: {e}")))
1154    }
1155
1156    /// Query catalog records and return Arrow IPC stream bytes.
1157    #[pyo3(signature = (
1158        record_type,
1159        identifier=None,
1160        start=None,
1161        end=None,
1162        where_clause=None,
1163        display=true,
1164        as_of=None,
1165    ))]
1166    pub fn query_record_arrow_bytes(
1167        &mut self,
1168        py: Python<'_>,
1169        #[gen_stub(override_type(type_repr = "model.NautilusRecordType"))] record_type: &Bound<
1170            '_,
1171            PyAny,
1172        >,
1173        identifier: Option<String>,
1174        start: Option<u64>,
1175        end: Option<u64>,
1176        where_clause: Option<&str>,
1177        display: bool,
1178        as_of: Option<&Bound<'_, PyAny>>,
1179    ) -> PyResult<Py<PyBytes>> {
1180        let record_type = catalog_record_type_from_py(record_type)?;
1181        reject_parquet_as_of(as_of)?;
1182        let start_nanos = start.map(UnixNanos::from);
1183        let end_nanos = end.map(UnixNanos::from);
1184
1185        let batches = py
1186            .detach(|| {
1187                if display {
1188                    CatalogReader::query_record_display_batches(
1189                        &mut self.inner,
1190                        &CatalogRecordQuery::new(record_type)
1191                            .with_identifier(identifier)
1192                            .with_range(start_nanos, end_nanos)
1193                            .with_where_clause(where_clause.map(str::to_string)),
1194                    )
1195                } else {
1196                    CatalogReader::query_record_batches(
1197                        &mut self.inner,
1198                        &CatalogRecordQuery::new(record_type)
1199                            .with_identifier(identifier)
1200                            .with_range(start_nanos, end_nanos)
1201                            .with_where_clause(where_clause.map(str::to_string)),
1202                    )
1203                }
1204            })
1205            .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
1206
1207        let schema = arrow_ipc_record_schema(record_type, &batches)?;
1208        let batches = arrow_ipc_batches(&schema, batches)?;
1209
1210        arrow_record_batches_to_pybytes(py, &schema, &batches)
1211    }
1212
1213    /// Query catalog records as an Arrow C stream `PyCapsule`.
1214    #[pyo3(signature = (
1215        record_type,
1216        identifier=None,
1217        start=None,
1218        end=None,
1219        where_clause=None,
1220        display=true,
1221        as_of=None,
1222    ))]
1223    pub fn query_record_arrow_stream(
1224        &mut self,
1225        py: Python<'_>,
1226        #[gen_stub(override_type(type_repr = "model.NautilusRecordType"))] record_type: &Bound<
1227            '_,
1228            PyAny,
1229        >,
1230        identifier: Option<String>,
1231        start: Option<u64>,
1232        end: Option<u64>,
1233        where_clause: Option<&str>,
1234        display: bool,
1235        as_of: Option<&Bound<'_, PyAny>>,
1236    ) -> PyResult<Py<PyAny>> {
1237        let record_type = catalog_record_type_from_py(record_type)?;
1238        reject_parquet_as_of(as_of)?;
1239        let start_nanos = start.map(UnixNanos::from);
1240        let end_nanos = end.map(UnixNanos::from);
1241
1242        let batches = py
1243            .detach(|| {
1244                if display {
1245                    CatalogReader::query_record_display_batches(
1246                        &mut self.inner,
1247                        &CatalogRecordQuery::new(record_type)
1248                            .with_identifier(identifier)
1249                            .with_range(start_nanos, end_nanos)
1250                            .with_where_clause(where_clause.map(str::to_string)),
1251                    )
1252                } else {
1253                    CatalogReader::query_record_batches(
1254                        &mut self.inner,
1255                        &CatalogRecordQuery::new(record_type)
1256                            .with_identifier(identifier)
1257                            .with_range(start_nanos, end_nanos)
1258                            .with_where_clause(where_clause.map(str::to_string)),
1259                    )
1260                }
1261            })
1262            .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
1263
1264        let schema = arrow_ipc_record_schema(record_type, &batches)?;
1265        let batches = arrow_ipc_batches(&schema, batches)?;
1266
1267        arrow_record_batches_to_pyarrow_stream(py, &schema, batches)
1268    }
1269
1270    /// Get missing time intervals for a data request.
1271    ///
1272    /// # Parameters
1273    ///
1274    /// - `start`: Start timestamp (nanoseconds since Unix epoch)
1275    /// - `end`: End timestamp (nanoseconds since Unix epoch)
1276    /// - `data_type`: The stored family to target (data type, record type, or instrument type).
1277    /// - `identifier`: Optional instrument ID or bar type filter
1278    ///
1279    /// # Returns
1280    ///
1281    /// Returns a list of (start, end) timestamp tuples representing missing intervals.
1282    #[pyo3(signature = (start, end, data_type, identifier=None))]
1283    #[expect(clippy::needless_pass_by_value)]
1284    pub fn get_missing_intervals_for_request(
1285        &self,
1286        start: u64,
1287        end: u64,
1288        data_type: PyCatalogDataType,
1289        identifier: Option<String>,
1290    ) -> PyResult<Vec<(u64, u64)>> {
1291        let data_type = data_type.into_inner();
1292        self.inner
1293            .get_missing_intervals_for_request(start, end, &data_type, identifier.as_deref())
1294            .map_err(|e| PyIOError::new_err(format!("Failed to get missing intervals: {e}")))
1295    }
1296
1297    /// Query the first timestamp for a specific data class and instrument.
1298    ///
1299    /// # Parameters
1300    ///
1301    /// - `data_type`: The stored family to target (data type, record type, or instrument type).
1302    /// - `identifier`: Optional instrument ID or bar type filter
1303    ///
1304    /// # Returns
1305    ///
1306    /// Returns the first timestamp as nanoseconds since Unix epoch, or None if no data exists.
1307    #[pyo3(signature = (data_type, identifier=None))]
1308    #[expect(clippy::needless_pass_by_value)]
1309    pub fn query_first_timestamp(
1310        &self,
1311        data_type: PyCatalogDataType,
1312        identifier: Option<String>,
1313    ) -> PyResult<Option<u64>> {
1314        let data_type = data_type.into_inner();
1315        self.inner
1316            .query_first_timestamp(&data_type, identifier.as_deref())
1317            .map_err(|e| PyIOError::new_err(format!("Failed to query first timestamp: {e}")))
1318    }
1319
1320    /// Query the last timestamp for a specific data class and instrument.
1321    ///
1322    /// # Parameters
1323    ///
1324    /// - `data_type`: The stored family to target (data type, record type, or instrument type).
1325    /// - `identifier`: Optional instrument ID or bar type filter
1326    ///
1327    /// # Returns
1328    ///
1329    /// Returns the last timestamp as nanoseconds since Unix epoch, or None if no data exists.
1330    #[pyo3(signature = (data_type, identifier=None))]
1331    #[expect(clippy::needless_pass_by_value)]
1332    pub fn query_last_timestamp(
1333        &self,
1334        data_type: PyCatalogDataType,
1335        identifier: Option<String>,
1336    ) -> PyResult<Option<u64>> {
1337        let data_type = data_type.into_inner();
1338        self.inner
1339            .query_last_timestamp(&data_type, identifier.as_deref())
1340            .map_err(|e| PyIOError::new_err(format!("Failed to query last timestamp: {e}")))
1341    }
1342
1343    /// Get time intervals covered by data for a specific data class and instrument.
1344    ///
1345    /// # Parameters
1346    ///
1347    /// - `data_type`: The stored family to target (data type, record type, or instrument type).
1348    /// - `identifier`: Optional instrument ID or bar type filter
1349    ///
1350    /// # Returns
1351    ///
1352    /// Returns a list of (start, end) timestamp tuples representing covered intervals.
1353    #[pyo3(signature = (data_type, identifier=None))]
1354    #[expect(clippy::needless_pass_by_value)]
1355    pub fn get_intervals(
1356        &self,
1357        data_type: PyCatalogDataType,
1358        identifier: Option<String>,
1359    ) -> PyResult<Vec<(u64, u64)>> {
1360        let data_type = data_type.into_inner();
1361        self.inner
1362            .get_intervals(&data_type, identifier.as_deref())
1363            .map_err(|e| PyIOError::new_err(format!("Failed to get intervals: {e}")))
1364    }
1365
1366    /// Queries one data family and returns the decoded Python objects.
1367    #[pyo3(signature = (data_type, identifiers=None, start=None, end=None, where_clause=None, files=None, optimize_file_loading=true))]
1368    #[expect(
1369        clippy::too_many_arguments,
1370        reason = "PyO3 signature mirrors the catalog query filters"
1371    )]
1372    pub fn query(
1373        &mut self,
1374        py: Python<'_>,
1375        #[gen_stub(override_type(type_repr = "model.NautilusDataType"))] data_type: &Bound<
1376            '_,
1377            PyAny,
1378        >,
1379        identifiers: Option<Vec<String>>,
1380        start: Option<u64>,
1381        end: Option<u64>,
1382        where_clause: Option<&str>,
1383        files: Option<Vec<String>>,
1384        optimize_file_loading: bool,
1385    ) -> PyResult<Vec<Py<PyAny>>> {
1386        let data_type = nautilus_data_type_from_py(data_type)?;
1387        let start_nanos = start.map(UnixNanos::from);
1388        let end_nanos = end.map(UnixNanos::from);
1389
1390        macro_rules! typed {
1391            ($type:ty) => {
1392                query_parquet_data::<$type>(
1393                    &mut self.inner,
1394                    identifiers,
1395                    start,
1396                    end,
1397                    where_clause,
1398                    files,
1399                    optimize_file_loading,
1400                    "Query failed",
1401                )?
1402                .into_iter()
1403                .map(Data::from)
1404                .collect::<Vec<Data>>()
1405            };
1406        }
1407
1408        let data = match data_type {
1409            NautilusDataType::QuoteTick => typed!(QuoteTick),
1410            NautilusDataType::TradeTick => typed!(TradeTick),
1411            NautilusDataType::Bar => typed!(Bar),
1412            NautilusDataType::OrderBookDelta => typed!(OrderBookDelta),
1413            NautilusDataType::OrderBookDepth => typed!(OrderBookDepth),
1414            NautilusDataType::IndexPriceUpdate => typed!(IndexPriceUpdate),
1415            NautilusDataType::MarkPriceUpdate => typed!(MarkPriceUpdate),
1416            NautilusDataType::FundingRateUpdate => typed!(FundingRateUpdate),
1417            NautilusDataType::OptionGreeks => typed!(OptionGreeks),
1418            NautilusDataType::InstrumentStatus => typed!(InstrumentStatus),
1419            NautilusDataType::InstrumentClose => typed!(InstrumentClose),
1420            NautilusDataType::Custom { type_name } => py
1421                .detach(|| {
1422                    self.inner.query_custom_data_dynamic(
1423                        &type_name,
1424                        identifiers.as_deref(),
1425                        start_nanos,
1426                        end_nanos,
1427                        where_clause,
1428                        files.clone(),
1429                        optimize_file_loading,
1430                    )
1431                })
1432                .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?,
1433            NautilusDataType::Instrument => {
1434                let instruments = py
1435                    .detach(|| {
1436                        self.inner.query_instruments_filtered_with_where(
1437                            identifiers.as_deref(),
1438                            start_nanos,
1439                            end_nanos,
1440                            where_clause,
1441                        )
1442                    })
1443                    .map_err(|e| PyIOError::new_err(format!("Query failed: {e}")))?;
1444
1445                return instruments
1446                    .into_iter()
1447                    .map(|instrument| instrument_any_to_pyobject(py, instrument))
1448                    .collect();
1449            }
1450            #[cfg(feature = "defi")]
1451            NautilusDataType::Defi => {
1452                return Err(to_pytype_err("Defi data is not supported by query"));
1453            }
1454            #[cfg(not(feature = "defi"))]
1455            #[allow(
1456                unreachable_patterns,
1457                reason = "DeFi variants can exist without this crate's defi feature"
1458            )]
1459            _ => return Err(to_pytype_err("Defi data is not supported by query")),
1460        };
1461
1462        let mut python_objects = Vec::new();
1463        for item in data {
1464            python_objects.push(data_to_pyobject(py, item)?);
1465        }
1466
1467        Ok(python_objects)
1468    }
1469
1470    /// Query quote tick data from Parquet files.
1471    ///
1472    /// # Parameters
1473    ///
1474    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings
1475    ///   (e.g., "EUR/USD.SIM") or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
1476    ///   For bars, partial matching is supported.
1477    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1478    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1479    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1480    ///
1481    /// # Returns
1482    ///
1483    /// Returns a vector of `QuoteTick` objects matching the query criteria.
1484    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1485    pub fn query_quote_ticks(
1486        &mut self,
1487        identifiers: Option<Vec<String>>,
1488        start: Option<u64>,
1489        end: Option<u64>,
1490        where_clause: Option<&str>,
1491    ) -> PyResult<Vec<QuoteTick>> {
1492        query_parquet_data::<QuoteTick>(
1493            &mut self.inner,
1494            identifiers,
1495            start,
1496            end,
1497            where_clause,
1498            None,
1499            true,
1500            "Failed to query data",
1501        )
1502    }
1503
1504    /// Query trade tick data from Parquet files.
1505    ///
1506    /// # Parameters
1507    ///
1508    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings
1509    ///   (e.g., "EUR/USD.SIM") or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
1510    ///   For bars, partial matching is supported.
1511    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1512    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1513    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1514    ///
1515    /// # Returns
1516    ///
1517    /// Returns a vector of `TradeTick` objects matching the query criteria.
1518    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1519    pub fn query_trade_ticks(
1520        &mut self,
1521        identifiers: Option<Vec<String>>,
1522        start: Option<u64>,
1523        end: Option<u64>,
1524        where_clause: Option<&str>,
1525    ) -> PyResult<Vec<TradeTick>> {
1526        query_parquet_data::<TradeTick>(
1527            &mut self.inner,
1528            identifiers,
1529            start,
1530            end,
1531            where_clause,
1532            None,
1533            true,
1534            "Failed to query data",
1535        )
1536    }
1537
1538    /// Query order book delta data from Parquet files.
1539    ///
1540    /// # Parameters
1541    ///
1542    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings
1543    ///   (e.g., "EUR/USD.SIM") or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
1544    ///   For bars, partial matching is supported.
1545    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1546    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1547    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1548    ///
1549    /// # Returns
1550    ///
1551    /// Returns a vector of `OrderBookDelta` objects matching the query criteria.
1552    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1553    pub fn query_order_book_deltas(
1554        &mut self,
1555        identifiers: Option<Vec<String>>,
1556        start: Option<u64>,
1557        end: Option<u64>,
1558        where_clause: Option<&str>,
1559    ) -> PyResult<Vec<OrderBookDelta>> {
1560        query_parquet_data::<OrderBookDelta>(
1561            &mut self.inner,
1562            identifiers,
1563            start,
1564            end,
1565            where_clause,
1566            None,
1567            true,
1568            "Failed to query data",
1569        )
1570    }
1571
1572    /// Query bar data from Parquet files.
1573    ///
1574    /// # Parameters
1575    ///
1576    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings
1577    ///   (e.g., "EUR/USD.SIM") or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
1578    ///   For bars, partial matching is supported (e.g., "EUR/USD.SIM" will match all bar types for that instrument).
1579    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1580    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1581    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1582    ///
1583    /// # Returns
1584    ///
1585    /// Returns a vector of Bar objects matching the query criteria.
1586    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1587    pub fn query_bars(
1588        &mut self,
1589        identifiers: Option<Vec<String>>,
1590        start: Option<u64>,
1591        end: Option<u64>,
1592        where_clause: Option<&str>,
1593    ) -> PyResult<Vec<Bar>> {
1594        query_parquet_data::<Bar>(
1595            &mut self.inner,
1596            identifiers,
1597            start,
1598            end,
1599            where_clause,
1600            None,
1601            true,
1602            "Failed to query data",
1603        )
1604    }
1605
1606    /// Query order book depth data from Parquet files.
1607    ///
1608    /// # Parameters
1609    ///
1610    /// - `identifiers`: Optional list of identifiers to filter by.
1611    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1612    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1613    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1614    ///
1615    /// # Returns
1616    ///
1617    /// Returns a vector of `OrderBookDepth` objects matching the query criteria.
1618    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1619    pub fn query_order_book_depths(
1620        &mut self,
1621        identifiers: Option<Vec<String>>,
1622        start: Option<u64>,
1623        end: Option<u64>,
1624        where_clause: Option<&str>,
1625    ) -> PyResult<Vec<OrderBookDepth>> {
1626        query_parquet_data::<OrderBookDepth>(
1627            &mut self.inner,
1628            identifiers,
1629            start,
1630            end,
1631            where_clause,
1632            None,
1633            true,
1634            "Failed to query data",
1635        )
1636    }
1637
1638    /// Query mark price update data from Parquet files.
1639    ///
1640    /// # Parameters
1641    ///
1642    /// - `identifiers`: Optional list of identifiers to filter by.
1643    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1644    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1645    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1646    ///
1647    /// # Returns
1648    ///
1649    /// Returns a vector of `MarkPriceUpdate` objects matching the query criteria.
1650    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1651    pub fn query_mark_price_updates(
1652        &mut self,
1653        identifiers: Option<Vec<String>>,
1654        start: Option<u64>,
1655        end: Option<u64>,
1656        where_clause: Option<&str>,
1657    ) -> PyResult<Vec<MarkPriceUpdate>> {
1658        query_parquet_data::<MarkPriceUpdate>(
1659            &mut self.inner,
1660            identifiers,
1661            start,
1662            end,
1663            where_clause,
1664            None,
1665            true,
1666            "Failed to query data",
1667        )
1668    }
1669
1670    /// Query index price update data from Parquet files.
1671    ///
1672    /// # Parameters
1673    ///
1674    /// - `identifiers`: Optional list of identifiers to filter by.
1675    /// - `start`: Optional start timestamp (nanoseconds since Unix epoch)
1676    /// - `end`: Optional end timestamp (nanoseconds since Unix epoch)
1677    /// - `where_clause`: Optional SQL WHERE clause for additional filtering
1678    ///
1679    /// # Returns
1680    ///
1681    /// Returns a vector of `IndexPriceUpdate` objects matching the query criteria.
1682    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1683    pub fn query_index_price_updates(
1684        &mut self,
1685        identifiers: Option<Vec<String>>,
1686        start: Option<u64>,
1687        end: Option<u64>,
1688        where_clause: Option<&str>,
1689    ) -> PyResult<Vec<IndexPriceUpdate>> {
1690        query_parquet_data::<IndexPriceUpdate>(
1691            &mut self.inner,
1692            identifiers,
1693            start,
1694            end,
1695            where_clause,
1696            None,
1697            true,
1698            "Failed to query data",
1699        )
1700    }
1701
1702    /// Query option greeks data from Parquet files.
1703    #[pyo3(signature = (identifiers=None, start=None, end=None, where_clause=None))]
1704    pub fn query_option_greeks(
1705        &mut self,
1706        identifiers: Option<Vec<String>>,
1707        start: Option<u64>,
1708        end: Option<u64>,
1709        where_clause: Option<&str>,
1710    ) -> PyResult<Vec<OptionGreeks>> {
1711        query_parquet_data::<OptionGreeks>(
1712            &mut self.inner,
1713            identifiers,
1714            start,
1715            end,
1716            where_clause,
1717            None,
1718            true,
1719            "Failed to query data",
1720        )
1721    }
1722
1723    /// List all data types available in the catalog.
1724    ///
1725    /// # Returns
1726    ///
1727    /// Returns the data, record, and instrument types stored in the catalog.
1728    pub fn list_data_types(&self) -> PyResult<Vec<PyCatalogDataType>> {
1729        self.inner
1730            .list_data_types()
1731            .map(|data_types| data_types.into_iter().map(PyCatalogDataType::new).collect())
1732            .map_err(|e| PyIOError::new_err(format!("Failed to list data types: {e}")))
1733    }
1734
1735    /// List all live run IDs available in the catalog.
1736    ///
1737    /// # Returns
1738    ///
1739    /// Returns a list of live run IDs (as directory stems) in the catalog.
1740    pub fn list_live_runs(&self) -> PyResult<Vec<String>> {
1741        self.inner
1742            .list_live_runs()
1743            .map_err(|e| PyIOError::new_err(format!("Failed to list live runs: {e}")))
1744    }
1745
1746    /// List all backtest run IDs available in the catalog.
1747    ///
1748    /// # Returns
1749    ///
1750    /// Returns a list of backtest run IDs (as directory stems) in the catalog.
1751    pub fn list_backtest_runs(&self) -> PyResult<Vec<String>> {
1752        self.inner
1753            .list_backtest_runs()
1754            .map_err(|e| PyIOError::new_err(format!("Failed to list backtest runs: {e}")))
1755    }
1756
1757    /// List all backtest run instances available in the catalog.
1758    pub fn list_backtests(&self) -> PyResult<Vec<String>> {
1759        self.inner
1760            .list_backtest_runs()
1761            .map_err(|e| PyIOError::new_err(format!("Failed to list backtests: {e}")))
1762    }
1763
1764    /// Read data from a live run instance.
1765    ///
1766    /// # Parameters
1767    ///
1768    /// - `instance_id`: The ID of the live run instance
1769    ///
1770    /// # Returns
1771    ///
1772    /// Returns a list of data objects from the live run, sorted by timestamp.
1773    #[pyo3(signature = (instance_id))]
1774    pub fn read_live_run(&self, py: Python<'_>, instance_id: &str) -> PyResult<Vec<Py<PyAny>>> {
1775        let data = self
1776            .inner
1777            .read_live_run(instance_id)
1778            .map_err(|e| PyIOError::new_err(format!("Failed to read live run: {e}")))?;
1779
1780        let mut python_objects = Vec::new();
1781        for item in data {
1782            python_objects.push(data_to_pyobject(py, item)?);
1783        }
1784
1785        Ok(python_objects)
1786    }
1787
1788    /// Read data from a backtest run instance.
1789    ///
1790    /// # Parameters
1791    ///
1792    /// - `instance_id`: The ID of the backtest run instance
1793    ///
1794    /// # Returns
1795    ///
1796    /// Returns a list of data objects from the backtest run, sorted by timestamp.
1797    #[pyo3(signature = (instance_id))]
1798    pub fn read_backtest(&self, py: Python<'_>, instance_id: &str) -> PyResult<Vec<Py<PyAny>>> {
1799        let data = self
1800            .inner
1801            .read_backtest(instance_id)
1802            .map_err(|e| PyIOError::new_err(format!("Failed to read backtest: {e}")))?;
1803
1804        let mut python_objects = Vec::new();
1805        for item in data {
1806            python_objects.push(data_to_pyobject(py, item)?);
1807        }
1808
1809        Ok(python_objects)
1810    }
1811
1812    /// Convert stream data from feather files to parquet files.
1813    ///
1814    /// This method reads data from feather files generated during a backtest or live run
1815    /// and writes it to the catalog in parquet format. It's useful for converting temporary
1816    /// stream data into a more permanent and queryable format.
1817    ///
1818    /// # Parameters
1819    ///
1820    /// - `instance_id`: The ID of the backtest or live run instance
1821    /// - `data_type`: The stored family to convert (data type or record type).
1822    /// - `environment`: The environment of the run, which names the folder holding its feather files (default: `Environment.BACKTEST`)
1823    /// - `identifiers`: Optional list of identifiers to filter by (instrument IDs or bar types)
1824    /// - `use_ts_event_for_ts_init`: If true, replaces the `ts_init` column with `ts_event` column values before deserializing
1825    ///
1826    /// # Returns
1827    ///
1828    /// Returns nothing on success.
1829    ///
1830    /// # Examples
1831    ///
1832    /// ```python
1833    /// # Convert backtest stream data to parquet
1834    /// catalog.convert_stream_to_data(
1835    ///     "instance-123",
1836    ///     NautilusDataType.QuoteTick,
1837    ///     environment=Environment.BACKTEST
1838    /// )
1839    ///
1840    /// # Convert live run data with identifier filtering
1841    /// catalog.convert_stream_to_data(
1842    ///     "instance-456",
1843    ///     NautilusDataType.TradeTick,
1844    ///     environment=Environment.LIVE,
1845    ///     identifiers=["EUR/USD.SIM"]
1846    /// )
1847    /// ```
1848    #[pyo3(signature = (instance_id, data_type, environment=Environment::Backtest, identifiers=None, use_ts_event_for_ts_init=false))]
1849    #[expect(clippy::needless_pass_by_value)]
1850    pub fn convert_stream_to_data(
1851        &mut self,
1852        instance_id: &str,
1853        data_type: PyCatalogDataType,
1854        environment: Environment,
1855        identifiers: Option<Vec<String>>,
1856        use_ts_event_for_ts_init: bool,
1857    ) -> PyResult<()> {
1858        let data_type = data_type.into_inner();
1859
1860        self.inner
1861            .convert_stream_to_data(
1862                instance_id,
1863                &data_type,
1864                environment,
1865                identifiers.as_deref(),
1866                use_ts_event_for_ts_init,
1867            )
1868            .map_err(|e| PyIOError::new_err(format!("Failed to convert stream to data: {e}")))
1869    }
1870
1871    /// Query custom data from Parquet files.
1872    #[pyo3(signature = (data_type, identifiers=None, start=None, end=None, where_clause=None))]
1873    #[expect(clippy::needless_pass_by_value)]
1874    pub fn query_custom_data(
1875        &mut self,
1876        py: Python<'_>,
1877        #[gen_stub(override_type(type_repr = "model.NautilusDataType"))] data_type: &Bound<
1878            '_,
1879            PyAny,
1880        >,
1881        identifiers: Option<Vec<String>>,
1882        start: Option<u64>,
1883        end: Option<u64>,
1884        where_clause: Option<&str>,
1885    ) -> PyResult<Vec<Py<PyAny>>> {
1886        let NautilusDataType::Custom { type_name } = nautilus_data_type_from_py(data_type)? else {
1887            return Err(to_pytype_err("data_type must be a custom NautilusDataType"));
1888        };
1889
1890        let start_nanos = start.map(UnixNanos::from);
1891        let end_nanos = end.map(UnixNanos::from);
1892
1893        let data = py
1894            .detach(|| {
1895                self.inner.query_custom_data_dynamic(
1896                    &type_name,
1897                    identifiers.as_deref(),
1898                    start_nanos,
1899                    end_nanos,
1900                    where_clause,
1901                    None,
1902                    true,
1903                )
1904            })
1905            .map_err(|e| PyIOError::new_err(format!("Failed to query custom data: {e}")))?;
1906
1907        let mut python_objects = Vec::new();
1908
1909        for item in data {
1910            let py_obj: Py<PyAny> = match item {
1911                Data::Custom(custom) => Py::new(py, custom)?.into_any(),
1912                _ => return Err(PyIOError::new_err("Expected custom data")),
1913            };
1914
1915            python_objects.push(py_obj);
1916        }
1917
1918        Ok(python_objects)
1919    }
1920}
1921
1922// Codes follow the Parquet format codec numbering, except that 5 writes LZ4_RAW rather than the
1923// deprecated Hadoop-framed LZ4; LZO (3) is excluded because the writer cannot produce it
1924fn catalog_compression_from_code(code: u8) -> PyResult<CatalogCompression> {
1925    match code {
1926        0 => Ok(CatalogCompression::Uncompressed),
1927        1 => Ok(CatalogCompression::Snappy),
1928        2 => Ok(CatalogCompression::Gzip),
1929        4 => Ok(CatalogCompression::Brotli),
1930        5 => Ok(CatalogCompression::Lz4),
1931        6 => Ok(CatalogCompression::Zstd),
1932        _ => Err(to_pyvalue_err(format!(
1933            "Invalid compression code {code}, expected one of 0 (UNCOMPRESSED), 1 (SNAPPY), \
1934             2 (GZIP), 4 (BROTLI), 5 (LZ4_RAW), or 6 (ZSTD)"
1935        ))),
1936    }
1937}
1938
1939#[cfg(test)]
1940mod tests {
1941    use rstest::rstest;
1942
1943    use super::*;
1944
1945    #[rstest]
1946    #[case::uncompressed(0, CatalogCompression::Uncompressed)]
1947    #[case::snappy(1, CatalogCompression::Snappy)]
1948    #[case::gzip(2, CatalogCompression::Gzip)]
1949    #[case::brotli(4, CatalogCompression::Brotli)]
1950    #[case::lz4(5, CatalogCompression::Lz4)]
1951    #[case::zstd(6, CatalogCompression::Zstd)]
1952    fn catalog_compression_from_code_maps_parquet_codes(
1953        #[case] code: u8,
1954        #[case] expected: CatalogCompression,
1955    ) {
1956        assert_eq!(catalog_compression_from_code(code).unwrap(), expected);
1957    }
1958}