This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 9cf89f2  [python] Support Date/Time/Timestamp/Decimal filter literals 
in py_to_datum (#443)
9cf89f2 is described below

commit 9cf89f278356813480788b018b9e62c3ae22d198
Author: Junrui Lee <[email protected]>
AuthorDate: Fri Jul 3 19:16:29 2026 +0800

    [python] Support Date/Time/Timestamp/Decimal filter literals in py_to_datum 
(#443)
---
 bindings/python/Cargo.toml         |   5 +-
 bindings/python/src/predicate.rs   | 647 ++++++++++++++++++++++++++++++++++++-
 bindings/python/tests/test_read.py |  88 ++++-
 3 files changed, 727 insertions(+), 13 deletions(-)

diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml
index c2e853b..af0085d 100644
--- a/bindings/python/Cargo.toml
+++ b/bindings/python/Cargo.toml
@@ -28,16 +28,17 @@ doc = false
 
 [dependencies]
 arrow = { workspace = true, features = ["pyarrow"] }
+chrono = "0.4.38"
 datafusion = { workspace = true }
 datafusion-ffi = { workspace = true }
 futures = "0.3"
 paimon = { path = "../../crates/paimon", features = ["storage-all"] }
 paimon-datafusion = { path = "../../crates/integrations/datafusion", features 
= ["fulltext"] }
-pyo3 = { version = "0.28", features = ["abi3-py310"] }
+pyo3 = { version = "0.28", features = ["abi3-py310", "chrono"] }
 serde_json = "1.0"
 tokio = { workspace = true }
 
 [dev-dependencies]
 # Enables `Python::attach` to auto-initialize an interpreter for Rust unit 
tests.
 # Only applied to the test build; the maturin/extension-module build does not 
pull dev-dependencies.
-pyo3 = { version = "0.28", features = ["abi3-py310", "auto-initialize"] }
+pyo3 = { version = "0.28", features = ["abi3-py310", "auto-initialize", 
"chrono"] }
diff --git a/bindings/python/src/predicate.rs b/bindings/python/src/predicate.rs
index 0e9962b..08b2f5c 100644
--- a/bindings/python/src/predicate.rs
+++ b/bindings/python/src/predicate.rs
@@ -15,10 +15,11 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use paimon::spec::{DataField, DataType, Datum, Predicate, PredicateBuilder};
+use paimon::spec::{DataField, DataType, Datum, DecimalType, Predicate, 
PredicateBuilder};
 use pyo3::exceptions::{PyNotImplementedError, PyValueError};
 use pyo3::prelude::*;
-use pyo3::types::{PyBool, PyDict, PyList, PyString};
+use pyo3::types::PyTzInfoAccess;
+use pyo3::types::{PyBool, PyDateTime, PyDict, PyInt, PyList, PyString, PyTime, 
PyTzInfo};
 
 /// Convert a single Python literal into a typed [`Datum`] driven by the target
 /// [`DataType`].
@@ -32,11 +33,24 @@ use pyo3::types::{PyBool, PyDict, PyList, PyString};
 ///   (which is an `int` subclass) and enforce the target range.
 /// - `Float`/`Double` accept Python `int` or `float` but reject `bool`.
 /// - `Char`/`VarChar` accept only a Python `str` (no implicit 
stringification).
-/// - All other types (Date/Time/Timestamp/Decimal/Bytes/complex) are not
-///   supported yet and raise `NotImplementedError`.
+/// - `Date` accepts only a `datetime.date` that is not a `datetime.datetime`
+///   (accepting the subclass would silently drop the time-of-day part).
+/// - `Time` accepts only a naive `datetime.time` with whole-millisecond
+///   microseconds (`Datum::Time` carries millis-of-day; sub-millisecond values
+///   would be silently truncated).
+/// - `Timestamp` accepts only a naive `datetime.datetime` (the type is
+///   timezone-less); `LocalZonedTimestamp` accepts only a timezone-aware one
+///   (converted to UTC). This mirrors the DataFusion pushdown path, which
+///   refuses zoned literals for TIMESTAMP and naive ones for TIMESTAMP_LTZ.
+/// - `Decimal` accepts a `decimal.Decimal` or an `int`, rescaled losslessly to
+///   the column's scale; anything needing rounding, exceeding the column's
+///   precision, non-finite, or a binary `float` is rejected.
+/// - All other types (Bytes/complex) are not supported yet and raise
+///   `NotImplementedError`.
 ///
 /// Errors:
-/// - `ValueError` for type mismatches and out-of-range integers.
+/// - `ValueError` for type mismatches, out-of-range integers, zoned/naive
+///   timestamp mismatches, and lossy decimal/time conversions.
 /// - `NotImplementedError` for unsupported field types (message names the 
type).
 pub(crate) fn py_to_datum(value: &Bound<'_, PyAny>, data_type: &DataType) -> 
PyResult<Datum> {
     match data_type {
@@ -64,6 +78,11 @@ pub(crate) fn py_to_datum(value: &Bound<'_, PyAny>, 
data_type: &DataType) -> PyR
                 .map_err(|_| PyValueError::new_err("expected a str literal for 
String field"))?;
             Ok(Datum::String(s.to_str()?.to_string()))
         }
+        DataType::Date(_) => date_datum(value),
+        DataType::Time(_) => time_datum(value),
+        DataType::Timestamp(_) => timestamp_datum(value),
+        DataType::LocalZonedTimestamp(_) => local_zoned_timestamp_datum(value),
+        DataType::Decimal(dec) => decimal_datum(value, dec),
         other => Err(PyNotImplementedError::new_err(format!(
             "literal conversion for type {other:?} is not supported yet"
         ))),
@@ -103,6 +122,221 @@ fn float_val(value: &Bound<'_, PyAny>) -> PyResult<f64> {
         .map_err(|_| PyValueError::new_err("expected a numeric literal"))
 }
 
+/// Convert a `datetime.date` into `Datum::Date` (epoch days).
+///
+/// `datetime.datetime` is a `date` subclass but is rejected: accepting it 
would
+/// silently drop the time-of-day part.
+fn date_datum(value: &Bound<'_, PyAny>) -> PyResult<Datum> {
+    if value.is_instance_of::<PyDateTime>() {
+        return Err(PyValueError::new_err(
+            "expected a datetime.date literal for Date field, got 
datetime.datetime \
+             (the time-of-day part would be dropped)",
+        ));
+    }
+    let date: chrono::NaiveDate = value
+        .extract()
+        .map_err(|_| PyValueError::new_err("expected a datetime.date literal 
for Date field"))?;
+    let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
+    let days = date.signed_duration_since(epoch).num_days();
+    let days = i32::try_from(days)
+        .map_err(|_| PyValueError::new_err(format!("date literal {date} out of 
range")))?;
+    Ok(Datum::Date(days))
+}
+
+/// Convert a naive `datetime.time` into `Datum::Time` (millis of day).
+///
+/// Timezone-aware times are rejected (TIME has no timezone), as are
+/// sub-millisecond microseconds (`Datum::Time` carries millis; truncating
+/// would silently change comparison semantics).
+fn time_datum(value: &Bound<'_, PyAny>) -> PyResult<Datum> {
+    let time = value
+        .cast::<PyTime>()
+        .map_err(|_| PyValueError::new_err("expected a datetime.time literal 
for Time field"))?;
+    if time.get_tzinfo().is_some() {
+        return Err(PyValueError::new_err(
+            "expected a naive datetime.time literal for Time field (tzinfo 
must be None)",
+        ));
+    }
+    let time: chrono::NaiveTime = value
+        .extract()
+        .map_err(|_| PyValueError::new_err("expected a datetime.time literal 
for Time field"))?;
+    use chrono::Timelike;
+    if !time.nanosecond().is_multiple_of(1_000_000) {
+        return Err(PyValueError::new_err(
+            "time literal has sub-millisecond microseconds, which TIME 
comparisons \
+             cannot represent without truncation",
+        ));
+    }
+    let millis = time.num_seconds_from_midnight() * 1_000 + time.nanosecond() 
/ 1_000_000;
+    Ok(Datum::Time(millis as i32))
+}
+
+/// Split epoch microseconds into `(millis, nanos-of-milli)` with a Euclidean
+/// split so pre-epoch instants keep a non-negative nano remainder, matching
+/// `BinaryRow::get_timestamp_raw` and the DataFusion pushdown conversion.
+fn micros_to_millis_nanos(micros: i64) -> (i64, i32) {
+    (
+        micros.div_euclid(1_000),
+        (micros.rem_euclid(1_000) * 1_000) as i32,
+    )
+}
+
+/// Convert a naive `datetime.datetime` into `Datum::Timestamp`.
+///
+/// TIMESTAMP (without time zone) is timezone-less, so timezone-aware datetimes
+/// are rejected rather than silently reinterpreted — parity with the 
DataFusion
+/// pushdown path, which refuses zoned literals for this type.
+fn timestamp_datum(value: &Bound<'_, PyAny>) -> PyResult<Datum> {
+    let dt = value.cast::<PyDateTime>().map_err(|_| {
+        PyValueError::new_err("expected a datetime.datetime literal for 
Timestamp field")
+    })?;
+    if dt.get_tzinfo().is_some() {
+        return Err(PyValueError::new_err(
+            "expected a naive datetime.datetime literal for Timestamp field 
(tzinfo must \
+             be None); TIMESTAMP has no timezone — use a TIMESTAMP WITH LOCAL 
TIME ZONE \
+             column for zoned instants",
+        ));
+    }
+    let naive: chrono::NaiveDateTime = value.extract().map_err(|_| {
+        PyValueError::new_err("expected a datetime.datetime literal for 
Timestamp field")
+    })?;
+    let (millis, nanos) = 
micros_to_millis_nanos(naive.and_utc().timestamp_micros());
+    Ok(Datum::Timestamp { millis, nanos })
+}
+
+/// Convert a timezone-aware `datetime.datetime` into 
`Datum::LocalZonedTimestamp`
+/// (the UTC instant).
+///
+/// Aware follows Python's definition: `tzinfo` is set AND `utcoffset()` is not
+/// `None`. A tzinfo whose `utcoffset()` returns `None` is naive — passing it 
to
+/// `astimezone` would interpret the value as process-local time, normalizing
+/// the same literal differently per machine. Naive datetimes are rejected
+/// rather than assumed to be in any timezone — parity with the DataFusion
+/// pushdown path, which refuses naive literals for TIMESTAMP WITH LOCAL TIME
+/// ZONE.
+fn local_zoned_timestamp_datum(value: &Bound<'_, PyAny>) -> PyResult<Datum> {
+    let py = value.py();
+    let dt = value.cast::<PyDateTime>().map_err(|_| {
+        PyValueError::new_err("expected a datetime.datetime literal for 
LocalZonedTimestamp field")
+    })?;
+    // Python's aware/naive rule (datetime docs): aware iff tzinfo is not None
+    // and utcoffset() does not return None.
+    let aware =
+        dt.get_tzinfo().is_some() && !dt.call_method0(pyo3::intern!(py, 
"utcoffset"))?.is_none();
+    if !aware {
+        return Err(PyValueError::new_err(
+            "expected a timezone-aware datetime.datetime literal for 
LocalZonedTimestamp \
+             field (naive datetimes, including tzinfo with utcoffset() = None, 
are \
+             ambiguous)",
+        ));
+    }
+    // Normalize through `astimezone(utc)` so any tzinfo implementation
+    // (zoneinfo, pytz, fixed offsets) is handled by Python itself.
+    let utc = PyTzInfo::utc(py)?;
+    let as_utc = dt.call_method1(pyo3::intern!(py, "astimezone"), (utc,))?;
+    let instant: chrono::DateTime<chrono::Utc> = as_utc.extract()?;
+    let (millis, nanos) = micros_to_millis_nanos(instant.timestamp_micros());
+    Ok(Datum::LocalZonedTimestamp { millis, nanos })
+}
+
+/// Convert a `decimal.Decimal` or `int` literal into `Datum::Decimal` at the
+/// column's precision and scale.
+///
+/// The conversion is exact: values that would need rounding at the column's
+/// scale, or whose unscaled magnitude exceeds the column's precision, are
+/// rejected. Binary `float`s are rejected outright (they are not exact decimal
+/// values), as are non-finite decimals (NaN/Infinity).
+fn decimal_datum(value: &Bound<'_, PyAny>, dec: &DecimalType) -> 
PyResult<Datum> {
+    let precision = dec.precision();
+    let scale = dec.scale();
+    if value.is_instance_of::<PyBool>() {
+        return Err(PyValueError::new_err("bool is not a valid decimal 
literal"));
+    }
+
+    let unscaled = if value.is_instance_of::<PyInt>() {
+        let v: i128 = value.extract().map_err(|_| {
+            PyValueError::new_err("int literal out of supported range for 
Decimal field")
+        })?;
+        v.checked_mul(10i128.pow(scale)).ok_or_else(|| {
+            PyValueError::new_err("int literal out of supported range for 
Decimal field")
+        })?
+    } else {
+        let decimal_cls = value.py().import("decimal")?.getattr("Decimal")?;
+        if !value.is_instance(&decimal_cls)? {
+            return Err(PyValueError::new_err(
+                "expected a decimal.Decimal or int literal for Decimal field 
(float is \
+                 not an exact decimal value)",
+            ));
+        }
+        decimal_to_unscaled(value, scale)?
+    };
+
+    // 10^precision fits i128 for the supported precision range (<= 38).
+    if unscaled.unsigned_abs() >= 10u128.pow(precision) {
+        return Err(PyValueError::new_err(format!(
+            "decimal literal does not fit DECIMAL({precision}, {scale})"
+        )));
+    }
+    Ok(Datum::Decimal {
+        unscaled,
+        precision,
+        scale,
+    })
+}
+
+/// Rescale a finite `decimal.Decimal` to `scale` exactly via `as_tuple()`
+/// (sign, digits, exponent), avoiding Decimal arithmetic whose context
+/// precision could silently round.
+fn decimal_to_unscaled(value: &Bound<'_, PyAny>, scale: u32) -> PyResult<i128> 
{
+    let tuple = value.call_method0("as_tuple")?;
+    let sign: u8 = tuple.getattr("sign")?.extract()?;
+    // For NaN/Infinity the exponent is a string ('n'/'N'/'F') and extraction 
fails.
+    let exponent: i64 = tuple.getattr("exponent")?.extract().map_err(|_| {
+        PyValueError::new_err("non-finite Decimal (NaN/Infinity) is not a 
valid literal")
+    })?;
+    let digits: Vec<u32> = tuple.getattr("digits")?.extract()?;
+
+    let overflow =
+        || PyValueError::new_err("decimal literal out of supported range for 
Decimal field");
+    let mut magnitude: i128 = 0;
+    for d in digits {
+        magnitude = magnitude
+            .checked_mul(10)
+            .and_then(|m| m.checked_add(d as i128))
+            .ok_or_else(overflow)?;
+    }
+
+    // value = ±magnitude * 10^exponent; unscaled = ±magnitude * 10^(exponent 
+ scale).
+    let shift = exponent + scale as i64;
+    let magnitude = if shift >= 0 {
+        u32::try_from(shift)
+            .ok()
+            .filter(|s| *s <= 38)
+            .and_then(|s| magnitude.checked_mul(10i128.pow(s)))
+            .ok_or_else(overflow)?
+    } else if magnitude == 0 {
+        0
+    } else {
+        let down = u32::try_from(-shift).ok().filter(|s| *s <= 38).ok_or_else(
+            // More than 38 digits would have to be truncated; magnitude != 0
+            // means that is always lossy.
+            || rounding_error(scale),
+        )?;
+        let divisor = 10i128.pow(down);
+        if magnitude % divisor != 0 {
+            return Err(rounding_error(scale));
+        }
+        magnitude / divisor
+    };
+    Ok(if sign == 1 { -magnitude } else { magnitude })
+}
+
+fn rounding_error(scale: u32) -> PyErr {
+    PyValueError::new_err(format!(
+        "decimal literal cannot be represented at scale {scale} without 
rounding"
+    ))
+}
+
 /// Operators recognized by the lightweight dict format but not translatable 
to a
 /// Rust [`Predicate`] for pushdown.
 const METHOD_NOT_SUPPORTED: &[&str] = &["not"];
@@ -836,11 +1070,410 @@ mod tests {
     }
 
     #[test]
-    fn timestamp_field_is_not_implemented() {
+    fn timestamp_field_rejects_non_datetime() {
         Python::attach(|py| {
             let v = 0i64.into_pyobject(py).unwrap();
             let err = py_to_datum(&v, 
&DataType::Timestamp(Default::default())).unwrap_err();
-            
assert!(err.is_instance_of::<pyo3::exceptions::PyNotImplementedError>(py));
+            assert!(err.is_instance_of::<pyo3::exceptions::PyValueError>(py));
+        });
+    }
+
+    // ---- temporal literals ----
+
+    /// `datetime.date(2024, 1, 1)` epoch days (1970-01-01 = 0).
+    const D_2024_01_01: i32 = 19723;
+
+    fn py_date<'py>(py: Python<'py>, y: i32, m: u8, d: u8) -> Bound<'py, 
PyAny> {
+        pyo3::types::PyDate::new(py, y, m, d).unwrap().into_any()
+    }
+
+    fn py_time<'py>(
+        py: Python<'py>,
+        h: u8,
+        min: u8,
+        s: u8,
+        micro: u32,
+        tz: Option<&Bound<'py, pyo3::types::PyTzInfo>>,
+    ) -> Bound<'py, PyAny> {
+        pyo3::types::PyTime::new(py, h, min, s, micro, tz)
+            .unwrap()
+            .into_any()
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    fn py_datetime<'py>(
+        py: Python<'py>,
+        y: i32,
+        mo: u8,
+        d: u8,
+        h: u8,
+        mi: u8,
+        s: u8,
+        micro: u32,
+        tz: Option<&Bound<'py, pyo3::types::PyTzInfo>>,
+    ) -> Bound<'py, PyAny> {
+        pyo3::types::PyDateTime::new(py, y, mo, d, h, mi, s, micro, tz)
+            .unwrap()
+            .into_any()
+    }
+
+    fn utc(py: Python<'_>) -> Bound<'_, pyo3::types::PyTzInfo> {
+        pyo3::types::PyTzInfo::utc(py).unwrap().to_owned()
+    }
+
+    fn fixed_offset(py: Python<'_>, seconds: i32) -> Bound<'_, 
pyo3::types::PyTzInfo> {
+        let delta = pyo3::types::PyDelta::new(py, 0, seconds, 0, 
false).unwrap();
+        pyo3::types::PyTzInfo::fixed_offset(py, &delta).unwrap()
+    }
+
+    #[test]
+    fn date_field_accepts_date() {
+        Python::attach(|py| {
+            let v = py_date(py, 2024, 1, 1);
+            assert_eq!(
+                py_to_datum(&v, &DataType::Date(Default::default())).unwrap(),
+                Datum::Date(D_2024_01_01)
+            );
+        });
+    }
+
+    #[test]
+    fn date_field_accepts_pre_epoch_date() {
+        Python::attach(|py| {
+            let v = py_date(py, 1969, 12, 31);
+            assert_eq!(
+                py_to_datum(&v, &DataType::Date(Default::default())).unwrap(),
+                Datum::Date(-1)
+            );
+        });
+    }
+
+    #[test]
+    fn date_field_rejects_datetime() {
+        Python::attach(|py| {
+            // datetime is a date subclass; accepting it would silently drop 
the
+            // time-of-day part.
+            let v = py_datetime(py, 2024, 1, 1, 0, 0, 0, 0, None);
+            let err = py_to_datum(&v, 
&DataType::Date(Default::default())).unwrap_err();
+            assert!(err.is_instance_of::<PyValueError>(py));
+        });
+    }
+
+    #[test]
+    fn date_field_rejects_str() {
+        Python::attach(|py| {
+            let v = "2024-01-01".into_pyobject(py).unwrap();
+            let err = py_to_datum(v.as_any(), 
&DataType::Date(Default::default())).unwrap_err();
+            assert!(err.is_instance_of::<PyValueError>(py));
+        });
+    }
+
+    #[test]
+    fn time_field_accepts_naive_time() {
+        Python::attach(|py| {
+            let v = py_time(py, 12, 34, 56, 789_000, None);
+            assert_eq!(
+                py_to_datum(&v, &DataType::Time(Default::default())).unwrap(),
+                Datum::Time(45_296_789)
+            );
+        });
+    }
+
+    #[test]
+    fn time_field_rejects_sub_millisecond() {
+        Python::attach(|py| {
+            // Datum::Time carries millis-of-day; silently truncating micros
+            // would change equality semantics.
+            let v = py_time(py, 0, 0, 0, 500, None);
+            let err = py_to_datum(&v, 
&DataType::Time(Default::default())).unwrap_err();
+            assert!(err.is_instance_of::<PyValueError>(py));
+        });
+    }
+
+    #[test]
+    fn time_field_rejects_aware_time() {
+        Python::attach(|py| {
+            let tz = utc(py);
+            let v = py_time(py, 1, 2, 3, 0, Some(&tz));
+            let err = py_to_datum(&v, 
&DataType::Time(Default::default())).unwrap_err();
+            assert!(err.is_instance_of::<PyValueError>(py));
+        });
+    }
+
+    #[test]
+    fn timestamp_field_accepts_naive_datetime() {
+        Python::attach(|py| {
+            let v = py_datetime(py, 2024, 1, 1, 0, 0, 0, 123_456, None);
+            assert_eq!(
+                py_to_datum(&v, 
&DataType::Timestamp(Default::default())).unwrap(),
+                Datum::Timestamp {
+                    millis: 1_704_067_200_123,
+                    nanos: 456_000,
+                }
+            );
+        });
+    }
+
+    #[test]
+    fn timestamp_field_pre_epoch_uses_euclidean_split() {
+        Python::attach(|py| {
+            // 1969-12-31 23:59:59.999999 = -1 µs from epoch
+            // → floor millis -1, non-negative nanos remainder 999_000.
+            let v = py_datetime(py, 1969, 12, 31, 23, 59, 59, 999_999, None);
+            assert_eq!(
+                py_to_datum(&v, 
&DataType::Timestamp(Default::default())).unwrap(),
+                Datum::Timestamp {
+                    millis: -1,
+                    nanos: 999_000,
+                }
+            );
+        });
+    }
+
+    #[test]
+    fn timestamp_field_rejects_aware_datetime() {
+        Python::attach(|py| {
+            // TIMESTAMP is timezone-less; parity with the DataFusion path 
which
+            // refuses zoned literals for it.
+            let tz = utc(py);
+            let v = py_datetime(py, 2024, 1, 1, 0, 0, 0, 0, Some(&tz));
+            let err = py_to_datum(&v, 
&DataType::Timestamp(Default::default())).unwrap_err();
+            assert!(err.is_instance_of::<PyValueError>(py));
+        });
+    }
+
+    #[test]
+    fn timestamp_field_rejects_date() {
+        Python::attach(|py| {
+            let v = py_date(py, 2024, 1, 1);
+            let err = py_to_datum(&v, 
&DataType::Timestamp(Default::default())).unwrap_err();
+            assert!(err.is_instance_of::<PyValueError>(py));
+        });
+    }
+
+    #[test]
+    fn local_zoned_timestamp_field_accepts_aware_datetime() {
+        Python::attach(|py| {
+            // 2024-01-01T08:00:00+08:00 == 2024-01-01T00:00:00Z.
+            let tz = fixed_offset(py, 8 * 3600);
+            let v = py_datetime(py, 2024, 1, 1, 8, 0, 0, 0, Some(&tz));
+            assert_eq!(
+                py_to_datum(&v, 
&DataType::LocalZonedTimestamp(Default::default())).unwrap(),
+                Datum::LocalZonedTimestamp {
+                    millis: 1_704_067_200_000,
+                    nanos: 0,
+                }
+            );
+        });
+    }
+
+    #[test]
+    fn local_zoned_timestamp_field_rejects_naive_datetime() {
+        Python::attach(|py| {
+            let v = py_datetime(py, 2024, 1, 1, 0, 0, 0, 0, None);
+            let err =
+                py_to_datum(&v, 
&DataType::LocalZonedTimestamp(Default::default())).unwrap_err();
+            assert!(err.is_instance_of::<PyValueError>(py));
+        });
+    }
+
+    /// Build a datetime whose tzinfo subclass returns None from utcoffset() —
+    /// naive by Python's definition (docs: "aware if tzinfo is not None AND
+    /// utcoffset() does not return None") despite tzinfo being set.
+    fn pseudo_naive_datetime(py: Python<'_>) -> Bound<'_, PyAny> {
+        let ns = PyDict::new(py);
+        py.run(
+            c"import datetime
+class FloatingTz(datetime.tzinfo):
+    def utcoffset(self, dt): return None
+    def dst(self, dt): return None
+    def tzname(self, dt): return 'FLOATING'
+value = datetime.datetime(2024, 1, 1, tzinfo=FloatingTz())",
+            None,
+            Some(&ns),
+        )
+        .unwrap();
+        ns.get_item("value").unwrap().unwrap()
+    }
+
+    #[test]
+    fn local_zoned_timestamp_field_rejects_pseudo_naive_tzinfo() {
+        Python::attach(|py| {
+            // tzinfo is set but utcoffset() is None: astimezone(utc) would
+            // interpret the value as process-local time, so the same literal
+            // would normalize differently per machine. Must be rejected.
+            let v = pseudo_naive_datetime(py);
+            let err =
+                py_to_datum(&v, 
&DataType::LocalZonedTimestamp(Default::default())).unwrap_err();
+            assert!(err.is_instance_of::<PyValueError>(py));
+        });
+    }
+
+    #[test]
+    fn timestamp_field_rejects_pseudo_naive_tzinfo() {
+        Python::attach(|py| {
+            // Timestamp deliberately rejects ANY tzinfo, even one whose
+            // utcoffset() is None: stricter than Python's naive/aware
+            // definition, but explicit — the caller strips tzinfo to state
+            // wall-clock intent.
+            let v = pseudo_naive_datetime(py);
+            let err = py_to_datum(&v, 
&DataType::Timestamp(Default::default())).unwrap_err();
+            assert!(err.is_instance_of::<PyValueError>(py));
+        });
+    }
+
+    // ---- decimal literals ----
+
+    fn py_decimal<'py>(py: Python<'py>, repr: &str) -> Bound<'py, PyAny> {
+        py.import("decimal")
+            .unwrap()
+            .getattr("Decimal")
+            .unwrap()
+            .call1((repr,))
+            .unwrap()
+    }
+
+    fn decimal_type(precision: u32, scale: u32) -> DataType {
+        DataType::Decimal(paimon::spec::DecimalType::new(precision, 
scale).unwrap())
+    }
+
+    #[test]
+    fn decimal_field_accepts_exact_scale() {
+        Python::attach(|py| {
+            let v = py_decimal(py, "12.34");
+            assert_eq!(
+                py_to_datum(&v, &decimal_type(10, 2)).unwrap(),
+                Datum::Decimal {
+                    unscaled: 1234,
+                    precision: 10,
+                    scale: 2,
+                }
+            );
+        });
+    }
+
+    #[test]
+    fn decimal_field_rescales_losslessly() {
+        Python::attach(|py| {
+            let v = py_decimal(py, "1.5");
+            assert_eq!(
+                py_to_datum(&v, &decimal_type(10, 2)).unwrap(),
+                Datum::Decimal {
+                    unscaled: 150,
+                    precision: 10,
+                    scale: 2,
+                }
+            );
+        });
+    }
+
+    #[test]
+    fn decimal_field_accepts_scientific_notation() {
+        Python::attach(|py| {
+            let v = py_decimal(py, "1E+2");
+            assert_eq!(
+                py_to_datum(&v, &decimal_type(10, 2)).unwrap(),
+                Datum::Decimal {
+                    unscaled: 10_000,
+                    precision: 10,
+                    scale: 2,
+                }
+            );
+        });
+    }
+
+    #[test]
+    fn decimal_field_negative_value() {
+        Python::attach(|py| {
+            let v = py_decimal(py, "-12.34");
+            assert_eq!(
+                py_to_datum(&v, &decimal_type(10, 2)).unwrap(),
+                Datum::Decimal {
+                    unscaled: -1234,
+                    precision: 10,
+                    scale: 2,
+                }
+            );
+        });
+    }
+
+    #[test]
+    fn decimal_field_accepts_int_literal() {
+        Python::attach(|py| {
+            let v = 7i64.into_pyobject(py).unwrap();
+            assert_eq!(
+                py_to_datum(&v, &decimal_type(10, 2)).unwrap(),
+                Datum::Decimal {
+                    unscaled: 700,
+                    precision: 10,
+                    scale: 2,
+                }
+            );
+        });
+    }
+
+    #[test]
+    fn decimal_field_rejects_rounding() {
+        Python::attach(|py| {
+            // 0.005 cannot be represented at scale 2 without rounding.
+            let v = py_decimal(py, "0.005");
+            let err = py_to_datum(&v, &decimal_type(10, 2)).unwrap_err();
+            assert!(err.is_instance_of::<PyValueError>(py));
+        });
+    }
+
+    #[test]
+    fn decimal_field_rejects_precision_overflow() {
+        Python::attach(|py| {
+            let v = py_decimal(py, "123.45");
+            let err = py_to_datum(&v, &decimal_type(4, 2)).unwrap_err();
+            assert!(err.is_instance_of::<PyValueError>(py));
+        });
+    }
+
+    #[test]
+    fn decimal_field_rejects_non_finite() {
+        Python::attach(|py| {
+            for repr in ["NaN", "Infinity", "-Infinity"] {
+                let v = py_decimal(py, repr);
+                let err = py_to_datum(&v, &decimal_type(10, 2)).unwrap_err();
+                assert!(err.is_instance_of::<PyValueError>(py), "{repr}");
+            }
+        });
+    }
+
+    #[test]
+    fn decimal_field_rejects_float() {
+        Python::attach(|py| {
+            // Binary floats are not exact decimal values; requiring
+            // decimal.Decimal keeps the conversion lossless.
+            let v = 1.5f64.into_pyobject(py).unwrap();
+            let err = py_to_datum(&v, &decimal_type(10, 2)).unwrap_err();
+            assert!(err.is_instance_of::<PyValueError>(py));
+        });
+    }
+
+    #[test]
+    fn decimal_field_rejects_bool() {
+        Python::attach(|py| {
+            let v = true.into_pyobject(py).unwrap();
+            let err = py_to_datum(v.as_any(), &decimal_type(10, 
2)).unwrap_err();
+            assert!(err.is_instance_of::<PyValueError>(py));
+        });
+    }
+
+    #[test]
+    fn unsupported_field_type_still_not_implemented() {
+        Python::attach(|py| {
+            // Bytes/complex types remain out of scope for literal conversion.
+            let v = 0i64.into_pyobject(py).unwrap();
+            for dt in [
+                DataType::Binary(Default::default()),
+                
DataType::Array(paimon::spec::ArrayType::new(DataType::Int(IntType::new()))),
+            ] {
+                let err = py_to_datum(&v, &dt).unwrap_err();
+                
assert!(err.is_instance_of::<pyo3::exceptions::PyNotImplementedError>(py));
+            }
         });
     }
 
diff --git a/bindings/python/tests/test_read.py 
b/bindings/python/tests/test_read.py
index 8065746..f81d583 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -278,17 +278,97 @@ def test_filter_string_op_on_non_string_column_raises():
 
 
 def test_filter_unsupported_type_raises():
-    # Build a table with a timestamp column, filter on it -> 
NotImplementedError
+    # Binary columns have no literal conversion -> NotImplementedError
     with tempfile.TemporaryDirectory() as warehouse:
         ctx = SQLContext()
         ctx.register_catalog("paimon", {"warehouse": warehouse})
         ctx.sql("CREATE SCHEMA paimon.tdb")
-        ctx.sql("CREATE TABLE paimon.tdb.tt (id INT, created_at TIMESTAMP)")
-        ctx.sql("INSERT INTO paimon.tdb.tt VALUES (1, TIMESTAMP '2024-01-01 
00:00:00')")
+        ctx.sql("CREATE TABLE paimon.tdb.tt (id INT, payload BYTEA)")
+        ctx.sql("INSERT INTO paimon.tdb.tt VALUES (1, X'00')")
         table = PaimonCatalog({"warehouse": warehouse}).get_table("tdb.tt")
         with pytest.raises(NotImplementedError):
             table.new_read_builder().with_filter(
-                {"method": "equal", "field": "created_at", "literals": [0]})
+                {"method": "equal", "field": "payload", "literals": [0]})
+
+
+def _make_temporal_table(warehouse):
+    ctx = SQLContext()
+    ctx.register_catalog("paimon", {"warehouse": warehouse})
+    ctx.sql("CREATE SCHEMA paimon.tmp")
+    ctx.sql(
+        "CREATE TABLE paimon.tmp.tt (id INT, d DATE, ts TIMESTAMP, dec 
DECIMAL(10, 2))")
+    # Two separate INSERTs -> two files, so file stats can prune per-file.
+    ctx.sql(
+        "INSERT INTO paimon.tmp.tt VALUES "
+        "(1, DATE '2024-01-01', TIMESTAMP '2024-01-01 00:00:00', 12.34)")
+    ctx.sql(
+        "INSERT INTO paimon.tmp.tt VALUES "
+        "(2, DATE '2024-06-15', TIMESTAMP '2024-06-15 12:30:00', 56.78)")
+    return PaimonCatalog({"warehouse": warehouse}).get_table("tmp.tt")
+
+
+def test_filter_date_literal_prunes_and_reads():
+    import datetime
+
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_temporal_table(warehouse)
+        b = table.new_read_builder().with_filter(
+            {"method": "equal", "field": "d", "literals": [datetime.date(2024, 
1, 1)]})
+        splits = b.new_scan().plan().splits()
+        assert len(splits) == 1
+        t = pa.Table.from_batches(b.new_read().read(splits))
+        assert t.column("id").to_pylist() == [1]
+
+
+def test_filter_timestamp_literal_prunes_and_reads():
+    import datetime
+
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_temporal_table(warehouse)
+        b = table.new_read_builder().with_filter(
+            {"method": "greaterThan", "field": "ts",
+             "literals": [datetime.datetime(2024, 3, 1, 0, 0, 0)]})
+        splits = b.new_scan().plan().splits()
+        assert len(splits) == 1
+        t = pa.Table.from_batches(b.new_read().read(splits))
+        assert t.column("id").to_pylist() == [2]
+
+
+def test_filter_timestamp_rejects_aware_datetime():
+    import datetime
+
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_temporal_table(warehouse)
+        aware = datetime.datetime(2024, 3, 1, tzinfo=datetime.timezone.utc)
+        with pytest.raises(ValueError):
+            table.new_read_builder().with_filter(
+                {"method": "greaterThan", "field": "ts", "literals": [aware]})
+
+
+def test_filter_decimal_literal_prunes_and_reads():
+    from decimal import Decimal
+
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_temporal_table(warehouse)
+        b = table.new_read_builder().with_filter(
+            {"method": "equal", "field": "dec", "literals": 
[Decimal("12.34")]})
+        splits = b.new_scan().plan().splits()
+        assert len(splits) == 1
+        t = pa.Table.from_batches(b.new_read().read(splits))
+        assert t.column("id").to_pylist() == [1]
+
+
+def test_filter_decimal_rejects_float_and_rounding():
+    from decimal import Decimal
+
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_temporal_table(warehouse)
+        b = table.new_read_builder()
+        with pytest.raises(ValueError):
+            b.with_filter({"method": "equal", "field": "dec", "literals": 
[12.34]})
+        with pytest.raises(ValueError):
+            b.with_filter(
+                {"method": "equal", "field": "dec", "literals": 
[Decimal("0.005")]})
 
 
 def test_filter_unknown_field_raises():

Reply via email to