pub(crate) mod column_info;
#[cfg(feature = "polars")]
mod polars;
#[cfg(feature = "python")]
mod python;
pub(crate) mod table;
#[cfg(feature = "python")]
use std::sync::Arc;
use std::{cmp, collections::HashSet, fmt::Debug, str::FromStr};
use calamine::{CellType, Range, Sheet as CalamineSheet, SheetVisible as CalamineSheetVisible};
use column_info::{AvailableColumns, ColumnInfoNoDtype};
#[cfg(feature = "polars")]
use polars_core::frame::DataFrame;
#[cfg(feature = "python")]
use pyo3::{Py, PyAny, Python, pyclass};
use self::column_info::{ColumnInfo, build_available_columns_info, finalize_column_info};
use crate::utils::schema::get_schema_sample_rows;
use crate::{
LoadSheetOrTableOptions,
data::{ExcelSheetData, FastExcelColumn},
error::{ErrorContext, FastExcelError, FastExcelErrorKind, FastExcelResult},
types::{dtype::DTypes, idx_or_name::IdxOrName},
};
#[cfg(feature = "python")]
pub(crate) use python::{CellError, CellErrors};
#[derive(Debug)]
pub(crate) enum Header {
None,
At(usize),
With(Vec<String>),
}
impl Header {
pub(crate) fn new(header_row: Option<usize>, column_names: Option<Vec<String>>) -> Self {
match column_names {
Some(headers) => Header::With(headers),
None => match header_row {
Some(row) => Header::At(row),
None => Header::None,
},
}
}
pub(crate) fn offset(&self) -> usize {
match self {
Header::At(index) => index + 1,
Header::None => 0,
Header::With(_) => 0,
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(not(feature = "python"), derive(PartialEq, Eq))]
pub(crate) struct Pagination {
skip_rows: SkipRows,
n_rows: Option<usize>,
}
#[derive(Debug, Default, Clone)]
#[cfg_attr(not(feature = "python"), derive(PartialEq, Eq))]
pub enum SkipRows {
Simple(usize),
List(HashSet<usize>),
#[cfg(feature = "python")]
Callable(Arc<Py<PyAny>>),
#[default]
SkipEmptyRowsAtBeginning,
}
impl SkipRows {
pub(crate) fn simple_offset(&self) -> Option<usize> {
match self {
SkipRows::Simple(offset) => Some(*offset),
SkipRows::SkipEmptyRowsAtBeginning => Some(0), _ => None,
}
}
}
impl Pagination {
pub(crate) fn try_new<CT: CellType>(
skip_rows: SkipRows,
n_rows: Option<usize>,
range: &Range<CT>,
) -> FastExcelResult<Self> {
let max_height = range.height();
if let SkipRows::Simple(skip_count) = &skip_rows {
if max_height < *skip_count {
return Err(FastExcelErrorKind::InvalidParameters(format!(
"Too many rows skipped. Max height is {max_height}"
))
.into());
}
}
Ok(Self { skip_rows, n_rows })
}
pub(crate) fn offset(&self) -> usize {
self.skip_rows.simple_offset().unwrap_or(0)
}
pub(crate) fn n_rows(&self) -> Option<usize> {
self.n_rows
}
pub(crate) fn skip_rows(&self) -> &SkipRows {
&self.skip_rows
}
}
#[derive(Default)]
pub enum SelectedColumns {
#[default]
All,
Selection(Vec<IdxOrName>),
#[cfg(feature = "python")]
DynamicSelection(Py<PyAny>),
DeferredSelection(Vec<DeferredColumnSelection>),
}
#[derive(Debug, Clone, PartialEq)]
pub enum DeferredColumnSelection {
Fixed(IdxOrName),
OpenEndedRange(usize),
FromBeginningRange(usize),
}
impl std::fmt::Debug for SelectedColumns {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::All => write!(f, "All"),
Self::Selection(selection) => write!(f, "Selection({selection:?})"),
#[cfg(feature = "python")]
Self::DynamicSelection(func) => {
let addr = func as *const _ as usize;
write!(f, "DynamicSelection({addr})")
}
Self::DeferredSelection(deferred) => write!(f, "DeferredSelection({deferred:?})"),
}
}
}
impl PartialEq for SelectedColumns {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::All, Self::All) => true,
(Self::Selection(selection), Self::Selection(other_selection)) => {
selection == other_selection
}
#[cfg(feature = "python")]
(Self::DynamicSelection(f1), Self::DynamicSelection(f2)) => std::ptr::eq(f1, f2),
(Self::DeferredSelection(deferred1), Self::DeferredSelection(deferred2)) => {
deferred1 == deferred2
}
_ => false,
}
}
}
pub(crate) fn deferred_selection_to_concrete(
deferred_selection: &[DeferredColumnSelection],
max_col_index: usize,
) -> Vec<IdxOrName> {
let mut resolved_indices = Vec::new();
for deferred in deferred_selection {
match deferred {
DeferredColumnSelection::Fixed(idx_or_name) => {
resolved_indices.push(idx_or_name.clone());
}
DeferredColumnSelection::OpenEndedRange(start_idx) => {
resolved_indices.extend((*start_idx..=max_col_index).map(IdxOrName::Idx));
}
DeferredColumnSelection::FromBeginningRange(end_idx) => {
let actual_end = (*end_idx).min(max_col_index);
resolved_indices.extend((0..=actual_end).map(IdxOrName::Idx));
}
}
}
resolved_indices
}
impl SelectedColumns {
pub(super) fn select_columns(
&self,
available_columns: Vec<ColumnInfoNoDtype>,
) -> FastExcelResult<Vec<ColumnInfoNoDtype>> {
match self {
SelectedColumns::All => Ok(available_columns),
SelectedColumns::Selection(selection) => {
let selected_indices: Vec<usize> = selection
.iter()
.map(|selected_column| {
match selected_column {
IdxOrName::Idx(index) => available_columns
.iter()
.position(|col_info| &col_info.absolute_index() == index),
IdxOrName::Name(name) => available_columns
.iter()
.position(|col_info| col_info.name() == name.as_str()),
}
.ok_or_else(|| {
FastExcelErrorKind::ColumnNotFound(selected_column.clone()).into()
})
.with_context(|| format!("available columns are: {available_columns:?}"))
})
.collect::<FastExcelResult<_>>()?;
let mut cols: Vec<(usize, ColumnInfoNoDtype)> = available_columns
.into_iter()
.enumerate()
.filter_map(|(idx, elem)| {
selected_indices
.iter()
.position(|selected_idx| *selected_idx == idx)
.map(|position| (position, elem))
})
.collect();
cols.sort_by_key(|(pos, _elem)| *pos);
Ok(cols.into_iter().map(|(_pos, elem)| elem).collect())
}
#[cfg(feature = "python")]
SelectedColumns::DynamicSelection(use_col_func) => Python::attach(|py| {
available_columns
.into_iter()
.filter_map(
|col_info| match use_col_func.call1(py, (col_info.clone(),)) {
Err(err) => Some(Err(FastExcelErrorKind::InvalidParameters(format!(
"`use_columns` callable could not be called ({err})"
))
.into())),
Ok(should_use_col) => match should_use_col.extract::<bool>(py) {
Err(_) => Some(Err(FastExcelErrorKind::InvalidParameters(
"`use_columns` callable should return a boolean".to_string(),
)
.into())),
Ok(true) => Some(Ok(col_info)),
Ok(false) => None,
},
},
)
.collect()
}),
SelectedColumns::DeferredSelection(deferred_selection) => {
let max_col_index = available_columns
.last()
.map_or(0, |col| col.absolute_index());
let concrete_selection = SelectedColumns::Selection(
deferred_selection_to_concrete(deferred_selection, max_col_index),
);
concrete_selection.select_columns(available_columns)
}
}
}
const ALPHABET: [char; 26] = [
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R',
'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
];
fn col_idx_for_col_as_letter(col: &str) -> FastExcelResult<usize> {
use FastExcelErrorKind::InvalidParameters;
if col.is_empty() {
return Err(InvalidParameters(
"a column should have at least one character, got none".to_string(),
)
.into());
}
col.chars()
.rev()
.enumerate()
.map(|(idx, col_chr)| {
let pos_in_alphabet = Self::ALPHABET
.iter()
.position(|chr| chr == &col_chr)
.ok_or_else(|| {
FastExcelError::from(InvalidParameters(format!(
"Char is not a valid column name: {col_chr}"
)))
})?;
Ok(match idx {
0 => pos_in_alphabet,
_ => 26usize.pow(idx as u32) * (pos_in_alphabet + 1),
})
})
.try_fold(0usize, |acc, elem_result| {
elem_result.map(|elem| acc + elem)
})
}
fn col_indices_for_letter_range(col_range: &str) -> FastExcelResult<Vec<usize>> {
use FastExcelErrorKind::InvalidParameters;
let col_elements = col_range.split(':').collect::<Vec<_>>();
if col_elements.len() == 2 {
let start = Self::col_idx_for_col_as_letter(col_elements[0])
.with_context(|| format!("invalid start element for range \"{col_range}\""))?;
if col_elements[1].is_empty() {
return Err(InvalidParameters(format!(
"open-ended range detected: \"{col_range}\". This should be handled by col_selection_for_letter_range"
))
.into());
}
let end = Self::col_idx_for_col_as_letter(col_elements[1])
.with_context(|| format!("invalid end element for range \"{col_range}\""))?;
match start.cmp(&end) {
cmp::Ordering::Less => Ok((start..=end).collect()),
cmp::Ordering::Greater => Err(InvalidParameters(format!(
"end of range is before start: \"{col_range}\""
))
.into()),
cmp::Ordering::Equal => {
Err(InvalidParameters(format!("empty range: \"{col_range}\"")).into())
}
}
} else {
Err(InvalidParameters(format!(
"expected range to contain exactly 2 elements, got {n_elements}: \"{col_range}\"",
n_elements = col_elements.len()
))
.into())
}
}
fn col_selection_for_letter_range(
col_range: &str,
) -> FastExcelResult<Vec<DeferredColumnSelection>> {
use FastExcelErrorKind::InvalidParameters;
let col_elements = col_range.split(':').collect::<Vec<_>>();
if col_elements.len() == 2 {
if col_elements[0].is_empty() {
if col_elements[1].is_empty() {
return Err(InvalidParameters(format!(
"cannot have both start and end empty in range: \"{col_range}\""
))
.into());
}
let end = Self::col_idx_for_col_as_letter(col_elements[1])
.with_context(|| format!("invalid end element for range \"{col_range}\""))?;
return Ok(vec![DeferredColumnSelection::FromBeginningRange(end)]);
}
let start = Self::col_idx_for_col_as_letter(col_elements[0])
.with_context(|| format!("invalid start element for range \"{col_range}\""))?;
if col_elements[1].is_empty() {
return Ok(vec![DeferredColumnSelection::OpenEndedRange(start)]);
}
let end = Self::col_idx_for_col_as_letter(col_elements[1])
.with_context(|| format!("invalid end element for range \"{col_range}\""))?;
match start.cmp(&end) {
cmp::Ordering::Less => Ok((start..=end)
.map(|idx| DeferredColumnSelection::Fixed(IdxOrName::Idx(idx)))
.collect()),
cmp::Ordering::Greater => Err(InvalidParameters(format!(
"end of range is before start: \"{col_range}\""
))
.into()),
cmp::Ordering::Equal => {
Err(InvalidParameters(format!("empty range: \"{col_range}\"")).into())
}
}
} else {
Err(InvalidParameters(format!(
"expected range to contain exactly 2 elements, got {n_elements}: \"{col_range}\"",
n_elements = col_elements.len()
))
.into())
}
}
}
impl FromStr for SelectedColumns {
type Err = FastExcelError;
fn from_str(s: &str) -> FastExcelResult<Self> {
let uppercase_s = s.to_uppercase();
let parts: Vec<&str> = uppercase_s.split(',').collect();
let has_open_ended = parts
.iter()
.any(|p| p.contains(':') && (p.ends_with(':') || p.starts_with(':')));
if has_open_ended {
let deferred_selections = parts
.iter()
.map(|part| {
if part.contains(':') {
Self::col_selection_for_letter_range(part).map(|mut selections| {
std::mem::take(&mut selections)
.into_iter()
.collect::<Vec<_>>()
})
} else {
Self::col_idx_for_col_as_letter(part)
.map(|idx| vec![DeferredColumnSelection::Fixed(IdxOrName::Idx(idx))])
}
})
.collect::<Result<Vec<Vec<_>>, _>>()?
.into_iter()
.flatten()
.collect();
Ok(Self::DeferredSelection(deferred_selections))
} else {
let unique_col_indices: HashSet<usize> = parts
.iter()
.map(|col_or_range| {
if col_or_range.contains(':') {
Self::col_indices_for_letter_range(col_or_range)
} else {
Self::col_idx_for_col_as_letter(col_or_range).map(|idx| vec![idx])
}
})
.collect::<FastExcelResult<Vec<_>>>()?
.into_iter()
.flatten()
.collect();
let mut sorted_col_indices: Vec<usize> = unique_col_indices.into_iter().collect();
sorted_col_indices.sort();
Ok(Self::Selection(
sorted_col_indices.into_iter().map(IdxOrName::Idx).collect(),
))
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SheetVisible {
Visible,
Hidden,
VeryHidden,
}
impl From<CalamineSheetVisible> for SheetVisible {
fn from(value: CalamineSheetVisible) -> Self {
match value {
CalamineSheetVisible::Visible => SheetVisible::Visible,
CalamineSheetVisible::Hidden => SheetVisible::Hidden,
CalamineSheetVisible::VeryHidden => SheetVisible::VeryHidden,
}
}
}
#[derive(Debug)]
#[cfg_attr(feature = "python", pyclass(name = "_ExcelSheet"))]
pub struct ExcelSheet {
sheet_meta: CalamineSheet,
header: Header,
pagination: Pagination,
data: ExcelSheetData<'static>,
height: Option<usize>,
total_height: Option<usize>,
width: Option<usize>,
limit: usize,
opts: LoadSheetOrTableOptions,
selected_columns: Vec<ColumnInfo>,
available_columns: AvailableColumns,
}
impl ExcelSheet {
pub(crate) fn data(&self) -> &ExcelSheetData<'_> {
&self.data
}
pub(crate) fn try_new(
sheet_meta: CalamineSheet,
data: ExcelSheetData<'static>,
opts: LoadSheetOrTableOptions,
) -> FastExcelResult<Self> {
let header = Header::new(opts.data_header_row(), opts.column_names.clone());
let available_columns_info =
build_available_columns_info(&data, &opts.selected_columns, &header)?;
let selected_columns_info = opts
.selected_columns
.select_columns(available_columns_info)?;
let pagination = match &data {
ExcelSheetData::Owned(range) => {
Pagination::try_new(opts.skip_rows.clone(), opts.n_rows, range)?
}
ExcelSheetData::Ref(range) => {
Pagination::try_new(opts.skip_rows.clone(), opts.n_rows, range)?
}
};
let mut sheet = ExcelSheet {
sheet_meta,
header,
pagination,
data,
opts,
height: None,
total_height: None,
width: None,
limit: 0,
available_columns: AvailableColumns::Pending,
selected_columns: Vec::with_capacity(0),
};
sheet.limit = sheet.compute_limit();
let row_limit = sheet.schema_sample_rows();
let selected_columns = finalize_column_info(
selected_columns_info,
&sheet.data,
sheet.offset(),
row_limit,
sheet.opts.dtypes.as_ref(),
&sheet.opts.dtype_coercion,
sheet.opts.whitespace_as_null,
)?;
sheet.selected_columns = selected_columns;
Ok(sheet)
}
fn ensure_available_columns_loaded(&mut self) -> FastExcelResult<()> {
let available_columns = match &self.available_columns {
AvailableColumns::Pending => {
let available_columns_info = build_available_columns_info(
&self.data,
&self.opts.selected_columns,
&self.header,
)?;
let final_info = finalize_column_info(
available_columns_info,
self.data(),
self.offset(),
self.limit(),
self.opts.dtypes.as_ref(),
&self.opts.dtype_coercion,
self.opts.whitespace_as_null,
)?;
AvailableColumns::Loaded(final_info)
}
AvailableColumns::Loaded(_) => return Ok(()),
};
self.available_columns = available_columns;
Ok(())
}
fn load_available_columns(&mut self) -> FastExcelResult<&[ColumnInfo]> {
self.ensure_available_columns_loaded()?;
self.available_columns.as_loaded()
}
fn compute_limit(&self) -> usize {
let upper_bound = if self.opts.skip_whitespace_tail_rows {
self.data.height_without_tail_whitespace()
} else {
self.data.height()
};
if let Some(n_rows) = self.pagination.n_rows {
let limit = self.offset() + n_rows;
if limit < upper_bound {
return limit;
}
}
upper_bound
}
pub(crate) fn limit(&self) -> usize {
self.limit
}
pub(crate) fn schema_sample_rows(&self) -> usize {
get_schema_sample_rows(self.opts.schema_sample_rows, self.offset(), self.limit())
}
pub fn width(&mut self) -> usize {
self.width.unwrap_or_else(|| {
let width = self.data.width();
self.width = Some(width);
width
})
}
pub fn height(&mut self) -> usize {
self.height.unwrap_or_else(|| {
use crate::data::generate_row_selector;
let height =
generate_row_selector(self.pagination.skip_rows(), self.offset(), self.limit())
.map(|selector| selector.len())
.unwrap_or_else(|_| self.limit() - self.offset());
self.height = Some(height);
height
})
}
pub fn total_height(&mut self) -> usize {
self.total_height.unwrap_or_else(|| {
let total_height = self.data.height() - self.header.offset();
self.total_height = Some(total_height);
total_height
})
}
pub fn offset(&self) -> usize {
self.header.offset() + self.pagination.offset()
}
pub fn selected_columns(&self) -> &Vec<ColumnInfo> {
&self.selected_columns
}
pub fn available_columns(&mut self) -> FastExcelResult<Vec<ColumnInfo>> {
self.load_available_columns().map(|cols| cols.to_vec())
}
pub fn specified_dtypes(&self) -> Option<&DTypes> {
self.opts.dtypes.as_ref()
}
pub fn name(&self) -> &str {
&self.sheet_meta.name
}
pub fn visible(&self) -> SheetVisible {
self.sheet_meta.visible.into()
}
pub fn to_columns(&self) -> FastExcelResult<Vec<FastExcelColumn>> {
self.selected_columns
.iter()
.map(|column_info| {
let offset = self.offset();
let limit = self.limit();
let whitespace_as_null = self.opts.whitespace_as_null;
match self.data() {
ExcelSheetData::Owned(range) => FastExcelColumn::try_from_column_info(
column_info,
range,
offset,
limit,
whitespace_as_null,
),
ExcelSheetData::Ref(range) => FastExcelColumn::try_from_column_info(
column_info,
range,
offset,
limit,
whitespace_as_null,
),
}
})
.collect()
}
#[cfg(feature = "polars")]
pub fn to_polars(&self) -> FastExcelResult<DataFrame> {
let pl_columns = self.to_columns()?.into_iter().map(Into::into).collect();
DataFrame::new_infer_height(pl_columns).map_err(|err| {
FastExcelErrorKind::Internal(format!("could not create DataFrame: {err:?}")).into()
})
}
}
#[cfg(feature = "__pyo3-tests")]
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use pyo3::{
prelude::PyListMethods,
types::{PyList, PyString},
};
use rstest::rstest;
#[test]
fn selected_columns_from_none() {
assert_eq!(
TryInto::<SelectedColumns>::try_into(None).unwrap(),
SelectedColumns::All
)
}
#[test]
fn selected_columns_from_list_of_valid_ints() {
Python::attach(|py| {
let py_list = PyList::new(py, vec![0, 1, 2]).expect("could not create PyList");
assert_eq!(
TryInto::<SelectedColumns>::try_into(Some(py_list.as_ref())).unwrap(),
SelectedColumns::Selection([0, 1, 2].into_iter().map(IdxOrName::Idx).collect())
)
});
}
#[test]
fn selected_columns_from_list_of_valid_strings() {
Python::attach(|py| {
let py_list = PyList::new(py, vec!["foo", "bar"]).expect("could not create PyList");
assert_eq!(
TryInto::<SelectedColumns>::try_into(Some(py_list.as_ref())).unwrap(),
SelectedColumns::Selection(
["foo", "bar"]
.iter()
.map(ToString::to_string)
.map(IdxOrName::Name)
.collect()
)
)
});
}
#[test]
fn selected_columns_from_list_of_valid_strings_and_ints() {
Python::attach(|py| {
let py_list = PyList::new(py, vec!["foo", "bar"]).expect("could not create PyList");
py_list.append(42).unwrap();
py_list.append(5).unwrap();
assert_eq!(
TryInto::<SelectedColumns>::try_into(Some(py_list.as_ref())).unwrap(),
SelectedColumns::Selection(vec![
IdxOrName::Name("foo".to_string()),
IdxOrName::Name("bar".to_string()),
IdxOrName::Idx(42),
IdxOrName::Idx(5)
])
)
});
}
#[test]
fn selected_columns_from_invalid_ints() {
Python::attach(|py| {
let py_list = PyList::new(py, vec![0, 2, -1]).expect("could not create PyList");
let err = TryInto::<SelectedColumns>::try_into(Some(py_list.as_ref())).unwrap_err();
assert!(matches!(err.kind, FastExcelErrorKind::InvalidParameters(_)));
});
}
#[test]
fn selected_columns_from_empty_int_list() {
Python::attach(|py| {
let py_list = PyList::new(py, Vec::<usize>::new()).expect("could not create PyList");
let err = TryInto::<SelectedColumns>::try_into(Some(py_list.as_ref())).unwrap_err();
assert!(matches!(err.kind, FastExcelErrorKind::InvalidParameters(_)));
});
}
#[test]
fn selected_columns_from_empty_string_list() {
Python::attach(|py| {
let py_list = PyList::new(py, Vec::<String>::new()).expect("could not create PyList");
let err = TryInto::<SelectedColumns>::try_into(Some(py_list.as_ref())).unwrap_err();
assert!(matches!(err.kind, FastExcelErrorKind::InvalidParameters(_)));
});
}
#[rstest]
#[case("A,B,D", vec![0, 1, 3])]
#[case("A,B:E,Y", vec![0, 1, 2, 3, 4, 24])]
#[case("A:c,b:E,w,Y:z", vec![0, 1, 2, 3, 4, 22, 24, 25])]
#[case("A,y:AB", vec![0, 24, 25, 26, 27])]
#[case("BB:BE,DDC:DDF", vec![53, 54, 55, 56, 2810, 2811, 2812, 2813])]
fn selected_columns_from_valid_ranges(#[case] raw: &str, #[case] expected_indices: Vec<usize>) {
Python::attach(|py| {
let expected_range = SelectedColumns::Selection(
expected_indices.into_iter().map(IdxOrName::Idx).collect(),
);
let input = PyString::new(py, raw);
let range = TryInto::<SelectedColumns>::try_into(Some(input.as_ref()))
.expect("expected a valid column selection");
assert_eq!(range, expected_range)
})
}
#[rstest]
#[case("B:")]
#[case("A,C:")]
#[case("A:")]
#[case(":E")]
#[case(":C")]
#[case(":A")]
#[case(":C,E:")]
fn selected_columns_from_valid_open_ended_ranges(#[case] raw: &str) {
Python::attach(|py| {
let input = PyString::new(py, raw);
let range = TryInto::<SelectedColumns>::try_into(Some(input.as_ref()))
.expect("expected a valid column selection");
assert!(matches!(range, SelectedColumns::DeferredSelection(_)));
})
}
#[rstest]
#[case("", "at least one character")]
#[case("a:a,b:d,e", "empty range")]
#[case("b:a", "end of range is before start")]
#[case(":", "cannot have both start and end empty")]
#[case("a:b:e", "exactly 2 elements, got 3")]
fn selected_columns_from_invalid_ranges(#[case] raw: &str, #[case] message: &str) {
Python::attach(|py| {
let input = PyString::new(py, raw);
let err = TryInto::<SelectedColumns>::try_into(Some(input.as_ref()))
.expect_err("expected an error");
match err.kind {
FastExcelErrorKind::InvalidParameters(detail) => {
if !detail.contains(message) {
panic!("expected \"{detail}\" to contain \"{message}\"")
}
}
_ => panic!("Expected error to be InvalidParameters, got {err:?}"),
}
})
}
}