Skip to main content

datafusion_python/
codec.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! Python-aware extension codecs.
19//!
20//! Datafusion-python plans can carry references to Python-defined
21//! objects that the upstream protobuf codecs do not know how to
22//! serialize: pure-Python scalar / aggregate / window UDFs, Python
23//! query-planning extensions, and so on. Their state lives inside
24//! `Py<PyAny>` callables and closures rather than being recoverable
25//! from a name in the receiver's function registry. To ship a plan
26//! across a process boundary (pickle, `multiprocessing`, Ray actor,
27//! `datafusion-distributed`, etc.) those payloads have to be encoded
28//! into the proto wire format itself.
29//!
30//! [`PythonLogicalCodec`] is the [`LogicalExtensionCodec`] that
31//! datafusion-python parks on every `SessionContext`. It wraps a
32//! user-supplied (or default) inner codec and adds Python-aware
33//! in-band encoding on top: when the encoder sees a Python-defined
34//! UDF, the codec cloudpickles the callable + signature into the
35//! `fun_definition` proto field; when the decoder sees a payload it
36//! produced, it reconstructs the UDF from the bytes alone — no
37//! pre-registration on the receiver. UDFs the codec does not
38//! recognise are delegated to `inner`, which is typically
39//! `DefaultLogicalExtensionCodec` but may be a downstream-supplied
40//! FFI codec installed via
41//! `SessionContext.with_logical_extension_codec(...)`.
42//!
43//! [`PythonPhysicalCodec`] is the symmetric wrapper around
44//! [`PhysicalExtensionCodec`]. Logical and physical layers each have
45//! a `try_encode_udf` / `try_decode_udf` pair, so a `ScalarUDF`
46//! referenced inside a `LogicalPlan`, an `ExecutionPlan`, or a
47//! `PhysicalExpr` must encode identically through either layer for
48//! plans to survive a serialization round-trip. Both codecs share
49//! the same payload framing for that reason.
50//!
51//! Payloads emitted by these codecs are framed as
52//! `<family_magic: 7 bytes> <version: u8> <py_major: u8> <py_minor: u8> <cloudpickle blob>`.
53//! The family magic identifies the UDF flavor; the version byte lets
54//! the decoder reject too-new or too-old payloads with a clean error
55//! instead of falling into an opaque `cloudpickle` tuple-unpack
56//! failure when the tuple shape changes; the Python `(major, minor)`
57//! bytes catch the cloudpickle-cross-minor-version case and raise an
58//! actionable error instead of an opaque `marshal` failure on load
59//! (cloudpickle payloads are not portable across Python minor
60//! versions). Dispatch precedence on decode: **family match +
61//! supported version + matching Python version → `inner` codec →
62//! caller's `FunctionRegistry` fallback.**
63//!
64//! ## Wire-format family registry
65//!
66//! | Layer + kind                  | Family prefix |
67//! | ----------------------------- | ------------- |
68//! | `PythonLogicalCodec` scalar   | `DFPYUDF`     |
69//! | `PythonLogicalCodec` agg      | `DFPYUDA`     |
70//! | `PythonLogicalCodec` window   | `DFPYUDW`     |
71//! | `PythonPhysicalCodec` scalar  | `DFPYUDF`     |
72//! | `PythonPhysicalCodec` agg     | `DFPYUDA`     |
73//! | `PythonPhysicalCodec` window  | `DFPYUDW`     |
74//! | User FFI extension codec      | user-chosen   |
75//! | Default codec                 | (none)        |
76//!
77//! Current wire-format version is [`WIRE_VERSION_CURRENT`]; supported
78//! receive range is `WIRE_VERSION_MIN_SUPPORTED..=WIRE_VERSION_CURRENT`.
79//! Bump [`WIRE_VERSION_CURRENT`] whenever the cloudpickle tuple shape
80//! changes; raise [`WIRE_VERSION_MIN_SUPPORTED`] when dropping support
81//! for an older shape.
82//!
83//! Downstream FFI codecs should pick non-colliding family prefixes
84//! (use a `DF` namespace plus a crate-specific suffix). The codec
85//! implementations in this module currently delegate every method to
86//! `inner`; the encoder/decoder hooks for each kind are added as the
87//! corresponding Python-side type becomes serializable.
88
89use std::sync::Arc;
90
91use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
92use arrow::ipc::reader::StreamReader;
93use arrow::ipc::writer::StreamWriter;
94use datafusion::common::{Result, TableReference};
95use datafusion::datasource::TableProvider;
96use datafusion::datasource::file_format::FileFormatFactory;
97use datafusion::execution::TaskContext;
98use datafusion::logical_expr::{
99    AggregateUDF, AggregateUDFImpl, Extension, LogicalPlan, ScalarUDF, ScalarUDFImpl, Signature,
100    TypeSignature, Volatility, WindowUDF, WindowUDFImpl,
101};
102use datafusion::physical_expr::PhysicalExpr;
103use datafusion::physical_plan::ExecutionPlan;
104use datafusion_proto::logical_plan::{DefaultLogicalExtensionCodec, LogicalExtensionCodec};
105use datafusion_proto::physical_plan::{DefaultPhysicalExtensionCodec, PhysicalExtensionCodec};
106use pyo3::prelude::*;
107use pyo3::sync::PyOnceLock;
108use pyo3::types::{PyBytes, PyTuple};
109
110use crate::errors::to_datafusion_err;
111use crate::udaf::PythonFunctionAggregateUDF;
112use crate::udf::PythonFunctionScalarUDF;
113use crate::udwf::PythonFunctionWindowUDF;
114
115// Wire-format framing for inlined Python UDF payloads.
116//
117// Layout: `<family_magic: 7 bytes> <version: u8> <py_major: u8> <py_minor: u8> <cloudpickle blob>`.
118// The family magic identifies the UDF flavor; the version byte lets
119// the decoder reject too-new or too-old payloads with a clean error
120// instead of falling into an opaque `cloudpickle` tuple-unpack failure
121// when the tuple shape changes; the Python `(major, minor)` bytes
122// catch the cloudpickle-cross-minor-version case (cloudpickle is not
123// portable across Python minor versions) and raise an actionable
124// error instead of an opaque `marshal` failure on load. Bump
125// [`WIRE_VERSION_CURRENT`] whenever the tuple shape changes; raise
126// [`WIRE_VERSION_MIN_SUPPORTED`] when dropping support for an older
127// shape.
128
129/// Family prefix for an inlined Python scalar UDF
130/// (cloudpickled tuple of name, callable, input schema, return field,
131/// volatility).
132pub(crate) const PY_SCALAR_UDF_FAMILY: &[u8] = b"DFPYUDF";
133
134/// Family prefix for an inlined Python aggregate UDF
135/// (cloudpickled tuple of name, accumulator factory, input schema bytes,
136/// return schema bytes (single-field IPC schema), state schema bytes,
137/// volatility).
138pub(crate) const PY_AGG_UDF_FAMILY: &[u8] = b"DFPYUDA";
139
140/// Family prefix for an inlined Python window UDF
141/// (cloudpickled tuple of name, evaluator factory, input schema bytes,
142/// return schema bytes (single-field IPC schema), volatility).
143pub(crate) const PY_WINDOW_UDF_FAMILY: &[u8] = b"DFPYUDW";
144
145/// Wire-format version this build emits.
146pub(crate) const WIRE_VERSION_CURRENT: u8 = 1;
147
148/// Oldest wire-format version this build still decodes. Bump when
149/// retiring support for an older payload shape.
150pub(crate) const WIRE_VERSION_MIN_SUPPORTED: u8 = 1;
151
152/// Tag `buf` with the framing header for `family` at the current
153/// wire-format version, stamping `py_version` as `(major, minor)`
154/// bytes. Append-only — the caller writes the cloudpickle payload
155/// after.
156fn write_wire_header(buf: &mut Vec<u8>, family: &[u8], py_version: (u8, u8)) {
157    buf.extend_from_slice(family);
158    buf.push(WIRE_VERSION_CURRENT);
159    buf.push(py_version.0);
160    buf.push(py_version.1);
161}
162
163/// Inspect the framing on `buf`.
164///
165/// * `Ok(None)` — `buf` does not carry `family`. The caller should
166///   delegate to its `inner` codec.
167/// * `Ok(Some(payload))` — `buf` carries `family` at a version this
168///   build accepts and a Python `(major, minor)` matching
169///   `expected_py`; `payload` is the cloudpickle blob.
170/// * `Err(_)` — `buf` carries `family` but the wire-format version
171///   is outside `WIRE_VERSION_MIN_SUPPORTED..=WIRE_VERSION_CURRENT`,
172///   or the stamped Python `(major, minor)` does not match
173///   `expected_py`. The error names the offending values so an
174///   operator can diagnose sender/receiver drift instead of seeing
175///   an opaque cloudpickle tuple-unpack or `marshal` failure.
176fn strip_wire_header<'a>(
177    buf: &'a [u8],
178    family: &[u8],
179    kind: &str,
180    expected_py: (u8, u8),
181) -> Result<Option<&'a [u8]>> {
182    if !buf.starts_with(family) {
183        return Ok(None);
184    }
185    let version_idx = family.len();
186    let Some(&version) = buf.get(version_idx) else {
187        return Err(datafusion::error::DataFusionError::Execution(format!(
188            "Truncated inline Python {kind} payload: missing wire-format version byte"
189        )));
190    };
191    if !(WIRE_VERSION_MIN_SUPPORTED..=WIRE_VERSION_CURRENT).contains(&version) {
192        return Err(datafusion::error::DataFusionError::Execution(format!(
193            "Inline Python {kind} payload wire-format version v{version}; \
194             this build supports v{WIRE_VERSION_MIN_SUPPORTED}..=v{WIRE_VERSION_CURRENT}. \
195             Align datafusion-python versions on sender and receiver."
196        )));
197    }
198    let py_major_idx = version_idx + 1;
199    let Some(&encoded_major) = buf.get(py_major_idx) else {
200        return Err(datafusion::error::DataFusionError::Execution(format!(
201            "Truncated inline Python {kind} payload: missing Python major version byte"
202        )));
203    };
204    let py_minor_idx = version_idx + 2;
205    let Some(&encoded_minor) = buf.get(py_minor_idx) else {
206        return Err(datafusion::error::DataFusionError::Execution(format!(
207            "Truncated inline Python {kind} payload: missing Python minor version byte"
208        )));
209    };
210    let (current_major, current_minor) = expected_py;
211    if encoded_major != current_major || encoded_minor != current_minor {
212        return Err(datafusion::error::DataFusionError::Execution(format!(
213            "Inline Python {kind} payload was serialized on Python \
214             {encoded_major}.{encoded_minor} but this process is running Python \
215             {current_major}.{current_minor}. cloudpickle payloads are not portable \
216             across Python minor versions. Align Python versions on sender and receiver."
217        )));
218    }
219    Ok(Some(&buf[py_minor_idx + 1..]))
220}
221
222/// `LogicalExtensionCodec` parked on every `SessionContext`. Holds
223/// the Python-aware encoding hooks for logical-layer types
224/// (`LogicalPlan`, `Expr`) and delegates everything it does not
225/// handle to the composable `inner` codec — typically
226/// `DefaultLogicalExtensionCodec`, or a downstream FFI codec
227/// installed via `SessionContext.with_logical_extension_codec(...)`.
228///
229/// Sitting at the top of the session's logical codec stack means
230/// every serializer that reads `session.logical_codec()` automatically
231/// picks up Python-aware encoding for free.
232#[derive(Debug)]
233pub struct PythonLogicalCodec {
234    inner: Arc<dyn LogicalExtensionCodec>,
235    python_udf_inlining: bool,
236}
237
238impl PythonLogicalCodec {
239    pub fn new(inner: Arc<dyn LogicalExtensionCodec>) -> Self {
240        Self {
241            inner,
242            python_udf_inlining: true,
243        }
244    }
245
246    pub fn inner(&self) -> &Arc<dyn LogicalExtensionCodec> {
247        &self.inner
248    }
249
250    /// Toggle inline encoding of Python UDFs. See
251    /// `SessionContext.with_python_udf_inlining` (Python) for full
252    /// behavior and use cases.
253    ///
254    /// Security scope: strict mode (`false`) narrows only the codec
255    /// layer — it stops `Expr::from_bytes` from invoking
256    /// `cloudpickle.loads` on the inline `DFPY*` payload. It does
257    /// **not** make `pickle.loads(untrusted_bytes)` safe; treat every
258    /// `pickle.loads` on untrusted input as unsafe regardless of this
259    /// setting. See `docs/source/user-guide/io/distributing_work.rst`
260    /// (Security section) for the full threat model, and Python's
261    /// [pickle module security warning][1] for why `pickle.loads` is
262    /// unsafe in general.
263    ///
264    /// [1]: https://docs.python.org/3/library/pickle.html#module-pickle
265    pub fn with_python_udf_inlining(mut self, enabled: bool) -> Self {
266        self.python_udf_inlining = enabled;
267        self
268    }
269
270    pub fn python_udf_inlining(&self) -> bool {
271        self.python_udf_inlining
272    }
273}
274
275impl Default for PythonLogicalCodec {
276    fn default() -> Self {
277        Self::new(Arc::new(DefaultLogicalExtensionCodec {}))
278    }
279}
280
281impl LogicalExtensionCodec for PythonLogicalCodec {
282    fn try_decode(
283        &self,
284        buf: &[u8],
285        inputs: &[LogicalPlan],
286        ctx: &TaskContext,
287    ) -> Result<Extension> {
288        self.inner.try_decode(buf, inputs, ctx)
289    }
290
291    fn try_encode(&self, node: &Extension, buf: &mut Vec<u8>) -> Result<()> {
292        self.inner.try_encode(node, buf)
293    }
294
295    fn try_decode_table_provider(
296        &self,
297        buf: &[u8],
298        table_ref: &TableReference,
299        schema: SchemaRef,
300        ctx: &TaskContext,
301    ) -> Result<Arc<dyn TableProvider>> {
302        self.inner
303            .try_decode_table_provider(buf, table_ref, schema, ctx)
304    }
305
306    fn try_encode_table_provider(
307        &self,
308        table_ref: &TableReference,
309        node: Arc<dyn TableProvider>,
310        buf: &mut Vec<u8>,
311    ) -> Result<()> {
312        self.inner.try_encode_table_provider(table_ref, node, buf)
313    }
314
315    fn try_decode_file_format(
316        &self,
317        buf: &[u8],
318        ctx: &TaskContext,
319    ) -> Result<Arc<dyn FileFormatFactory>> {
320        self.inner.try_decode_file_format(buf, ctx)
321    }
322
323    fn try_encode_file_format(
324        &self,
325        buf: &mut Vec<u8>,
326        node: Arc<dyn FileFormatFactory>,
327    ) -> Result<()> {
328        self.inner.try_encode_file_format(buf, node)
329    }
330
331    fn try_encode_udf(&self, node: &ScalarUDF, buf: &mut Vec<u8>) -> Result<()> {
332        if self.python_udf_inlining && try_encode_python_scalar_udf(node, buf)? {
333            return Ok(());
334        }
335        self.inner.try_encode_udf(node, buf)
336    }
337
338    fn try_decode_udf(&self, name: &str, buf: &[u8]) -> Result<Arc<ScalarUDF>> {
339        if self.python_udf_inlining {
340            if let Some(udf) = try_decode_python_scalar_udf(buf)? {
341                return Ok(udf);
342            }
343        } else {
344            refuse_if_inline(buf, PY_SCALAR_UDF_FAMILY, "scalar UDF", name)?;
345        }
346        self.inner.try_decode_udf(name, buf)
347    }
348
349    fn try_encode_udaf(&self, node: &AggregateUDF, buf: &mut Vec<u8>) -> Result<()> {
350        if self.python_udf_inlining && try_encode_python_udaf(node, buf)? {
351            return Ok(());
352        }
353        self.inner.try_encode_udaf(node, buf)
354    }
355
356    fn try_decode_udaf(&self, name: &str, buf: &[u8]) -> Result<Arc<AggregateUDF>> {
357        if self.python_udf_inlining {
358            if let Some(udaf) = try_decode_python_udaf(buf)? {
359                return Ok(udaf);
360            }
361        } else {
362            refuse_if_inline(buf, PY_AGG_UDF_FAMILY, "aggregate UDF", name)?;
363        }
364        self.inner.try_decode_udaf(name, buf)
365    }
366
367    fn try_encode_udwf(&self, node: &WindowUDF, buf: &mut Vec<u8>) -> Result<()> {
368        if self.python_udf_inlining && try_encode_python_udwf(node, buf)? {
369            return Ok(());
370        }
371        self.inner.try_encode_udwf(node, buf)
372    }
373
374    fn try_decode_udwf(&self, name: &str, buf: &[u8]) -> Result<Arc<WindowUDF>> {
375        if self.python_udf_inlining {
376            if let Some(udwf) = try_decode_python_udwf(buf)? {
377                return Ok(udwf);
378            }
379        } else {
380            refuse_if_inline(buf, PY_WINDOW_UDF_FAMILY, "window UDF", name)?;
381        }
382        self.inner.try_decode_udwf(name, buf)
383    }
384}
385
386/// Strict-mode gate: if `buf` is a well-framed inline payload for
387/// `family`, return the strict-refusal error; otherwise return
388/// `Ok(())` so the caller can delegate to its `inner` codec.
389///
390/// Routing through [`read_framed_payload`] (rather than a bare
391/// `starts_with` probe) means malformed inline bytes — wrong
392/// wire-format version, mismatched Python version, truncated header —
393/// surface *their* diagnostic instead of the strict-mode message.
394/// The strict message implies sender intent ("inlining is disabled"),
395/// so it should fire only when the bytes really would have decoded.
396///
397/// Fast path: short-circuit on the family-magic prefix before
398/// acquiring the GIL. Plans with many non-Python UDFs would otherwise
399/// pay a GIL acquisition per decode call just to confirm "not a
400/// Python UDF". `read_framed_payload` itself rejects buffers that
401/// don't start with `family`, so this is purely an optimization.
402fn refuse_if_inline(buf: &[u8], family: &[u8], kind: &str, name: &str) -> Result<()> {
403    if !buf.starts_with(family) {
404        return Ok(());
405    }
406    Python::attach(|py| match read_framed_payload(py, buf, family, kind)? {
407        Some(_) => Err(refuse_inline_payload(kind, name)),
408        None => Ok(()),
409    })
410}
411
412/// Build the error returned by a strict codec when it receives an
413/// inline Python-UDF payload it has been told not to deserialize.
414fn refuse_inline_payload(kind: &str, name: &str) -> datafusion::error::DataFusionError {
415    // `Execution`, not `Plan`: this is a wire-format decode refusal at
416    // codec time, not a planner-stage failure. Downstream error
417    // classification keys off the variant — surfacing this as a planner
418    // error would mis-route it into "fix your SQL" buckets.
419    datafusion::error::DataFusionError::Execution(format!(
420        "Refusing to deserialize inline Python {kind} '{name}': Python UDF \
421         inlining is disabled on this session. Two remediations: \
422         (1) ask the sender to re-encode with inlining disabled so '{name}' \
423         travels by name, and register '{name}' on this receiver; or \
424         (2) enable inlining on this receiver (accepts the cloudpickle \
425         execution risk on inbound payloads). Receivers cannot re-encode \
426         bytes they did not produce."
427    ))
428}
429
430/// `PhysicalExtensionCodec` mirror of [`PythonLogicalCodec`] parked
431/// on the same `SessionContext`. Carries the Python-aware encoding
432/// hooks for physical-layer types (`ExecutionPlan`, `PhysicalExpr`)
433/// and delegates the rest to `inner`.
434///
435/// The `PhysicalExtensionCodec` trait has its own `try_encode_udf`
436/// / `try_decode_udf` pair distinct from the logical one, so a
437/// `ScalarUDF` referenced inside a physical plan needs Python-aware
438/// encoding on this layer too — otherwise a plan with a Python UDF
439/// would round-trip at the logical level but break at the physical
440/// level. Both layers reuse the shared payload framing
441/// ([`PY_SCALAR_UDF_FAMILY`] et al.) so the wire format is identical.
442#[derive(Debug)]
443pub struct PythonPhysicalCodec {
444    inner: Arc<dyn PhysicalExtensionCodec>,
445    python_udf_inlining: bool,
446}
447
448impl PythonPhysicalCodec {
449    pub fn new(inner: Arc<dyn PhysicalExtensionCodec>) -> Self {
450        Self {
451            inner,
452            python_udf_inlining: true,
453        }
454    }
455
456    pub fn inner(&self) -> &Arc<dyn PhysicalExtensionCodec> {
457        &self.inner
458    }
459
460    /// Toggle inline encoding of Python UDFs on this physical codec.
461    ///
462    /// Mirrors [`PythonLogicalCodec::with_python_udf_inlining`]; see
463    /// that method for the full security and portability discussion.
464    pub fn with_python_udf_inlining(mut self, enabled: bool) -> Self {
465        self.python_udf_inlining = enabled;
466        self
467    }
468
469    pub fn python_udf_inlining(&self) -> bool {
470        self.python_udf_inlining
471    }
472}
473
474impl Default for PythonPhysicalCodec {
475    fn default() -> Self {
476        Self::new(Arc::new(DefaultPhysicalExtensionCodec {}))
477    }
478}
479
480impl PhysicalExtensionCodec for PythonPhysicalCodec {
481    fn try_decode(
482        &self,
483        buf: &[u8],
484        inputs: &[Arc<dyn ExecutionPlan>],
485        ctx: &TaskContext,
486    ) -> Result<Arc<dyn ExecutionPlan>> {
487        self.inner.try_decode(buf, inputs, ctx)
488    }
489
490    fn try_encode(&self, node: Arc<dyn ExecutionPlan>, buf: &mut Vec<u8>) -> Result<()> {
491        self.inner.try_encode(node, buf)
492    }
493
494    fn try_encode_udf(&self, node: &ScalarUDF, buf: &mut Vec<u8>) -> Result<()> {
495        if self.python_udf_inlining && try_encode_python_scalar_udf(node, buf)? {
496            return Ok(());
497        }
498        self.inner.try_encode_udf(node, buf)
499    }
500
501    fn try_decode_udf(&self, name: &str, buf: &[u8]) -> Result<Arc<ScalarUDF>> {
502        if self.python_udf_inlining {
503            if let Some(udf) = try_decode_python_scalar_udf(buf)? {
504                return Ok(udf);
505            }
506        } else {
507            refuse_if_inline(buf, PY_SCALAR_UDF_FAMILY, "scalar UDF", name)?;
508        }
509        self.inner.try_decode_udf(name, buf)
510    }
511
512    fn try_encode_expr(&self, node: &Arc<dyn PhysicalExpr>, buf: &mut Vec<u8>) -> Result<()> {
513        self.inner.try_encode_expr(node, buf)
514    }
515
516    fn try_decode_expr(
517        &self,
518        buf: &[u8],
519        inputs: &[Arc<dyn PhysicalExpr>],
520    ) -> Result<Arc<dyn PhysicalExpr>> {
521        self.inner.try_decode_expr(buf, inputs)
522    }
523
524    fn try_encode_udaf(&self, node: &AggregateUDF, buf: &mut Vec<u8>) -> Result<()> {
525        if self.python_udf_inlining && try_encode_python_udaf(node, buf)? {
526            return Ok(());
527        }
528        self.inner.try_encode_udaf(node, buf)
529    }
530
531    fn try_decode_udaf(&self, name: &str, buf: &[u8]) -> Result<Arc<AggregateUDF>> {
532        if self.python_udf_inlining {
533            if let Some(udaf) = try_decode_python_udaf(buf)? {
534                return Ok(udaf);
535            }
536        } else {
537            refuse_if_inline(buf, PY_AGG_UDF_FAMILY, "aggregate UDF", name)?;
538        }
539        self.inner.try_decode_udaf(name, buf)
540    }
541
542    fn try_encode_udwf(&self, node: &WindowUDF, buf: &mut Vec<u8>) -> Result<()> {
543        if self.python_udf_inlining && try_encode_python_udwf(node, buf)? {
544            return Ok(());
545        }
546        self.inner.try_encode_udwf(node, buf)
547    }
548
549    fn try_decode_udwf(&self, name: &str, buf: &[u8]) -> Result<Arc<WindowUDF>> {
550        if self.python_udf_inlining {
551            if let Some(udwf) = try_decode_python_udwf(buf)? {
552                return Ok(udwf);
553            }
554        } else {
555            refuse_if_inline(buf, PY_WINDOW_UDF_FAMILY, "window UDF", name)?;
556        }
557        self.inner.try_decode_udwf(name, buf)
558    }
559}
560
561// =============================================================================
562// Shared Python scalar UDF encode / decode helpers
563//
564// Both `PythonLogicalCodec` and `PythonPhysicalCodec` consult these on
565// every `try_encode_udf` / `try_decode_udf` call. Same wire format on
566// both layers — a Python `ScalarUDF` referenced inside a `LogicalPlan`
567// or an `ExecutionPlan` round-trips identically.
568// =============================================================================
569
570/// Encode a Python scalar UDF inline if `node` is one. Returns
571/// `Ok(true)` when the payload (`DFPYUDF` family prefix, version byte,
572/// cloudpickled tuple) was written and the caller should skip its
573/// inner codec. Returns `Ok(false)` for any non-Python UDF, signalling
574/// the caller to delegate to its `inner`.
575pub(crate) fn try_encode_python_scalar_udf(node: &ScalarUDF, buf: &mut Vec<u8>) -> Result<bool> {
576    let Some(py_udf) = node.inner().downcast_ref::<PythonFunctionScalarUDF>() else {
577        return Ok(false);
578    };
579
580    Python::attach(|py| -> Result<bool> {
581        let bytes = encode_python_scalar_udf(py, py_udf).map_err(to_datafusion_err)?;
582        append_framed_payload(py, buf, PY_SCALAR_UDF_FAMILY, &bytes)?;
583        Ok(true)
584    })
585}
586
587/// Decode an inline Python scalar UDF payload. Returns `Ok(None)`
588/// when `buf` does not carry the `DFPYUDF` family prefix, signalling
589/// the caller to delegate to its `inner` codec (and eventually the
590/// `FunctionRegistry`).
591pub(crate) fn try_decode_python_scalar_udf(buf: &[u8]) -> Result<Option<Arc<ScalarUDF>>> {
592    if !buf.starts_with(PY_SCALAR_UDF_FAMILY) {
593        return Ok(None);
594    }
595    Python::attach(|py| -> Result<Option<Arc<ScalarUDF>>> {
596        let Some(payload) = read_framed_payload(py, buf, PY_SCALAR_UDF_FAMILY, "scalar UDF")?
597        else {
598            return Ok(None);
599        };
600        let udf = decode_python_scalar_udf(py, payload).map_err(to_datafusion_err)?;
601        Ok(Some(Arc::new(ScalarUDF::new_from_impl(udf))))
602    })
603}
604
605/// Build the cloudpickle payload for a `PythonFunctionScalarUDF`.
606///
607/// Layout: `cloudpickle.dumps((name, func, input_schema_bytes,
608/// return_schema_bytes, volatility_str))`. Schema blobs are produced
609/// by arrow-rs's native IPC stream writer (no pyarrow round-trip) and
610/// decoded with the matching stream reader on the receiver. See
611/// [`build_input_schema_bytes`] for what the input blob carries.
612fn encode_python_scalar_udf(py: Python<'_>, udf: &PythonFunctionScalarUDF) -> PyResult<Vec<u8>> {
613    let signature = udf.signature();
614    let input_dtypes = signature_input_dtypes(signature, "PythonFunctionScalarUDF")?;
615    let input_schema_bytes = build_input_schema_bytes(&input_dtypes)?;
616    let return_schema_bytes = build_single_field_schema_bytes(udf.return_field().as_ref())?;
617    let volatility = volatility_wire_str(signature.volatility);
618
619    let payload = PyTuple::new(
620        py,
621        [
622            udf.name().into_pyobject(py)?.into_any(),
623            udf.func().bind(py).clone().into_any(),
624            PyBytes::new(py, &input_schema_bytes).into_any(),
625            PyBytes::new(py, &return_schema_bytes).into_any(),
626            volatility.into_pyobject(py)?.into_any(),
627        ],
628    )?;
629
630    cloudpickle(py)?
631        .call_method1("dumps", (payload,))?
632        .extract::<Vec<u8>>()
633}
634
635/// Inverse of [`encode_python_scalar_udf`].
636fn decode_python_scalar_udf(py: Python<'_>, payload: &[u8]) -> PyResult<PythonFunctionScalarUDF> {
637    let tuple = cloudpickle(py)?
638        .call_method1("loads", (PyBytes::new(py, payload),))?
639        .cast_into::<PyTuple>()?;
640
641    let name: String = tuple.get_item(0)?.extract()?;
642    let func: Py<PyAny> = tuple.get_item(1)?.unbind();
643    let input_schema_bytes: Vec<u8> = tuple.get_item(2)?.extract()?;
644    let return_schema_bytes: Vec<u8> = tuple.get_item(3)?.extract()?;
645    let volatility_str: String = tuple.get_item(4)?.extract()?;
646
647    let input_types = read_input_dtypes(&input_schema_bytes)?;
648    let return_field = read_single_return_field(&return_schema_bytes, "PythonFunctionScalarUDF")?;
649    let volatility = parse_volatility_str(&volatility_str)?;
650
651    Ok(PythonFunctionScalarUDF::from_parts(
652        name,
653        func,
654        input_types,
655        return_field,
656        volatility,
657    ))
658}
659
660/// Serialize a `Schema` to a self-contained IPC stream containing
661/// only the schema message (no record batches). Inverse:
662/// [`schema_from_ipc_bytes`].
663fn schema_to_ipc_bytes(schema: &Schema) -> arrow::error::Result<Vec<u8>> {
664    let mut buf: Vec<u8> = Vec::new();
665    {
666        let mut writer = StreamWriter::try_new(&mut buf, schema)?;
667        writer.finish()?;
668    }
669    Ok(buf)
670}
671
672/// Decode an IPC stream containing only a schema message back into a
673/// `Schema`. Inverse: [`schema_to_ipc_bytes`].
674fn schema_from_ipc_bytes(bytes: &[u8]) -> arrow::error::Result<Schema> {
675    let reader = StreamReader::try_new(std::io::Cursor::new(bytes), None)?;
676    Ok(reader.schema().as_ref().clone())
677}
678
679/// Extract the per-arg `DataType`s from a `Signature` known to be
680/// `TypeSignature::Exact` (all Python-defined UDFs are constructed
681/// with `Signature::exact`). Any other variant indicates the impl was
682/// not built by this crate's UDF/UDAF/UDWF constructors.
683fn signature_input_dtypes(signature: &Signature, kind: &str) -> PyResult<Vec<DataType>> {
684    match &signature.type_signature {
685        TypeSignature::Exact(types) => Ok(types.clone()),
686        other => Err(pyo3::exceptions::PyValueError::new_err(format!(
687            "{kind} expected Signature::Exact, got {other:?}"
688        ))),
689    }
690}
691
692/// Wrap per-arg `DataType`s in synthetic `arg_{i}` fields and emit
693/// the IPC schema blob the encoder writes into the cloudpickle tuple.
694///
695/// The names and `nullable: true` are arbitrary: the underlying
696/// `TypeSignature::Exact` carries no per-input nullability or
697/// metadata, and the receiver collapses these fields back to
698/// `Vec<DataType>` via [`read_input_dtypes`], so anything set here
699/// beyond the data type is discarded on decode.
700fn build_input_schema_bytes(dtypes: &[DataType]) -> PyResult<Vec<u8>> {
701    let fields: Vec<Field> = dtypes
702        .iter()
703        .enumerate()
704        .map(|(i, dt)| Field::new(format!("arg_{i}"), dt.clone(), true))
705        .collect();
706    schema_to_ipc_bytes(&Schema::new(fields)).map_err(arrow_to_py_err)
707}
708
709/// Emit a single-field IPC schema blob. Used for return-type and
710/// state-field payloads where the receiver needs to recover field
711/// metadata (names, nullability, key/value attributes) verbatim.
712fn build_single_field_schema_bytes(field: &Field) -> PyResult<Vec<u8>> {
713    schema_to_ipc_bytes(&Schema::new(vec![field.clone()])).map_err(arrow_to_py_err)
714}
715
716/// Emit a multi-field IPC schema blob.
717fn build_schema_bytes(fields: Vec<Field>) -> PyResult<Vec<u8>> {
718    schema_to_ipc_bytes(&Schema::new(fields)).map_err(arrow_to_py_err)
719}
720
721/// Decode the per-arg `DataType`s the encoder wrote via
722/// [`build_input_schema_bytes`].
723fn read_input_dtypes(bytes: &[u8]) -> PyResult<Vec<DataType>> {
724    let schema = schema_from_ipc_bytes(bytes).map_err(arrow_to_py_err)?;
725    Ok(schema
726        .fields()
727        .iter()
728        .map(|f| f.data_type().clone())
729        .collect())
730}
731
732/// Decode a single-field IPC schema blob and return that field by
733/// value. `kind` names the UDF flavor in the error message produced
734/// when the blob is empty (should be unreachable for sender-side
735/// payloads built via [`build_single_field_schema_bytes`]).
736fn read_single_return_field(bytes: &[u8], kind: &str) -> PyResult<Field> {
737    let schema = schema_from_ipc_bytes(bytes).map_err(arrow_to_py_err)?;
738    let field = schema.fields().first().ok_or_else(|| {
739        pyo3::exceptions::PyValueError::new_err(format!(
740            "{kind} return schema must contain exactly one field"
741        ))
742    })?;
743    Ok(field.as_ref().clone())
744}
745
746fn arrow_to_py_err(e: arrow::error::ArrowError) -> PyErr {
747    pyo3::exceptions::PyValueError::new_err(format!("{e}"))
748}
749
750fn parse_volatility_str(s: &str) -> PyResult<Volatility> {
751    datafusion_python_util::parse_volatility(s)
752        .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("{e}")))
753}
754
755/// Stable wire-format string for a `Volatility`. Pinned to the three
756/// tokens [`datafusion_python_util::parse_volatility`] accepts, so an
757/// upstream change to `Volatility`'s `Debug` repr cannot silently
758/// produce bytes the decoder rejects.
759fn volatility_wire_str(v: Volatility) -> &'static str {
760    match v {
761        Volatility::Immutable => "immutable",
762        Volatility::Stable => "stable",
763        Volatility::Volatile => "volatile",
764    }
765}
766
767/// Read the interpreter's `sys.version_info` as `(major, minor)`.
768///
769/// Used by encoder/decoder to stamp and verify the Python version a
770/// cloudpickle payload was produced on. cloudpickle is not portable
771/// across Python minor versions; the wire header carries these bytes
772/// so a mismatch surfaces an actionable error instead of an opaque
773/// `marshal` failure at `cloudpickle.loads` time.
774fn current_python_version(py: Python<'_>) -> PyResult<(u8, u8)> {
775    let version_info = py.import("sys")?.getattr("version_info")?;
776    let major: u8 = version_info.getattr("major")?.extract()?;
777    let minor: u8 = version_info.getattr("minor")?.extract()?;
778    Ok((major, minor))
779}
780
781/// Stamp `buf` with the framing header for `family` plus the current
782/// Python `(major, minor)`, then append `payload`. Bundles the
783/// `current_python_version` lookup with the header write so each
784/// encoder call site stays one line.
785fn append_framed_payload(
786    py: Python<'_>,
787    buf: &mut Vec<u8>,
788    family: &[u8],
789    payload: &[u8],
790) -> Result<()> {
791    let py_version = current_python_version(py).map_err(to_datafusion_err)?;
792    write_wire_header(buf, family, py_version);
793    buf.extend_from_slice(payload);
794    Ok(())
795}
796
797/// Inspect `buf`'s framing against `family` + the current Python
798/// `(major, minor)`. Returns `Ok(None)` when `buf` does not carry
799/// `family` (caller should delegate); `Ok(Some(payload))` when the
800/// framing matches; `Err(_)` for a recognised family at the wrong
801/// wire-format or Python version (see [`strip_wire_header`]).
802fn read_framed_payload<'a>(
803    py: Python<'_>,
804    buf: &'a [u8],
805    family: &[u8],
806    kind: &str,
807) -> Result<Option<&'a [u8]>> {
808    let py_version = current_python_version(py).map_err(to_datafusion_err)?;
809    strip_wire_header(buf, family, kind, py_version)
810}
811
812/// Cached handle to the `cloudpickle` module.
813///
814/// The encode/decode helpers above would otherwise re-resolve the
815/// module on every call. `py.import` is backed by `sys.modules` and
816/// therefore cheap, but each call still walks a dict and re-binds the
817/// result; a plan with many Python UDFs pays that cost per UDF.
818///
819/// `PyOnceLock` scopes the cached `Py<PyAny>` to the current
820/// interpreter, so the slot drops cleanly on interpreter teardown
821/// (relevant under CPython subinterpreters, PEP 684) instead of
822/// resurrecting a `Py` rooted in a dead interpreter on the next call.
823fn cloudpickle<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
824    static CLOUDPICKLE: PyOnceLock<Py<PyAny>> = PyOnceLock::new();
825    CLOUDPICKLE
826        .get_or_try_init(py, || Ok(py.import("cloudpickle")?.unbind().into_any()))
827        .map(|cached| cached.bind(py).clone())
828}
829
830// =============================================================================
831// Shared Python window UDF encode / decode helpers
832//
833// Cloudpickle tuple shape: `(name, evaluator_factory, input_schema_bytes,
834// return_schema_bytes, volatility_str)`. The evaluator factory is the
835// Python callable that produces a new evaluator instance per partition.
836// =============================================================================
837
838pub(crate) fn try_encode_python_udwf(node: &WindowUDF, buf: &mut Vec<u8>) -> Result<bool> {
839    let Some(py_udf) = node.inner().downcast_ref::<PythonFunctionWindowUDF>() else {
840        return Ok(false);
841    };
842
843    Python::attach(|py| -> Result<bool> {
844        let bytes = encode_python_udwf(py, py_udf).map_err(to_datafusion_err)?;
845        append_framed_payload(py, buf, PY_WINDOW_UDF_FAMILY, &bytes)?;
846        Ok(true)
847    })
848}
849
850pub(crate) fn try_decode_python_udwf(buf: &[u8]) -> Result<Option<Arc<WindowUDF>>> {
851    if !buf.starts_with(PY_WINDOW_UDF_FAMILY) {
852        return Ok(None);
853    }
854    Python::attach(|py| -> Result<Option<Arc<WindowUDF>>> {
855        let Some(payload) = read_framed_payload(py, buf, PY_WINDOW_UDF_FAMILY, "window UDF")?
856        else {
857            return Ok(None);
858        };
859        let udf = decode_python_udwf(py, payload).map_err(to_datafusion_err)?;
860        Ok(Some(Arc::new(WindowUDF::new_from_impl(udf))))
861    })
862}
863
864fn encode_python_udwf(py: Python<'_>, udf: &PythonFunctionWindowUDF) -> PyResult<Vec<u8>> {
865    let signature = WindowUDFImpl::signature(udf);
866    let input_dtypes = signature_input_dtypes(signature, "PythonFunctionWindowUDF")?;
867    let input_schema_bytes = build_input_schema_bytes(&input_dtypes)?;
868    let return_field = Field::new("result", udf.return_type().clone(), true);
869    let return_schema_bytes = build_single_field_schema_bytes(&return_field)?;
870    let volatility = volatility_wire_str(signature.volatility);
871
872    let payload = PyTuple::new(
873        py,
874        [
875            WindowUDFImpl::name(udf).into_pyobject(py)?.into_any(),
876            udf.evaluator().bind(py).clone().into_any(),
877            PyBytes::new(py, &input_schema_bytes).into_any(),
878            PyBytes::new(py, &return_schema_bytes).into_any(),
879            volatility.into_pyobject(py)?.into_any(),
880        ],
881    )?;
882
883    cloudpickle(py)?
884        .call_method1("dumps", (payload,))?
885        .extract::<Vec<u8>>()
886}
887
888fn decode_python_udwf(py: Python<'_>, payload: &[u8]) -> PyResult<PythonFunctionWindowUDF> {
889    let tuple = cloudpickle(py)?
890        .call_method1("loads", (PyBytes::new(py, payload),))?
891        .cast_into::<PyTuple>()?;
892
893    let name: String = tuple.get_item(0)?.extract()?;
894    let evaluator: Py<PyAny> = tuple.get_item(1)?.unbind();
895    let input_schema_bytes: Vec<u8> = tuple.get_item(2)?.extract()?;
896    let return_schema_bytes: Vec<u8> = tuple.get_item(3)?.extract()?;
897    let volatility_str: String = tuple.get_item(4)?.extract()?;
898
899    let input_types = read_input_dtypes(&input_schema_bytes)?;
900    let return_type = read_single_return_field(&return_schema_bytes, "PythonFunctionWindowUDF")?
901        .data_type()
902        .clone();
903    let volatility = parse_volatility_str(&volatility_str)?;
904
905    Ok(PythonFunctionWindowUDF::new(
906        name,
907        evaluator,
908        input_types,
909        return_type,
910        volatility,
911    ))
912}
913
914// =============================================================================
915// Shared Python aggregate UDF encode / decode helpers
916//
917// Cloudpickle tuple shape: `(name, accumulator_factory, input_schema_bytes,
918// return_schema_bytes, state_schema_bytes, volatility_str)`. The accumulator
919// factory is the Python callable that produces a new accumulator instance
920// per partition.
921// =============================================================================
922
923pub(crate) fn try_encode_python_udaf(node: &AggregateUDF, buf: &mut Vec<u8>) -> Result<bool> {
924    let Some(py_udf) = node.inner().downcast_ref::<PythonFunctionAggregateUDF>() else {
925        return Ok(false);
926    };
927
928    Python::attach(|py| -> Result<bool> {
929        let bytes = encode_python_udaf(py, py_udf).map_err(to_datafusion_err)?;
930        append_framed_payload(py, buf, PY_AGG_UDF_FAMILY, &bytes)?;
931        Ok(true)
932    })
933}
934
935pub(crate) fn try_decode_python_udaf(buf: &[u8]) -> Result<Option<Arc<AggregateUDF>>> {
936    if !buf.starts_with(PY_AGG_UDF_FAMILY) {
937        return Ok(None);
938    }
939    Python::attach(|py| -> Result<Option<Arc<AggregateUDF>>> {
940        let Some(payload) = read_framed_payload(py, buf, PY_AGG_UDF_FAMILY, "aggregate UDF")?
941        else {
942            return Ok(None);
943        };
944        let udf = decode_python_udaf(py, payload).map_err(to_datafusion_err)?;
945        Ok(Some(Arc::new(AggregateUDF::new_from_impl(udf))))
946    })
947}
948
949fn encode_python_udaf(py: Python<'_>, udf: &PythonFunctionAggregateUDF) -> PyResult<Vec<u8>> {
950    let signature = AggregateUDFImpl::signature(udf);
951    let input_dtypes = signature_input_dtypes(signature, "PythonFunctionAggregateUDF")?;
952    let input_schema_bytes = build_input_schema_bytes(&input_dtypes)?;
953    let return_field = Field::new("result", udf.return_type().clone(), true);
954    let return_schema_bytes = build_single_field_schema_bytes(&return_field)?;
955    let state_fields: Vec<Field> = udf
956        .state_fields_ref()
957        .iter()
958        .map(|f| f.as_ref().clone())
959        .collect();
960    let state_schema_bytes = build_schema_bytes(state_fields)?;
961    let volatility = volatility_wire_str(signature.volatility);
962
963    let payload = PyTuple::new(
964        py,
965        [
966            AggregateUDFImpl::name(udf).into_pyobject(py)?.into_any(),
967            udf.accumulator().bind(py).clone().into_any(),
968            PyBytes::new(py, &input_schema_bytes).into_any(),
969            PyBytes::new(py, &return_schema_bytes).into_any(),
970            PyBytes::new(py, &state_schema_bytes).into_any(),
971            volatility.into_pyobject(py)?.into_any(),
972        ],
973    )?;
974
975    cloudpickle(py)?
976        .call_method1("dumps", (payload,))?
977        .extract::<Vec<u8>>()
978}
979
980fn decode_python_udaf(py: Python<'_>, payload: &[u8]) -> PyResult<PythonFunctionAggregateUDF> {
981    let tuple = cloudpickle(py)?
982        .call_method1("loads", (PyBytes::new(py, payload),))?
983        .cast_into::<PyTuple>()?;
984
985    let name: String = tuple.get_item(0)?.extract()?;
986    let accumulator: Py<PyAny> = tuple.get_item(1)?.unbind();
987    let input_schema_bytes: Vec<u8> = tuple.get_item(2)?.extract()?;
988    let return_schema_bytes: Vec<u8> = tuple.get_item(3)?.extract()?;
989    let state_schema_bytes: Vec<u8> = tuple.get_item(4)?.extract()?;
990    let volatility_str: String = tuple.get_item(5)?.extract()?;
991
992    let input_types = read_input_dtypes(&input_schema_bytes)?;
993    let return_type = read_single_return_field(&return_schema_bytes, "PythonFunctionAggregateUDF")?
994        .data_type()
995        .clone();
996    // Preserve the encoded state field metadata (names, nullability,
997    // arbitrary key/value attributes) so the post-decode UDF reports
998    // the same state schema as the sender's instance — important for
999    // accumulators whose `StateFieldsArgs` consumers key off names or
1000    // nullability rather than positional `DataType`.
1001    let state_schema = schema_from_ipc_bytes(&state_schema_bytes).map_err(arrow_to_py_err)?;
1002    let state_fields: Vec<arrow::datatypes::FieldRef> =
1003        state_schema.fields().iter().cloned().collect();
1004    let volatility = parse_volatility_str(&volatility_str)?;
1005
1006    Ok(PythonFunctionAggregateUDF::from_parts(
1007        name,
1008        accumulator,
1009        input_types,
1010        return_type,
1011        state_fields,
1012        volatility,
1013    ))
1014}
1015
1016#[cfg(test)]
1017mod wire_header_tests {
1018    use super::*;
1019
1020    const TEST_PY: (u8, u8) = (3, 12);
1021
1022    #[test]
1023    fn strip_returns_none_when_family_absent() {
1024        let buf = b"OTHER_PAYLOAD";
1025        assert!(matches!(
1026            strip_wire_header(buf, PY_SCALAR_UDF_FAMILY, "scalar UDF", TEST_PY),
1027            Ok(None)
1028        ));
1029    }
1030
1031    #[test]
1032    fn strip_errors_on_truncated_version_byte() {
1033        let buf = PY_SCALAR_UDF_FAMILY;
1034        let err = strip_wire_header(buf, PY_SCALAR_UDF_FAMILY, "scalar UDF", TEST_PY).unwrap_err();
1035        assert!(format!("{err}").contains("missing wire-format version byte"));
1036    }
1037
1038    #[test]
1039    fn strip_errors_on_too_new_version() {
1040        let mut buf = PY_SCALAR_UDF_FAMILY.to_vec();
1041        buf.push(WIRE_VERSION_CURRENT.saturating_add(1));
1042        buf.push(TEST_PY.0);
1043        buf.push(TEST_PY.1);
1044        buf.extend_from_slice(b"payload");
1045        let err = strip_wire_header(&buf, PY_SCALAR_UDF_FAMILY, "scalar UDF", TEST_PY).unwrap_err();
1046        let msg = format!("{err}");
1047        assert!(msg.contains("wire-format version v"));
1048        assert!(msg.contains("supports"));
1049        assert!(msg.contains("Align datafusion-python versions"));
1050    }
1051
1052    #[test]
1053    fn strip_errors_on_too_old_version() {
1054        if WIRE_VERSION_MIN_SUPPORTED == 0 {
1055            return;
1056        }
1057        let mut buf = PY_SCALAR_UDF_FAMILY.to_vec();
1058        buf.push(WIRE_VERSION_MIN_SUPPORTED - 1);
1059        buf.push(TEST_PY.0);
1060        buf.push(TEST_PY.1);
1061        buf.extend_from_slice(b"payload");
1062        assert!(strip_wire_header(&buf, PY_SCALAR_UDF_FAMILY, "scalar UDF", TEST_PY).is_err());
1063    }
1064
1065    #[test]
1066    fn strip_errors_on_truncated_py_major() {
1067        let mut buf = PY_SCALAR_UDF_FAMILY.to_vec();
1068        buf.push(WIRE_VERSION_CURRENT);
1069        let err = strip_wire_header(&buf, PY_SCALAR_UDF_FAMILY, "scalar UDF", TEST_PY).unwrap_err();
1070        assert!(format!("{err}").contains("missing Python major version byte"));
1071    }
1072
1073    #[test]
1074    fn strip_errors_on_truncated_py_minor() {
1075        let mut buf = PY_SCALAR_UDF_FAMILY.to_vec();
1076        buf.push(WIRE_VERSION_CURRENT);
1077        buf.push(TEST_PY.0);
1078        let err = strip_wire_header(&buf, PY_SCALAR_UDF_FAMILY, "scalar UDF", TEST_PY).unwrap_err();
1079        assert!(format!("{err}").contains("missing Python minor version byte"));
1080    }
1081
1082    #[test]
1083    fn strip_errors_on_py_minor_mismatch() {
1084        let mut buf = Vec::new();
1085        write_wire_header(&mut buf, PY_SCALAR_UDF_FAMILY, (3, 11));
1086        buf.extend_from_slice(b"payload");
1087        let err = strip_wire_header(&buf, PY_SCALAR_UDF_FAMILY, "scalar UDF", (3, 12)).unwrap_err();
1088        let msg = format!("{err}");
1089        assert!(msg.contains("Python 3.11"));
1090        assert!(msg.contains("Python 3.12"));
1091        assert!(msg.contains("not portable across Python minor versions"));
1092    }
1093
1094    #[test]
1095    fn strip_errors_on_py_major_mismatch() {
1096        let mut buf = Vec::new();
1097        write_wire_header(&mut buf, PY_SCALAR_UDF_FAMILY, (3, 12));
1098        buf.extend_from_slice(b"payload");
1099        assert!(strip_wire_header(&buf, PY_SCALAR_UDF_FAMILY, "scalar UDF", (4, 0)).is_err());
1100    }
1101
1102    #[test]
1103    fn write_then_strip_round_trips_scalar_payload() {
1104        let mut buf = Vec::new();
1105        write_wire_header(&mut buf, PY_SCALAR_UDF_FAMILY, TEST_PY);
1106        buf.extend_from_slice(b"scalar-payload");
1107
1108        let payload = strip_wire_header(&buf, PY_SCALAR_UDF_FAMILY, "scalar UDF", TEST_PY)
1109            .unwrap()
1110            .unwrap();
1111        assert_eq!(payload, b"scalar-payload");
1112    }
1113
1114    #[test]
1115    fn write_then_strip_round_trips_agg_payload() {
1116        let mut buf = Vec::new();
1117        write_wire_header(&mut buf, PY_AGG_UDF_FAMILY, TEST_PY);
1118        buf.extend_from_slice(b"agg-payload");
1119
1120        let payload = strip_wire_header(&buf, PY_AGG_UDF_FAMILY, "aggregate UDF", TEST_PY)
1121            .unwrap()
1122            .unwrap();
1123        assert_eq!(payload, b"agg-payload");
1124    }
1125
1126    #[test]
1127    fn write_then_strip_round_trips_window_payload() {
1128        let mut buf = Vec::new();
1129        write_wire_header(&mut buf, PY_WINDOW_UDF_FAMILY, TEST_PY);
1130        buf.extend_from_slice(b"window-payload");
1131
1132        let payload = strip_wire_header(&buf, PY_WINDOW_UDF_FAMILY, "window UDF", TEST_PY)
1133            .unwrap()
1134            .unwrap();
1135        assert_eq!(payload, b"window-payload");
1136    }
1137
1138    #[test]
1139    fn strip_does_not_match_a_different_family() {
1140        let mut buf = Vec::new();
1141        write_wire_header(&mut buf, PY_SCALAR_UDF_FAMILY, TEST_PY);
1142        buf.extend_from_slice(b"payload");
1143        assert!(matches!(
1144            strip_wire_header(&buf, PY_WINDOW_UDF_FAMILY, "window UDF", TEST_PY),
1145            Ok(None)
1146        ));
1147    }
1148}