1use std::{collections::HashMap, path::PathBuf};
19
20use chrono::NaiveTime;
21use databento::dbn;
22use nautilus_core::python::{IntoPyObjectNautilusExt, to_pyvalue_err};
23use nautilus_model::{
24 data::{
25 Bar, Data, DataFFI, InstrumentStatus, OrderBookDelta, OrderBookDepth10, QuoteTick,
26 TradeTick,
27 },
28 identifiers::{InstrumentId, Symbol, Venue},
29 python::{
30 data::{DATA_FFI_CVEC_CAPSULE_NAME, DataFfiCVec},
31 instruments::instrument_any_to_pyobject,
32 },
33};
34use pyo3::{
35 prelude::*,
36 types::{PyCapsule, PyList},
37};
38use ustr::Ustr;
39
40use crate::{
41 decode::DatabentoDecodeConfig,
42 loader::DatabentoDataLoader,
43 types::{DatabentoImbalance, DatabentoPublisher, DatabentoStatistics, PublisherId},
44};
45
46#[expect(clippy::needless_pass_by_value)]
47#[pymethods]
48#[pyo3_stub_gen::derive::gen_stub_pymethods]
49impl DatabentoDataLoader {
50 #[new]
78 #[pyo3(signature = (publishers_filepath=None))]
79 fn py_new(publishers_filepath: Option<PathBuf>) -> PyResult<Self> {
80 Self::new(publishers_filepath).map_err(to_pyvalue_err)
81 }
82
83 #[pyo3(name = "load_publishers")]
89 fn py_load_publishers(&mut self, publishers_filepath: PathBuf) -> PyResult<()> {
90 self.load_publishers(publishers_filepath)
91 .map_err(to_pyvalue_err)
92 }
93
94 #[must_use]
96 #[pyo3(name = "get_publishers")]
97 fn py_get_publishers(&self) -> HashMap<u16, DatabentoPublisher> {
98 self.get_publishers()
99 .iter()
100 .map(|(&key, value)| (key, value.clone()))
101 .collect::<HashMap<u16, DatabentoPublisher>>()
102 }
103
104 #[pyo3(name = "set_dataset_for_venue")]
106 fn py_set_dataset_for_venue(&mut self, dataset: String, venue: Venue) {
107 self.set_dataset_for_venue(Ustr::from(&dataset), venue);
108 }
109
110 #[must_use]
112 #[pyo3(name = "get_dataset_for_venue")]
113 fn py_get_dataset_for_venue(&self, venue: &Venue) -> Option<String> {
114 self.get_dataset_for_venue(venue).map(ToString::to_string)
115 }
116
117 #[must_use]
119 #[pyo3(name = "get_venue_for_publisher")]
120 fn py_get_venue_for_publisher(&self, publisher_id: PublisherId) -> Option<String> {
121 self.get_venue_for_publisher(publisher_id)
122 .map(ToString::to_string)
123 }
124
125 #[pyo3(name = "set_price_precision")]
131 fn py_set_price_precision(&mut self, symbol: &str, price_precision: u8) {
132 self.set_price_precision(Symbol::from(symbol), price_precision);
133 }
134
135 #[must_use]
137 #[pyo3(name = "get_price_precisions")]
138 fn py_get_price_precisions(&self) -> HashMap<String, u8> {
139 self.get_price_precisions()
140 .iter()
141 .map(|(symbol, precision)| (symbol.to_string(), *precision))
142 .collect()
143 }
144
145 #[pyo3(name = "schema_for_file")]
146 fn py_schema_for_file(&self, filepath: PathBuf) -> PyResult<Option<String>> {
147 self.schema_from_file(&filepath).map_err(to_pyvalue_err)
148 }
149
150 #[pyo3(name = "load_instruments")]
155 #[pyo3(signature = (filepath, use_exchange_as_venue, skip_on_error=false, expiration_overrides=None))]
156 fn py_load_instruments(
157 &mut self,
158 py: Python,
159 filepath: PathBuf,
160 use_exchange_as_venue: bool,
161 skip_on_error: bool,
162 expiration_overrides: Option<HashMap<String, HashMap<String, String>>>,
163 ) -> PyResult<Py<PyAny>> {
164 let decode_config = build_decode_config(expiration_overrides)?;
165 let iter = self
166 .load_instruments(
167 &filepath,
168 use_exchange_as_venue,
169 skip_on_error,
170 decode_config.as_ref(),
171 )
172 .map_err(to_pyvalue_err)?;
173
174 let mut data = Vec::new();
175
176 for instrument in iter {
177 let py_object = instrument_any_to_pyobject(py, instrument)?;
178 data.push(py_object);
179 }
180
181 let list = PyList::new(py, &data).expect("Invalid `ExactSizeIterator`");
182
183 Ok(list.into_py_any_unwrap(py))
184 }
185
186 #[pyo3(name = "load_order_book_deltas")]
191 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
192 fn py_load_order_book_deltas(
193 &self,
194 filepath: PathBuf,
195 instrument_id: Option<InstrumentId>,
196 price_precision: Option<u8>,
197 ) -> PyResult<Vec<OrderBookDelta>> {
198 self.load_order_book_deltas(&filepath, instrument_id, price_precision)
199 .map_err(to_pyvalue_err)
200 }
201
202 #[pyo3(name = "load_order_book_deltas_as_pycapsule")]
203 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None, include_trades=None))]
204 fn py_load_order_book_deltas_as_pycapsule(
205 &self,
206 py: Python,
207 filepath: PathBuf,
208 instrument_id: Option<InstrumentId>,
209 price_precision: Option<u8>,
210 include_trades: Option<bool>,
211 ) -> PyResult<Py<PyAny>> {
212 let iter = self
213 .read_records::<dbn::MboMsg>(
214 &filepath,
215 instrument_id,
216 price_precision,
217 include_trades.unwrap_or(false),
218 None,
219 )
220 .map_err(to_pyvalue_err)?;
221
222 exhaust_data_iter_to_pycapsule(py, iter).map_err(to_pyvalue_err)
223 }
224
225 #[pyo3(name = "load_order_book_depth10")]
227 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
228 fn py_load_order_book_depth10(
229 &self,
230 filepath: PathBuf,
231 instrument_id: Option<InstrumentId>,
232 price_precision: Option<u8>,
233 ) -> PyResult<Vec<OrderBookDepth10>> {
234 self.load_order_book_depth10(&filepath, instrument_id, price_precision)
235 .map_err(to_pyvalue_err)
236 }
237
238 #[pyo3(name = "load_order_book_depth10_as_pycapsule")]
239 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
240 fn py_load_order_book_depth10_as_pycapsule(
241 &self,
242 py: Python,
243 filepath: PathBuf,
244 instrument_id: Option<InstrumentId>,
245 price_precision: Option<u8>,
246 ) -> PyResult<Py<PyAny>> {
247 let iter = self
248 .read_records::<dbn::Mbp10Msg>(&filepath, instrument_id, price_precision, false, None)
249 .map_err(to_pyvalue_err)?;
250
251 exhaust_data_iter_to_pycapsule(py, iter).map_err(to_pyvalue_err)
252 }
253
254 #[pyo3(name = "load_quotes")]
256 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
257 fn py_load_quotes(
258 &self,
259 filepath: PathBuf,
260 instrument_id: Option<InstrumentId>,
261 price_precision: Option<u8>,
262 ) -> PyResult<Vec<QuoteTick>> {
263 self.load_quotes(&filepath, instrument_id, price_precision)
264 .map_err(to_pyvalue_err)
265 }
266
267 #[pyo3(name = "load_quotes_as_pycapsule")]
268 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None, include_trades=None))]
269 fn py_load_quotes_as_pycapsule(
270 &self,
271 py: Python,
272 filepath: PathBuf,
273 instrument_id: Option<InstrumentId>,
274 price_precision: Option<u8>,
275 include_trades: Option<bool>,
276 ) -> PyResult<Py<PyAny>> {
277 let iter = self
278 .read_records::<dbn::Mbp1Msg>(
279 &filepath,
280 instrument_id,
281 price_precision,
282 include_trades.unwrap_or(false),
283 None,
284 )
285 .map_err(to_pyvalue_err)?;
286
287 exhaust_data_iter_to_pycapsule(py, iter).map_err(to_pyvalue_err)
288 }
289
290 #[pyo3(name = "load_bbo_quotes")]
292 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
293 fn py_load_bbo_quotes(
294 &self,
295 filepath: PathBuf,
296 instrument_id: Option<InstrumentId>,
297 price_precision: Option<u8>,
298 ) -> PyResult<Vec<QuoteTick>> {
299 self.load_bbo_quotes(&filepath, instrument_id, price_precision)
300 .map_err(to_pyvalue_err)
301 }
302
303 #[pyo3(name = "load_bbo_quotes_as_pycapsule")]
304 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
305 fn py_load_bbo_quotes_as_pycapsule(
306 &self,
307 py: Python,
308 filepath: PathBuf,
309 instrument_id: Option<InstrumentId>,
310 price_precision: Option<u8>,
311 ) -> PyResult<Py<PyAny>> {
312 let iter = self
313 .read_records::<dbn::BboMsg>(&filepath, instrument_id, price_precision, false, None)
314 .map_err(to_pyvalue_err)?;
315
316 exhaust_data_iter_to_pycapsule(py, iter).map_err(to_pyvalue_err)
317 }
318
319 #[pyo3(name = "load_cmbp_quotes")]
321 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
322 fn py_load_cmbp_quotes(
323 &self,
324 filepath: PathBuf,
325 instrument_id: Option<InstrumentId>,
326 price_precision: Option<u8>,
327 ) -> PyResult<Vec<QuoteTick>> {
328 self.load_cmbp_quotes(&filepath, instrument_id, price_precision)
329 .map_err(to_pyvalue_err)
330 }
331
332 #[pyo3(name = "load_cmbp_quotes_as_pycapsule")]
333 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None, include_trades=None))]
334 fn py_load_cmbp_quotes_as_pycapsule(
335 &self,
336 py: Python,
337 filepath: PathBuf,
338 instrument_id: Option<InstrumentId>,
339 price_precision: Option<u8>,
340 include_trades: Option<bool>,
341 ) -> PyResult<Py<PyAny>> {
342 let iter = self
343 .read_records::<dbn::Cmbp1Msg>(
344 &filepath,
345 instrument_id,
346 price_precision,
347 include_trades.unwrap_or(false),
348 None,
349 )
350 .map_err(to_pyvalue_err)?;
351
352 exhaust_data_iter_to_pycapsule(py, iter).map_err(to_pyvalue_err)
353 }
354
355 #[pyo3(name = "load_cbbo_quotes")]
357 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
358 fn py_load_cbbo_quotes(
359 &self,
360 filepath: PathBuf,
361 instrument_id: Option<InstrumentId>,
362 price_precision: Option<u8>,
363 ) -> PyResult<Vec<QuoteTick>> {
364 self.load_cbbo_quotes(&filepath, instrument_id, price_precision)
365 .map_err(to_pyvalue_err)
366 }
367
368 #[pyo3(name = "load_cbbo_quotes_as_pycapsule")]
369 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
370 fn py_load_cbbo_quotes_as_pycapsule(
371 &self,
372 py: Python,
373 filepath: PathBuf,
374 instrument_id: Option<InstrumentId>,
375 price_precision: Option<u8>,
376 ) -> PyResult<Py<PyAny>> {
377 let iter = self
378 .read_records::<dbn::CbboMsg>(&filepath, instrument_id, price_precision, false, None)
379 .map_err(to_pyvalue_err)?;
380
381 exhaust_data_iter_to_pycapsule(py, iter).map_err(to_pyvalue_err)
382 }
383
384 #[pyo3(name = "load_tbbo_trades")]
386 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
387 fn py_load_tbbo_trades(
388 &self,
389 filepath: PathBuf,
390 instrument_id: Option<InstrumentId>,
391 price_precision: Option<u8>,
392 ) -> PyResult<Vec<TradeTick>> {
393 self.load_tbbo_trades(&filepath, instrument_id, price_precision)
394 .map_err(to_pyvalue_err)
395 }
396
397 #[pyo3(name = "load_tbbo_trades_as_pycapsule")]
398 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
399 fn py_load_tbbo_trades_as_pycapsule(
400 &self,
401 py: Python,
402 filepath: PathBuf,
403 instrument_id: Option<InstrumentId>,
404 price_precision: Option<u8>,
405 ) -> PyResult<Py<PyAny>> {
406 let iter = self
407 .read_records::<dbn::TbboMsg>(&filepath, instrument_id, price_precision, false, None)
408 .map_err(to_pyvalue_err)?;
409
410 exhaust_data_iter_to_pycapsule(py, iter).map_err(to_pyvalue_err)
411 }
412
413 #[pyo3(name = "load_tcbbo_trades")]
415 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
416 fn py_load_tcbbo_trades(
417 &self,
418 filepath: PathBuf,
419 instrument_id: Option<InstrumentId>,
420 price_precision: Option<u8>,
421 ) -> PyResult<Vec<TradeTick>> {
422 self.load_tcbbo_trades(&filepath, instrument_id, price_precision)
423 .map_err(to_pyvalue_err)
424 }
425
426 #[pyo3(name = "load_tcbbo_trades_as_pycapsule")]
427 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
428 fn py_load_tcbbo_trades_as_pycapsule(
429 &self,
430 py: Python,
431 filepath: PathBuf,
432 instrument_id: Option<InstrumentId>,
433 price_precision: Option<u8>,
434 ) -> PyResult<Py<PyAny>> {
435 let iter = self
436 .read_records::<dbn::TcbboMsg>(&filepath, instrument_id, price_precision, false, None)
437 .map_err(to_pyvalue_err)?;
438
439 exhaust_data_iter_to_pycapsule(py, iter).map_err(to_pyvalue_err)
440 }
441
442 #[pyo3(name = "load_trades")]
444 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
445 fn py_load_trades(
446 &self,
447 filepath: PathBuf,
448 instrument_id: Option<InstrumentId>,
449 price_precision: Option<u8>,
450 ) -> PyResult<Vec<TradeTick>> {
451 self.load_trades(&filepath, instrument_id, price_precision)
452 .map_err(to_pyvalue_err)
453 }
454
455 #[pyo3(name = "load_trades_as_pycapsule")]
456 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
457 fn py_load_trades_as_pycapsule(
458 &self,
459 py: Python,
460 filepath: PathBuf,
461 instrument_id: Option<InstrumentId>,
462 price_precision: Option<u8>,
463 ) -> PyResult<Py<PyAny>> {
464 let iter = self
465 .read_records::<dbn::TradeMsg>(&filepath, instrument_id, price_precision, false, None)
466 .map_err(to_pyvalue_err)?;
467
468 exhaust_data_iter_to_pycapsule(py, iter).map_err(to_pyvalue_err)
469 }
470
471 #[pyo3(name = "load_bars")]
473 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None, timestamp_on_close=true))]
474 fn py_load_bars(
475 &self,
476 filepath: PathBuf,
477 instrument_id: Option<InstrumentId>,
478 price_precision: Option<u8>,
479 timestamp_on_close: bool,
480 ) -> PyResult<Vec<Bar>> {
481 self.load_bars(
482 &filepath,
483 instrument_id,
484 price_precision,
485 Some(timestamp_on_close),
486 )
487 .map_err(to_pyvalue_err)
488 }
489
490 #[pyo3(name = "load_bars_as_pycapsule")]
491 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None, timestamp_on_close=true))]
492 fn py_load_bars_as_pycapsule(
493 &self,
494 py: Python,
495 filepath: PathBuf,
496 instrument_id: Option<InstrumentId>,
497 price_precision: Option<u8>,
498 timestamp_on_close: bool,
499 ) -> PyResult<Py<PyAny>> {
500 let iter = self
501 .read_records::<dbn::OhlcvMsg>(
502 &filepath,
503 instrument_id,
504 price_precision,
505 false,
506 Some(timestamp_on_close),
507 )
508 .map_err(to_pyvalue_err)?;
509
510 exhaust_data_iter_to_pycapsule(py, iter).map_err(to_pyvalue_err)
511 }
512
513 #[pyo3(name = "load_status")]
514 #[pyo3(signature = (filepath, instrument_id=None))]
515 fn py_load_status(
516 &self,
517 filepath: PathBuf,
518 instrument_id: Option<InstrumentId>,
519 ) -> PyResult<Vec<InstrumentStatus>> {
520 let iter = self
521 .load_status_records::<dbn::StatusMsg>(&filepath, instrument_id)
522 .map_err(to_pyvalue_err)?;
523
524 let mut data = Vec::new();
525
526 for result in iter {
527 match result {
528 Ok(item) => data.push(item),
529 Err(e) => return Err(to_pyvalue_err(e)),
530 }
531 }
532
533 Ok(data)
534 }
535
536 #[pyo3(name = "load_imbalance")]
537 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
538 fn py_load_imbalance(
539 &self,
540 filepath: PathBuf,
541 instrument_id: Option<InstrumentId>,
542 price_precision: Option<u8>,
543 ) -> PyResult<Vec<DatabentoImbalance>> {
544 let iter = self
545 .read_imbalance_records::<dbn::ImbalanceMsg>(&filepath, instrument_id, price_precision)
546 .map_err(to_pyvalue_err)?;
547
548 let mut data = Vec::new();
549
550 for result in iter {
551 match result {
552 Ok(item) => data.push(item),
553 Err(e) => return Err(to_pyvalue_err(e)),
554 }
555 }
556
557 Ok(data)
558 }
559
560 #[pyo3(name = "load_statistics")]
561 #[pyo3(signature = (filepath, instrument_id=None, price_precision=None))]
562 fn py_load_statistics(
563 &self,
564 filepath: PathBuf,
565 instrument_id: Option<InstrumentId>,
566 price_precision: Option<u8>,
567 ) -> PyResult<Vec<DatabentoStatistics>> {
568 let iter = self
569 .read_statistics_records::<dbn::StatMsg>(&filepath, instrument_id, price_precision)
570 .map_err(to_pyvalue_err)?;
571
572 let mut data = Vec::new();
573
574 for result in iter {
575 match result {
576 Ok(item) => data.push(item),
577 Err(e) => return Err(to_pyvalue_err(e)),
578 }
579 }
580
581 Ok(data)
582 }
583}
584
585fn exhaust_data_iter_to_pycapsule(
586 py: Python,
587 iter: impl Iterator<Item = anyhow::Result<(Option<Data>, Option<Data>)>>,
588) -> anyhow::Result<Py<PyAny>> {
589 let mut data = Vec::new();
590
591 for result in iter {
592 match result {
593 Ok((Some(item1), None)) => data.push(item1),
594 Ok((None, Some(item2))) => data.push(item2),
595 Ok((Some(item1), Some(item2))) => {
596 data.push(item1);
597 data.push(item2);
598 }
599 Ok((None, None)) => {}
600 Err(e) => return Err(e),
601 }
602 }
603
604 let ffi_data: Vec<DataFFI> = data
605 .into_iter()
606 .map(DataFFI::try_from)
607 .collect::<Result<Vec<_>, _>>()
608 .map_err(to_pyvalue_err)?;
609 let cvec: DataFfiCVec = ffi_data.into();
610 let capsule = PyCapsule::new_with_value_and_destructor::<DataFfiCVec, _>(
612 py,
613 cvec,
614 DATA_FFI_CVEC_CAPSULE_NAME,
615 |_, _| {},
616 )?;
617
618 Ok(capsule.into_py_any_unwrap(py))
621}
622
623fn build_decode_config(
625 expiration_overrides: Option<HashMap<String, HashMap<String, String>>>,
626) -> PyResult<Option<DatabentoDecodeConfig>> {
627 expiration_overrides
628 .map(|overrides| decode_config_from_overrides(overrides).map_err(to_pyvalue_err))
629 .transpose()
630}
631
632fn decode_config_from_overrides(
635 overrides: HashMap<String, HashMap<String, String>>,
636) -> Result<DatabentoDecodeConfig, String> {
637 let mut config = DatabentoDecodeConfig::default();
638
639 for (dataset_name, times) in overrides {
640 let dataset = dataset_name
641 .parse::<dbn::Dataset>()
642 .map_err(|_| format!("Unknown dataset '{dataset_name}'"))?;
643 let rule = config
644 .option_expiration
645 .get_mut(&dataset)
646 .ok_or_else(|| format!("No expiration correction rule for dataset '{dataset_name}'"))?;
647
648 for (underlying, value) in times {
649 let time = parse_expiration_time(&value)?;
650 if underlying == "default" {
651 rule.default_time = time;
652 } else {
653 rule.overrides.insert(Ustr::from(&underlying), time);
654 }
655 }
656 }
657
658 Ok(config)
659}
660
661fn parse_expiration_time(value: &str) -> Result<NaiveTime, String> {
663 NaiveTime::parse_from_str(value, "%H:%M:%S")
664 .or_else(|_| NaiveTime::parse_from_str(value, "%H:%M"))
665 .map_err(|_| format!("Invalid expiration time '{value}', expected 'HH:MM' or 'HH:MM:SS'"))
666}
667
668#[cfg(test)]
669mod tests {
670 use std::collections::HashMap;
671
672 use chrono::NaiveTime;
673 use databento::dbn;
674 use rstest::rstest;
675 use ustr::Ustr;
676
677 use super::decode_config_from_overrides;
678
679 fn overrides(
680 dataset: &str,
681 entries: &[(&str, &str)],
682 ) -> HashMap<String, HashMap<String, String>> {
683 let inner = entries
684 .iter()
685 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
686 .collect();
687 HashMap::from([(dataset.to_string(), inner)])
688 }
689
690 #[rstest]
691 fn test_default_key_sets_dataset_default_time() {
692 let config =
693 decode_config_from_overrides(overrides("OPRA.PILLAR", &[("default", "15:30")]))
694 .unwrap();
695 let rule = config
696 .option_expiration
697 .get(&dbn::Dataset::OpraPillar)
698 .unwrap();
699 assert_eq!(
700 rule.default_time,
701 NaiveTime::from_hms_opt(15, 30, 0).unwrap()
702 );
703 assert!(rule.overrides.is_empty());
704 }
705
706 #[rstest]
707 fn test_underlying_key_sets_override_and_keeps_default() {
708 let config =
709 decode_config_from_overrides(overrides("OPRA.PILLAR", &[("SPX", "09:30:00")])).unwrap();
710 let rule = config
711 .option_expiration
712 .get(&dbn::Dataset::OpraPillar)
713 .unwrap();
714 assert_eq!(
715 rule.overrides.get(&Ustr::from("SPX")).copied(),
716 Some(NaiveTime::from_hms_opt(9, 30, 0).unwrap())
717 );
718 assert_eq!(
719 rule.default_time,
720 NaiveTime::from_hms_opt(16, 0, 0).unwrap()
721 );
722 }
723
724 #[rstest]
725 fn test_unknown_dataset_errors() {
726 let err = decode_config_from_overrides(overrides("NOT.ADATASET", &[("default", "16:00")]))
727 .unwrap_err();
728 assert!(err.contains("Unknown dataset"), "was: {err}");
729 }
730
731 #[rstest]
732 fn test_dataset_without_rule_errors() {
733 let err = decode_config_from_overrides(overrides("GLBX.MDP3", &[("default", "16:00")]))
735 .unwrap_err();
736 assert!(err.contains("No expiration correction rule"), "was: {err}");
737 }
738
739 #[rstest]
740 fn test_invalid_time_errors() {
741 let err = decode_config_from_overrides(overrides("OPRA.PILLAR", &[("default", "nope")]))
742 .unwrap_err();
743 assert!(err.contains("Invalid expiration time"), "was: {err}");
744 }
745}