Skip to content

Commit 78cc12b

Browse files
timsaucerclaude
andcommitted
fix: route the last seven capsule getters through call_capsule_getter
`call_capsule_getter` claimed every capsule getter went through it, so the mapping from a refused argument to a diagnosable error would live in one place. Seven sites still called `getattr(...).call0()` or `.call1(...)` directly, so the claim was false and four of them — catalog provider, schema provider, catalog provider list, table provider factory — still handed an out-of-date extension library a bare `TypeError`. Those four take the host's logical extension codec rather than the session, which is why they could not simply be passed through as they stood: the diagnostic would have told a catalog author their method "must accept the SessionContext", pointing them at the wrong parameter. Carry the argument and its description together in a `CapsuleGetterArg` so one diagnostic can serve getters that take a session, getters that take a codec, and getters that take nothing. `Option<&Bound<PyAny>>` still converts into it, so the documented `*_from_pycapsule` helper signatures are unchanged. The three zero-argument getters (scalar, aggregate, window UDF) route through as well. Nothing can be refused there, but the rule is easier to follow with no exceptions to remember. Also add `validate_pycapsule` to `table_provider_from_pycapsule` and `ffi_logical_codec_from_pycapsule`, the two extraction sites that lacked it. This is not redundant with `pointer_checked`, despite appearances: `pointer_checked` bottoms out in CPython's `PyCapsule_GetPointer`, whose error is the fixed string `PyCapsule_GetPointer called with incorrect name` and names neither the expected capsule nor the one received. Say so in a doc comment so it does not get "simplified" away later. Drop the unused `datafusion-proto` dependency from the query planner example while here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d3ce2b2 commit 78cc12b

9 files changed

Lines changed: 153 additions & 48 deletions

File tree

