Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ extended_categorical = ["default_categorical_8"]
# For most analytical use cases, they get upcasted anyway.
extended_numeric_types = []

# Adds Decimal32, Decimal64, and Decimal128 array types for exact numeric
# values (for e.g., monetary, accounting, high-precision columns). DecimalArray<T>
# stores unscaled integers with precision and scale metadata for
# scale-aware formatting and lossless round-trip through Arrow FFI.
# Also gates `impl Integer for i128` and the associated Numeric/Primitive
# impls that i128 requires.
decimal = []

# Adds a cube object for stacking tables on an extra axis
# Useful for time series, and group analytics.
cube = ["views", "select", "hash"]
Expand Down
4 changes: 2 additions & 2 deletions minarrow-py/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion minarrow-py/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pyo3 = { version = "0.29", features = ["abi3-py39"] }
thiserror = "2"

[features]
default = ["datetime", "large_string", "matrix", "scalar_type", "value_type", "cube", "arrow_interop", "ndarray"]
default = ["datetime", "large_string", "matrix", "scalar_type", "value_type", "cube", "arrow_interop", "ndarray", "decimal"]
extension-module = ["pyo3/extension-module"]
arrow_interop = ["dep:minarrow-pyo3", "minarrow-pyo3/datetime"]
simd = ["minarrow/simd"]
Expand All @@ -47,6 +47,7 @@ ndarray = [
# Links libpython - use the default non `extension-module` link mode and
# it should not be combined with `extension-module`.
embed = ["arrow_interop", "scalar_type", "value_type", "simd"]
decimal = ["minarrow/decimal", "minarrow-pyo3?/decimal"]
datetime = ["minarrow/datetime"]
extended_numeric_types = ["minarrow/extended_numeric_types", "minarrow-pyo3?/extended_numeric_types"]
large_string = ["minarrow/large_string"]
Expand Down
60 changes: 60 additions & 0 deletions minarrow-py/src/array.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,54 @@ impl PyArrayInner {
}
}

/// Maximum number of significant decimal digits this array can represent.
pub fn precision(&self) -> u8 {
match self.arrow_dtype() {
ArrowType::Int32 => 10,
ArrowType::Int64 => 19,
ArrowType::UInt32 => 10,
ArrowType::UInt64 => 20,
#[cfg(feature = "extended_numeric_types")]
ArrowType::Int8 => 3,
#[cfg(feature = "extended_numeric_types")]
ArrowType::Int16 => 5,
#[cfg(feature = "extended_numeric_types")]
ArrowType::UInt8 => 3,
#[cfg(feature = "extended_numeric_types")]
ArrowType::UInt16 => 5,
ArrowType::Float32 => 7,
ArrowType::Float64 => 15,
#[cfg(feature = "decimal")]
ArrowType::Decimal32(p, _) => p,
#[cfg(feature = "decimal")]
ArrowType::Decimal64(p, _) => p,
#[cfg(feature = "decimal")]
ArrowType::Decimal128(p, _) => p,
_ => 0,
}
}

/// Number of digits after the decimal point. Zero for integer types,
/// `None` for floats because scale varies per value, and `None` for
/// non-numeric types.
pub fn scale(&self) -> Option<i8> {
match self.arrow_dtype() {
ArrowType::Int32 | ArrowType::Int64
| ArrowType::UInt32 | ArrowType::UInt64 => Some(0),
#[cfg(feature = "extended_numeric_types")]
ArrowType::Int8 | ArrowType::Int16
| ArrowType::UInt8 | ArrowType::UInt16 => Some(0),
ArrowType::Float32 | ArrowType::Float64 => None,
#[cfg(feature = "decimal")]
ArrowType::Decimal32(_, s) => Some(s),
#[cfg(feature = "decimal")]
ArrowType::Decimal64(_, s) => Some(s),
#[cfg(feature = "decimal")]
ArrowType::Decimal128(_, s) => Some(s),
_ => None,
}
}