Cargo.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/core/src/catalog.rs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ use datafusion_ffi::catalog_provider::FFI_CatalogProvider;
3030
use datafusion_ffi::proto::logical_extension_codec::FFI_LogicalExtensionCodec;
3131
use datafusion_ffi::schema_provider::FFI_SchemaProvider;
3232
use datafusion_python_util::{
33-
create_logical_extension_capsule, ffi_logical_codec_from_pycapsule, wait_for_future,
33+
CapsuleGetterArg, call_capsule_getter, create_logical_extension_capsule,
34+
ffi_logical_codec_from_pycapsule, wait_for_future,
3435
};
3536
use pyo3::IntoPyObjectExt;
3637
use pyo3::exceptions::PyKeyError;
@@ -626,9 +627,11 @@ fn extract_catalog_provider_from_pyobj(
626627
if catalog_provider.hasattr("__datafusion_catalog_provider__")? {
627628
let py = catalog_provider.py();
628629
let codec_capsule = create_logical_extension_capsule(py, codec)?;
629-
catalog_provider = catalog_provider
630-
.getattr("__datafusion_catalog_provider__")?
631-
.call1((codec_capsule,))?;
630+
catalog_provider = call_capsule_getter(
631+
catalog_provider,
632+
"__datafusion_catalog_provider__",
633+
CapsuleGetterArg::LogicalCodec(&codec_capsule),
634+
)?;
632635
}
633636

634637
let provider = if let Ok(capsule) = catalog_provider.cast::<PyCapsule>() {
@@ -658,9 +661,11 @@ fn extract_schema_provider_from_pyobj(
658661
if schema_provider.hasattr("__datafusion_schema_provider__")? {
659662
let py = schema_provider.py();
660663
let codec_capsule = create_logical_extension_capsule(py, codec)?;
661-
schema_provider = schema_provider
662-
.getattr("__datafusion_schema_provider__")?
663-
.call1((codec_capsule,))?;
664+
schema_provider = call_capsule_getter(
665+
schema_provider,
666+
"__datafusion_schema_provider__",
667+
CapsuleGetterArg::LogicalCodec(&codec_capsule),
668+
)?;
664669
}
665670

666671
let provider = if let Ok(capsule) = schema_provider.cast::<PyCapsule>() {

crates/core/src/context.rs

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,11 @@ use datafusion_ffi::table_provider_factory::FFI_TableProviderFactory;
5858
use datafusion_proto::logical_plan::LogicalExtensionCodec;
5959
use datafusion_proto::physical_plan::PhysicalExtensionCodec;
6060
use datafusion_python_util::{
61-
create_logical_extension_capsule, create_physical_extension_capsule,
62-
create_query_planner_capsule, ffi_logical_codec_from_pycapsule,
63-
ffi_physical_codec_from_pycapsule, ffi_query_planner_from_pycapsule, get_global_ctx,
64-
get_tokio_runtime, physical_optimizer_rule_from_pycapsule, spawn_future, wait_for_future,
61+
CapsuleGetterArg, call_capsule_getter, create_logical_extension_capsule,
62+
create_physical_extension_capsule, create_query_planner_capsule,
63+
ffi_logical_codec_from_pycapsule, ffi_physical_codec_from_pycapsule,
64+
ffi_query_planner_from_pycapsule, get_global_ctx, get_tokio_runtime,
65+
physical_optimizer_rule_from_pycapsule, spawn_future, wait_for_future,
6566
};
6667
use object_store::ObjectStore;
6768
use pyo3::IntoPyObjectExt;
@@ -725,9 +726,11 @@ impl PySessionContext {
725726
let py = factory.py();
726727
let ffi = self.ffi_logical_codec();
727728
let codec_capsule = create_logical_extension_capsule(py, ffi.as_ref())?;
728-
factory = factory
729-
.getattr("__datafusion_table_provider_factory__")?
730-
.call1((codec_capsule,))?;
729+
factory = call_capsule_getter(
730+
factory,
731+
"__datafusion_table_provider_factory__",
732+
CapsuleGetterArg::LogicalCodec(&codec_capsule),
733+
)?;
731734
}
732735

733736
let factory: Arc<dyn TableProviderFactory> =
@@ -760,9 +763,11 @@ impl PySessionContext {
760763
let py = provider.py();
761764
let ffi = self.ffi_logical_codec();
762765
let codec_capsule = create_logical_extension_capsule(py, ffi.as_ref())?;
763-
provider = provider
764-
.getattr("__datafusion_catalog_provider_list__")?
765-
.call1((codec_capsule,))?;
766+
provider = call_capsule_getter(
767+
provider,
768+
"__datafusion_catalog_provider_list__",
769+
CapsuleGetterArg::LogicalCodec(&codec_capsule),
770+
)?;
766771
}
767772

768773
let provider = if let Ok(capsule) = provider.cast::<PyCapsule>() {
@@ -796,9 +801,11 @@ impl PySessionContext {
796801
let py = provider.py();
797802
let ffi = self.ffi_logical_codec();
798803
let codec_capsule = create_logical_extension_capsule(py, ffi.as_ref())?;
799-
provider = provider
800-
.getattr("__datafusion_catalog_provider__")?
801-
.call1((codec_capsule,))?;
804+
provider = call_capsule_getter(
805+
provider,
806+
"__datafusion_catalog_provider__",
807+
CapsuleGetterArg::LogicalCodec(&codec_capsule),
808+
)?;
802809
}
803810

804811
let provider = if let Ok(capsule) = provider.cast::<PyCapsule>() {

crates/core/src/udaf.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ use datafusion::logical_expr::{
2828
Accumulator, AccumulatorFactoryFunction, AggregateUDF, AggregateUDFImpl, Signature, Volatility,
2929
};
3030
use datafusion_ffi::udaf::FFI_AggregateUDF;
31-
use datafusion_python_util::parse_volatility;
31+
use datafusion_python_util::{CapsuleGetterArg, call_capsule_getter, parse_volatility};
3232
use pyo3::prelude::*;
3333
use pyo3::types::{PyCapsule, PyTuple};
3434

@@ -365,7 +365,11 @@ impl PyAggregateUDF {
365365
}
366366

367367
if func.hasattr("__datafusion_aggregate_udf__")? {
368-
let capsule = func.getattr("__datafusion_aggregate_udf__")?.call0()?;
368+
let capsule = call_capsule_getter(
369+
func.clone(),
370+
"__datafusion_aggregate_udf__",
371+
CapsuleGetterArg::None,
372+
)?;
369373
let capsule = capsule.cast::<PyCapsule>().map_err(py_datafusion_err)?;
370374
let function = aggregate_udf_from_capsule(capsule)?;
371375
return Ok(Self { function });

crates/core/src/udf.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ use datafusion::logical_expr::{
3131
Volatility,
3232
};
3333
use datafusion_ffi::udf::FFI_ScalarUDF;
34-
use datafusion_python_util::parse_volatility;
34+
use datafusion_python_util::{CapsuleGetterArg, call_capsule_getter, parse_volatility};
3535
use pyo3::prelude::*;
3636
use pyo3::types::{PyCapsule, PyTuple};
3737

@@ -248,7 +248,11 @@ impl PyScalarUDF {
248248
#[staticmethod]
249249
pub fn from_pycapsule(func: Bound<'_, PyAny>) -> PyDataFusionResult<Self> {
250250
if func.hasattr("__datafusion_scalar_udf__")? {
251-
let capsule = func.getattr("__datafusion_scalar_udf__")?.call0()?;
251+
let capsule = call_capsule_getter(
252+
func.clone(),
253+
"__datafusion_scalar_udf__",
254+
CapsuleGetterArg::None,
255+
)?;
252256
let capsule = capsule.cast::<PyCapsule>().map_err(to_datafusion_err)?;
253257
let data: NonNull<FFI_ScalarUDF> = capsule
254258
.pointer_checked(Some(c"datafusion_scalar_udf"))?

crates/core/src/udwf.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ use datafusion::logical_expr::{
3030
};
3131
use datafusion::scalar::ScalarValue;
3232
use datafusion_ffi::udwf::FFI_WindowUDF;
33-
use datafusion_python_util::parse_volatility;
33+
use datafusion_python_util::{CapsuleGetterArg, call_capsule_getter, parse_volatility};
3434
use pyo3::exceptions::PyValueError;
3535
use pyo3::prelude::*;
3636
use pyo3::types::{PyCapsule, PyList, PyTuple};
@@ -262,11 +262,8 @@ impl PyWindowUDF {
262262

263263
#[staticmethod]
264264
pub fn from_pycapsule(func: Bound<'_, PyAny>) -> PyDataFusionResult<Self> {
265-
let capsule = if func.hasattr("__datafusion_window_udf__")? {
266-
func.getattr("__datafusion_window_udf__")?.call0()?
267-
} else {
268-
func
269-
};
265+
let capsule =
266+
call_capsule_getter(func, "__datafusion_window_udf__", CapsuleGetterArg::None)?;
270267

271268
let capsule = capsule.cast::<PyCapsule>().map_err(to_datafusion_err)?;
272269
let data: NonNull<FFI_WindowUDF> = capsule

crates/util/src/lib.rs

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,17 @@ pub fn parse_volatility(value: &str) -> PyDataFusionResult<Volatility> {
156156
})
157157
}
158158

159+
/// Check that `capsule` carries the name `name`, and say so usefully if not.
160+
///
161+
/// This looks redundant next to `capsule.pointer_checked(Some(name))`, which
162+
/// also rejects a mismatched name, and it is not. `pointer_checked` bottoms out
163+
/// in CPython's `PyCapsule_GetPointer`, whose error is the fixed string
164+
/// `PyCapsule_GetPointer called with incorrect name` — it names neither what
165+
/// was expected nor what was found. Handing an extension author that message
166+
/// tells them nothing about which capsule they got wrong.
167+
///
168+
/// So call this first at every extraction site. `pointer_checked` still gets
169+
/// the name, because it is what actually guards the cast.
159170
pub fn validate_pycapsule(capsule: &Bound<PyCapsule>, name: &str) -> PyResult<()> {
160171
let capsule_name = capsule.name()?;
161172
if capsule_name.is_none() {
@@ -228,6 +239,7 @@ pub fn table_provider_from_pycapsule<'py>(
228239
obj = call_capsule_getter(obj, "__datafusion_table_provider__", Some(&session))?;
229240

230241
if let Ok(capsule) = obj.cast::<PyCapsule>() {
242+
validate_pycapsule(capsule, "datafusion_table_provider")?;
231243
let data: NonNull<FFI_TableProvider> = capsule
232244
.pointer_checked(Some(c"datafusion_table_provider"))?
233245
.cast();
@@ -250,35 +262,75 @@ pub fn create_logical_extension_capsule<'py>(
250262
PyCapsule::new_with_value(py, codec, cr"datafusion_logical_extension_codec")
251263
}
252264

253-
/// Calls `obj.__<attr_name>__(session)`, or `obj.__<attr_name>__()` when no
254-
/// session is supplied.
265+
/// The single positional argument datafusion-python hands a capsule getter.
266+
///
267+
/// Carrying the argument and its description together is what lets one
268+
/// diagnostic serve the whole family: the getters do not all take a session,
269+
/// and telling the author of a catalog provider that their method "must accept
270+
/// the SessionContext" would send them to fix the wrong signature.
271+
#[derive(Clone, Copy)]
272+
pub enum CapsuleGetterArg<'a, 'py> {
273+
/// The getter takes no arguments, so it cannot refuse one. A `TypeError`
274+
/// from one of these is always the getter's own and is never rewritten.
275+
None,
276+
/// The `SessionContext` the object is being installed on.
277+
Session(&'a Bound<'py, PyAny>),
278+
/// The host's logical extension codec, as a
279+
/// `datafusion_logical_extension_codec` capsule.
280+
LogicalCodec(&'a Bound<'py, PyAny>),
281+
}
282+
283+
impl<'a, 'py> CapsuleGetterArg<'a, 'py> {
284+
fn value(self) -> Option<&'a Bound<'py, PyAny>> {
285+
match self {
286+
Self::None => None,
287+
Self::Session(value) | Self::LogicalCodec(value) => Some(value),
288+
}
289+
}
290+
291+
fn expected(self) -> &'static str {
292+
match self {
293+
Self::None => "",
294+
Self::Session(_) => "the SessionContext it is being installed on",
295+
Self::LogicalCodec(_) => "the host's logical extension codec",
296+
}
297+
}
298+
}
299+
300+
impl<'a, 'py> From<Option<&'a Bound<'py, PyAny>>> for CapsuleGetterArg<'a, 'py> {
301+
fn from(session: Option<&'a Bound<'py, PyAny>>) -> Self {
302+
session.map_or(Self::None, Self::Session)
303+
}
304+
}
305+
306+
/// Calls `obj.__<attr_name>__(arg)`, or `obj.__<attr_name>__()` for
307+
/// [`CapsuleGetterArg::None`]. Returns `obj` untouched when it has no such
308+
/// attribute, so a raw capsule passes straight through.
255309
///
256-
/// The session is how an exporting library obtains the codecs and task context
257-
/// of the session it is being installed on, instead of inventing one of its
258-
/// own. `None` is for the reverse direction, where `obj` *is* a session and is
259-
/// being asked for what it holds.
260-
/// Every capsule getter must go through here rather than calling `getattr`
310+
/// **Every** capsule getter goes through here rather than calling `getattr`
261311
/// directly, so that the mapping from a refused argument to a useful error
262312
/// lives in one place. Three importers previously each had their own copy and
263-
/// each missed later corrections to it.
264-
pub fn call_capsule_getter<'py>(
313+
/// each missed later corrections to it; the getters that take no argument route
314+
/// through as well, so the rule has no exceptions to remember.
315+
pub fn call_capsule_getter<'a, 'py: 'a>(
265316
obj: Bound<'py, PyAny>,
266317
attr_name: &str,
267-
session: Option<&Bound<'py, PyAny>>,
318+
arg: impl Into<CapsuleGetterArg<'a, 'py>>,
268319
) -> PyResult<Bound<'py, PyAny>> {
269320
if !obj.hasattr(attr_name)? {
270321
return Ok(obj);
271322
}
272323

324+
let arg = arg.into();
273325
let getter = obj.getattr(attr_name)?;
274-
let result = match session {
275-
Some(session) => getter.call1((session,)),
326+
let result = match arg.value() {
327+
Some(value) => getter.call1((value,)),
276328
None => getter.call0(),
277329
};
278330

279331
result.map_err(|err| {
280332
let py = obj.py();
281-
if session.is_none() || !err.get_type(py).is(PyType::new::<PyTypeError>(py)) {
333+
if arg.value().is_none() || !err.get_type(py).is(PyType::new::<PyTypeError>(py)) {
282334
return err;
283335
}
284336

@@ -296,8 +348,9 @@ pub fn call_capsule_getter<'py>(
296348
}
297349

298350
let import_err = PyImportError::new_err(format!(
299-
"Incompatible libraries. `{attr_name}` must accept the SessionContext it \
300-
is being installed on. Upgrade the library providing this object."
351+
"Incompatible libraries. `{attr_name}` must accept {}. \
352+
Upgrade the library providing this object.",
353+
arg.expected()
301354
));
302355
// Keep the original reachable as `__cause__` rather than discarding it.
303356
import_err.set_cause(py, Some(err));
@@ -312,6 +365,7 @@ pub fn ffi_logical_codec_from_pycapsule<'py>(
312365
let capsule = call_capsule_getter(obj, "__datafusion_logical_extension_codec__", session)?;
313366

314367
let capsule = capsule.cast::<PyCapsule>()?;
368+
validate_pycapsule(capsule, "datafusion_logical_extension_codec")?;
315369
let data: NonNull<FFI_LogicalExtensionCodec> = capsule
316370
.pointer_checked(Some(c"datafusion_logical_extension_codec"))?
317371
.cast();
@@ -350,7 +404,7 @@ pub fn ffi_task_context_provider_from_pycapsule(
350404
let capsule = call_capsule_getter(
351405
session.clone(),
352406
"__datafusion_task_context_provider__",
353-
None,
407+
CapsuleGetterArg::None,
354408
)?;
355409

356410
let capsule = capsule.cast::<PyCapsule>()?;

examples/datafusion-ffi-query-planner-example/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ datafusion = { workspace = true }
3131
datafusion-catalog = { workspace = true, default-features = false }
3232
datafusion-common = { workspace = true, default-features = false }
3333
datafusion-ffi = { workspace = true }
34-
datafusion-proto = { workspace = true }
3534
datafusion-session = { workspace = true }
3635
async-trait = { workspace = true }
3736
datafusion-python-util.workspace = true

python/tests/test_context.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,6 +1301,42 @@ def __datafusion_table_provider__(self):
13011301
assert isinstance(excinfo.value.__cause__, TypeError)
13021302

13031303

1304+
def test_catalog_provider_getter_arity_error_names_the_codec(ctx):
1305+
"""A getter refusing the codec capsule is diagnosed, not left as a TypeError.
1306+
1307+
`__datafusion_catalog_provider__` is handed the host's logical extension
1308+
codec, not the session. It used to call `getattr(...).call1(...)` directly
1309+
and so produced a bare `TypeError` for an out-of-date library; it now routes
1310+
through `call_capsule_getter` like the rest of the family.
1311+
1312+
The message has to name the codec rather than the SessionContext, or it
1313+
would send the author to change the wrong parameter.
1314+
"""
1315+
1316+
class PreCodecCatalogProvider:
1317+
def __datafusion_catalog_provider__(self):
1318+
msg = "should never be called"
1319+
raise AssertionError(msg)
1320+
1321+
with pytest.raises(ImportError, match="__datafusion_catalog_provider__") as excinfo:
1322+
ctx.register_catalog_provider("old_sig", PreCodecCatalogProvider())
1323+
assert "logical extension codec" in str(excinfo.value)
1324+
assert "SessionContext" not in str(excinfo.value)
1325+
assert isinstance(excinfo.value.__cause__, TypeError)
1326+
1327+
1328+
def test_type_error_inside_a_catalog_provider_getter_propagates(ctx):
1329+
"""A correctly-signed catalog getter's own TypeError survives unchanged."""
1330+
1331+
class RaisesTypeError:
1332+
def __datafusion_catalog_provider__(self, codec):
1333+
msg = "bad cast inside the getter"
1334+
raise TypeError(msg)
1335+
1336+
with pytest.raises(TypeError, match="bad cast inside the getter"):
1337+
ctx.register_catalog_provider("raises", RaisesTypeError())
1338+
1339+
13041340
def test_type_error_inside_a_table_provider_getter_propagates(ctx):
13051341
"""A correctly-signed provider getter's own TypeError survives unchanged."""
13061342

0 commit comments

Comments
 (0)