/// Whether this array is a windowed view of a larger buffer.
pub fn is_view(&self) -> bool {
match self {
Expand Down Expand Up @@ -530,6 +578,18 @@ impl PyArray {
self.0.arrow_type()
}

/// Maximum number of significant decimal digits this array can represent.
#[getter]
fn precision(&self) -> u8 {
self.0.precision()
}

/// Decimal scale metadata, or `None` for non-decimal types.
#[getter]
fn scale(&self) -> Option<i8> {
self.0.scale()
}

fn __len__(&self) -> usize {
self.0.len()
}
Expand Down
18 changes: 18 additions & 0 deletions minarrow-py/src/arrow_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@ pub enum PyArrowType {
Timestamp { unit: PyTimeUnit, tz: Option<String> },
#[cfg(feature = "datetime")]
Interval { unit: PyIntervalUnit },
#[cfg(feature = "decimal")]
Decimal32 { precision: u8, scale: i8 },
#[cfg(feature = "decimal")]
Decimal64 { precision: u8, scale: i8 },
#[cfg(feature = "decimal")]
Decimal128 { precision: u8, scale: i8 },
String(),
#[cfg(feature = "large_string")]
LargeString(),
Expand Down Expand Up @@ -233,6 +239,12 @@ impl From<ArrowType> for PyArrowType {
ArrowType::Timestamp(unit, tz) => PyArrowType::Timestamp { unit: unit.into(), tz },
#[cfg(feature = "datetime")]
ArrowType::Interval(unit) => PyArrowType::Interval { unit: unit.into() },
#[cfg(feature = "decimal")]
ArrowType::Decimal32(p, s) => PyArrowType::Decimal32 { precision: p, scale: s },
#[cfg(feature = "decimal")]
ArrowType::Decimal64(p, s) => PyArrowType::Decimal64 { precision: p, scale: s },
#[cfg(feature = "decimal")]
ArrowType::Decimal128(p, s) => PyArrowType::Decimal128 { precision: p, scale: s },
ArrowType::String => PyArrowType::String(),
#[cfg(feature = "large_string")]
ArrowType::LargeString => PyArrowType::LargeString(),
Expand Down Expand Up @@ -285,6 +297,12 @@ impl From<PyArrowType> for ArrowType {
PyArrowType::Timestamp { unit, tz } => ArrowType::Timestamp(unit.into(), tz),
#[cfg(feature = "datetime")]
PyArrowType::Interval { unit } => ArrowType::Interval(unit.into()),
#[cfg(feature = "decimal")]
PyArrowType::Decimal32 { precision, scale } => ArrowType::Decimal32(precision, scale),
#[cfg(feature = "decimal")]
PyArrowType::Decimal64 { precision, scale } => ArrowType::Decimal64(precision, scale),
#[cfg(feature = "decimal")]
PyArrowType::Decimal128 { precision, scale } => ArrowType::Decimal128(precision, scale),
PyArrowType::String() => ArrowType::String,
#[cfg(feature = "large_string")]
PyArrowType::LargeString() => ArrowType::LargeString,
Expand Down
135 changes: 135 additions & 0 deletions minarrow-py/src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ use minarrow::arr_str64_opt;
use minarrow::{arr_i8_opt, arr_i16_opt, arr_u8_opt, arr_u16_opt};
use minarrow::enums::array::extract_option_values64;
use minarrow::enums::time_units::TimeUnit;
#[cfg(feature = "decimal")]
use minarrow::DecimalArray;
use minarrow::{
arr_bool_opt, arr_f32_opt, arr_f64_opt, arr_i32_opt, arr_i64_opt, arr_str32_opt, arr_u32_opt,
arr_u64_opt, Array, ArrayV, Bitmask, CategoricalArray, DatetimeArray, Scalar, Vec64,
Expand Down Expand Up @@ -164,6 +166,30 @@ pub fn build_array_typed(data: &Bound<'_, PyAny>, dtype: &ArrowType) -> PyResult
build_temporal!(i64, from_datetime_i64, *unit)
}
ArrowType::Timestamp(unit, _) => build_temporal!(i64, from_datetime_i64, *unit),
#[cfg(feature = "decimal")]
ArrowType::Decimal32(p, s) => {
let (values, null_mask) =
extract_option_values64(read_sequence::<Option<i32>>(data)?);
Ok(Array::from_decimal32(DecimalArray::<i32>::from_vec64(
values, null_mask, *p, *s,
)))
}
#[cfg(feature = "decimal")]
ArrowType::Decimal64(p, s) => {
let (values, null_mask) =
extract_option_values64(read_sequence::<Option<i64>>(data)?);
Ok(Array::from_decimal64(DecimalArray::<i64>::from_vec64(
values, null_mask, *p, *s,
)))
}
#[cfg(feature = "decimal")]
ArrowType::Decimal128(p, s) => {
let (values, null_mask) =
extract_option_values64(read_sequence::<Option<i128>>(data)?);
Ok(Array::from_decimal128(DecimalArray::<i128>::from_vec64(
values, null_mask, *p, *s,
)))
}
other => Err(PyValueError::new_err(format!(
"dtype {} cannot be built from a Python sequence; use from_arrow instead",
other
Expand Down Expand Up @@ -272,10 +298,64 @@ pub fn parse_dtype(name: &str) -> PyResult<ArrowType> {
));
}
}
#[cfg(feature = "decimal")]
s if s.starts_with("decimal128") || s.starts_with("decimal64") || s.starts_with("decimal32") || s.starts_with("decimal") => {
return parse_decimal_dtype(s);
}
other => return Err(PyValueError::new_err(format!("unknown dtype '{other}'"))),
})
}

/// Parse a decimal dtype string such as `"decimal128(38,10)"` or `"decimal(10,2)"`.
///
/// Accepted forms:
/// - `decimal128(P,S)` - Decimal128 with precision P and scale S.
/// - `decimal64(P,S)` - Decimal64.
/// - `decimal32(P,S)` - Decimal32.
/// - `decimal(P,S)` - alias for Decimal128.
#[cfg(feature = "decimal")]
fn parse_decimal_dtype(s: &str) -> PyResult<ArrowType> {
// Determine the width prefix and the remainder after it
let (width, rest) = if s.starts_with("decimal128") {
(128u32, &s["decimal128".len()..])
} else if s.starts_with("decimal64") {
(64, &s["decimal64".len()..])
} else if s.starts_with("decimal32") {
(32, &s["decimal32".len()..])
} else if s.starts_with("decimal") {
(128, &s["decimal".len()..])
} else {
return Err(PyValueError::new_err(format!("unknown dtype '{s}'")));
};

// Expect (P,S) after the prefix
let rest = rest.trim();
if !rest.starts_with('(') || !rest.ends_with(')') {
return Err(PyValueError::new_err(format!(
"decimal dtype must include precision and scale as 'decimal128(P,S)', got '{s}'"
)));
}
let inner = &rest[1..rest.len() - 1];
let parts: Vec<&str> = inner.split(',').map(str::trim).collect();
if parts.len() != 2 {
return Err(PyValueError::new_err(format!(
"decimal dtype must have two parameters (precision, scale), got '{s}'"
)));
}
let precision: u8 = parts[0].parse().map_err(|_| {
PyValueError::new_err(format!("invalid decimal precision in '{s}'"))
})?;
let scale: i8 = parts[1].parse().map_err(|_| {
PyValueError::new_err(format!("invalid decimal scale in '{s}'"))
})?;

Ok(match width {
32 => ArrowType::Decimal32(precision, scale),
64 => ArrowType::Decimal64(precision, scale),
_ => ArrowType::Decimal128(precision, scale),
})
}

/// Resolve a `dtype` argument that may be a string or an [`ArrowType`].
pub fn resolve_dtype(dtype: &Bound<'_, PyAny>) -> PyResult<ArrowType> {
if let Ok(name) = dtype.extract::<String>() {
Expand Down Expand Up @@ -442,6 +522,49 @@ pub fn resolve_index(i: isize, len: usize) -> PyResult<usize> {
Ok(resolved as usize)
}

/// Convert a decimal scalar to a Python `decimal.Decimal` value.
///
/// Reconstructs the human-readable decimal string from the raw unscaled integer
/// and scale, then passes it to `decimal.Decimal()` for exact conversion.
#[cfg(feature = "decimal")]
fn decimal_scalar_to_py(py: Python<'_>, raw: i128, scale: i8) -> PyResult<Py<PyAny>> {
let formatted = format_decimal_string(raw, scale);
let decimal_mod = py.import("decimal")?;
let result = decimal_mod.call_method1("Decimal", (formatted,))?;
Ok(result.unbind())
}

/// Format a decimal value as a string for `decimal.Decimal` construction.
#[cfg(feature = "decimal")]
fn format_decimal_string(raw: i128, scale: i8) -> String {
let raw_str = format!("{}", raw);
if scale == 0 {
return raw_str;
}
if scale < 0 {
let zeros = (-scale) as usize;
return format!("{}{}", raw_str, "0".repeat(zeros));
}
let scale_usize = scale as usize;
let (is_negative, digits) = if raw_str.starts_with('-') {
(true, &raw_str[1..])
} else {
(false, raw_str.as_str())
};
let padded = if digits.len() <= scale_usize {
format!("{:0>width$}", digits, width = scale_usize + 1)
} else {
digits.to_string()
};
let split_pos = padded.len() - scale_usize;
let (int_part, frac_part) = padded.split_at(split_pos);
if is_negative {
format!("-{}.{}", int_part, frac_part)
} else {
format!("{}.{}", int_part, frac_part)
}
}

/// Coerce a minarrow `Scalar` to its Python-native value. `Null` becomes `None`.
///
/// Temporal values surface as their raw integer. Faithful
Expand All @@ -468,6 +591,12 @@ pub fn scalar_to_py(py: Python<'_>, scalar: Scalar) -> PyResult<Py<PyAny>> {
Scalar::String32(v) => v.into_py_any(py),
#[cfg(feature = "large_string")]
Scalar::String64(v) => v.into_py_any(py),
#[cfg(feature = "decimal")]
Scalar::Decimal32(v, scale) => decimal_scalar_to_py(py, v as i128, scale),
#[cfg(feature = "decimal")]
Scalar::Decimal64(v, scale) => decimal_scalar_to_py(py, v as i128, scale),
#[cfg(feature = "decimal")]
Scalar::Decimal128(v, scale) => decimal_scalar_to_py(py, v, scale),
#[cfg(feature = "datetime")]
Scalar::Datetime32(v) => v.into_py_any(py),
#[cfg(feature = "datetime")]
Expand Down Expand Up @@ -502,6 +631,12 @@ pub fn scalar_repr(scalar: &Scalar) -> String {
Scalar::String32(v) => format!("\"{}\"", v),
#[cfg(feature = "large_string")]
Scalar::String64(v) => format!("\"{}\"", v),
#[cfg(feature = "decimal")]
Scalar::Decimal32(v, s) => format_decimal_string(*v as i128, *s),
#[cfg(feature = "decimal")]
Scalar::Decimal64(v, s) => format_decimal_string(*v as i128, *s),
#[cfg(feature = "decimal")]
Scalar::Decimal128(v, s) => format_decimal_string(*v, *s),
#[cfg(feature = "datetime")]
Scalar::Datetime32(v) => v.to_string(),
#[cfg(feature = "datetime")]
Expand Down
14 changes: 12 additions & 2 deletions minarrow-py/src/dtype.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ impl TypeClass {
pub enum DType {
Integer,
Float,
Decimal,
String,
Categorical,
Datetime,
Expand All @@ -76,6 +77,7 @@ impl DType {
match self {
DType::Integer => "Integer",
DType::Float => "Float",
DType::Decimal => "Decimal",
DType::String => "String",
DType::Categorical => "Categorical",
DType::Datetime => "Datetime",
Expand All @@ -99,7 +101,7 @@ impl DType {
#[getter]
pub fn group(&self) -> TypeClass {
match self {
DType::Integer | DType::Float => TypeClass::Numeric,
DType::Integer | DType::Float | DType::Decimal => TypeClass::Numeric,
DType::String | DType::Categorical => TypeClass::Text,
DType::Datetime => TypeClass::Temporal,
DType::Boolean => TypeClass::Boolean,
Expand All @@ -109,7 +111,7 @@ impl DType {

#[getter]
fn is_numeric(&self) -> bool {
matches!(self, DType::Integer | DType::Float)
matches!(self, DType::Integer | DType::Float | DType::Decimal)
}

#[getter]
Expand All @@ -133,6 +135,8 @@ pub fn dtype_from_arrow(at: &ArrowType) -> DType {
#[cfg(feature = "extended_numeric_types")]
Int8 | Int16 | UInt8 | UInt16 => DType::Integer,
Float32 | Float64 => DType::Float,
#[cfg(feature = "decimal")]
Decimal32(_, _) | Decimal64(_, _) | Decimal128(_, _) => DType::Decimal,
String | Utf8View => DType::String,
#[cfg(feature = "large_string")]
LargeString => DType::String,
Expand All @@ -157,6 +161,12 @@ pub fn width_from_arrow(at: &ArrowType) -> u32 {
Int16 | UInt16 => 16,
Int32 | UInt32 | Float32 => 32,
Int64 | UInt64 | Float64 => 64,
#[cfg(feature = "decimal")]
Decimal32(_, _) => 32,
#[cfg(feature = "decimal")]
Decimal64(_, _) => 64,
#[cfg(feature = "decimal")]
Decimal128(_, _) => 128,
String | Utf8View => 32,
#[cfg(feature = "large_string")]
LargeString => 64,
Expand Down
Loading
Loading