Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
71c9365
speicialize self conversion for descriptor slots for which CPython do…
MatthieuDartiailh Mar 30, 2026
3932d02
Implement trusted self conversion for all extension-type method and s…
Copilot Apr 7, 2026
c36a08d
tests: bless outputs
MatthieuDartiailh Apr 28, 2026
831a855
Merge branch 'main' into descriptor
MatthieuDartiailh Apr 29, 2026
8f685ee
add a news fragment
MatthieuDartiailh Apr 29, 2026
e2fbacf
address review comments
MatthieuDartiailh May 6, 2026
dd3d5eb
proper news fragment
MatthieuDartiailh May 6, 2026
fa19092
wip fixing tests
MatthieuDartiailh May 6, 2026
0462993
attempt to fix test
MatthieuDartiailh May 6, 2026
7170fda
fix broken tests
MatthieuDartiailh May 21, 2026
e86289e
Merge remote-tracking branch 'origin/main' into descriptor
MatthieuDartiailh May 21, 2026
cabbe9b
address review comment
MatthieuDartiailh May 21, 2026
ad0bf36
test: bless ui test
MatthieuDartiailh May 27, 2026
d449db0
test: disable 2 tests based on accessing __getattr__ from type object
MatthieuDartiailh May 27, 2026
63d014f
tests: broader type ignore in tests testing with known wrong types
MatthieuDartiailh May 27, 2026
abdb347
Merge branch 'main' into descriptor
MatthieuDartiailh May 27, 2026
00e8225
test: bless ui tests
MatthieuDartiailh May 27, 2026
d3fcc97
fix clippy warning
MatthieuDartiailh May 27, 2026
33f6421
test: ui fix indentation
MatthieuDartiailh May 27, 2026
1661553
Merge remote-tracking branch 'origin/main' into descriptor
MatthieuDartiailh May 27, 2026
2a1240b
test; fix again bad indent in ui test
MatthieuDartiailh May 27, 2026
93165c0
test: do not test for silly calls in pure Python
MatthieuDartiailh May 28, 2026
25d9862
test: ui fix formatting
MatthieuDartiailh May 28, 2026
bba8597
Merge remote-tracking branch 'origin/main' into descriptor
MatthieuDartiailh Jun 3, 2026
07921a4
address review comments
MatthieuDartiailh Jun 3, 2026
16e9b0a
tests: attempt to fix ui tests
MatthieuDartiailh Jun 3, 2026
28d5326
tests: attempt to fix ui tests
MatthieuDartiailh Jun 3, 2026
4e30ed0
Revert "tests: attempt to fix ui tests"
MatthieuDartiailh Jun 3, 2026
f1daa3b
Merge remote-tracking branch 'origin/main' into descriptor
MatthieuDartiailh Jun 3, 2026
0a9df8e
test: ui attempt to fix tests
MatthieuDartiailh Jun 5, 2026
afe4d36
fix bad indent
MatthieuDartiailh Jun 5, 2026
0351c2a
Merge remote-tracking branch 'origin/main' into descriptor
MatthieuDartiailh Jun 5, 2026
1916d80
revert bad changes to invalid_pyfunction_argument.default.stderr
MatthieuDartiailh Jun 5, 2026
130b5f3
Merge remote-tracking branch 'origin/main' into descriptor
davidhewitt Jun 10, 2026
80c7534
label SAFETY comments
davidhewitt Jun 10, 2026
7ab495e
disable trusted optimization on PyPy
davidhewitt Jun 10, 2026
c76bd35
fixup richcmp on PyPy
davidhewitt Jun 10, 2026
411c151
fix msrv build
davidhewitt Jun 10, 2026
6ca12ea
fix clippy
davidhewitt Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions newsfragments/5930.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Remove redundant type checks for methods where CPython guarantees the type of `self`
141 changes: 123 additions & 18 deletions pyo3-macros-backend/src/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ impl FnType {
&self,
cls: Option<&syn::Type>,
error_mode: ExtractErrorMode,
self_conversion: SelfConversionPolicy,
holders: &mut Holders,
ctx: &Ctx,
) -> Option<TokenStream> {
Expand All @@ -272,6 +273,7 @@ impl FnType {
Some(st.receiver(
cls.expect("no class given for Fn with a \"self\" receiver"),
error_mode,
self_conversion,
holders,
ctx,
))
Expand Down Expand Up @@ -320,6 +322,56 @@ pub enum SelfType {
},
}

#[derive(Clone, Copy, Debug)]
enum SelfConversionPolicyInner {
/// The receiver's type is guaranteed by CPython's slot/method dispatch contract.
/// Used for all extension-type method and slot entrypoints.
Trusted,
/// The receiver's type is verified at runtime. Used for number-protocol
/// binary operator fragments where the CPython dispatch contract does not
/// guarantee the receiver type.
Checked,
}

/// Receiver conversion policy for extension-type method wrappers.
///
/// Controls whether the `self` receiver is validated with a runtime type check
/// (`Checked`) or treated as trusted and cast directly without checking
/// (`Trusted`).
///
/// # Invariant
///
/// The `Trusted` path is valid due to CPython's slot/method receiver contract:
/// when CPython dispatches a method call on an extension type — whether through
/// a type slot or through `tp_methods` — the receiver is guaranteed to be an
/// instance of the owning type (or a compatible subtype). For `tp_methods`
/// entries, CPython's method-wrapper descriptor enforces this before the C
/// function is reached.
///
/// `Checked` should be used in cases where that guarantee does not hold:
/// - Number-protocol binary operator fragments (`__add__`, `__radd__`, …,
/// `__pow__`, `__rpow__`): CPython combines the forward and reflected
/// fragments into a single `nb_add`/`nb_power` slot, and the runtime helper
/// may call the reflected fragment with the operands swapped, meaning `_slf`
/// can arrive with a non-class type. The existing
/// `ExtractErrorMode::NotImplemented` behavior on type mismatch is preserved
/// by using `Checked` for those fragments.
Comment on lines +352 to +358

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps, but I wonder if we are making a mistake by calling the runtime helper with the arguments swapped (we might not match CPython's behavior for __add__ / __radd__, for example). Maybe CPython always calls the slot with self on the LHS? I cannot remember, worth checking.

@MatthieuDartiailh MatthieuDartiailh May 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/python/cpython/blob/main/Include/cpython/object.h#L62-L64
mentions explicitly that nb_add must check both args.

The proper fix may be to alter define_pyclass_binary_operator_slot in pyo3 to check the argument and dispatch to __add__ and __radd__ accordingly.

This is technically what the code does since a failed extraction is turned into NotImplemented. It is somewhat convoluted which is why it took me a couple of iterations of this comment to get it right.

To me it looks wrong to call __radd__ on the same object if __add__ fails. I believe it should be called on the other object and this is the responsibility of CPython to make the call.

#[derive(Clone, Copy, Debug)]
pub struct SelfConversionPolicy(SelfConversionPolicyInner);

impl SelfConversionPolicy {
pub const fn checked() -> Self {
Self(SelfConversionPolicyInner::Checked)
}

// Using the trusted conversion incorrectly can lead to incorrect runtime
// behavior and memory safety issues, so this is marked `unsafe` and usage
// should be justified by the caller.
pub const unsafe fn trusted() -> Self {
Self(SelfConversionPolicyInner::Trusted)
}
}

#[derive(Clone, Copy)]
pub enum ExtractErrorMode {
NotImplemented,
Expand All @@ -346,6 +398,7 @@ impl SelfType {
&self,
cls: &syn::Type,
error_mode: ExtractErrorMode,
self_conversion: SelfConversionPolicy,
holders: &mut Holders,
ctx: &Ctx,
) -> TokenStream {
Expand All @@ -367,22 +420,45 @@ impl SelfType {
};
let arg =
quote! { unsafe { #pyo3_path::impl_::extract_argument::#cast_fn(#py, #slf) } };
let method = if *mutable {
syn::Ident::new("extract_pyclass_ref_mut", *span)
} else {
syn::Ident::new("extract_pyclass_ref", *span)
};
let holder = holders.push_holder(*span);
let pyo3_path = pyo3_path.to_tokens_spanned(*span);
error_mode.handle_error(
quote_spanned! { *span =>
#pyo3_path::impl_::extract_argument::#method::<#cls>(
#arg,
&mut #holder,
match self_conversion.0 {
SelfConversionPolicyInner::Trusted => {
let method = if *mutable {
syn::Ident::new("extract_pyclass_ref_mut_trusted", *span)
} else {
syn::Ident::new("extract_pyclass_ref_trusted", *span)
};
// Safety: slot wrappers are only installed on the extension type itself.
// CPython's slot dispatch contract ensures the receiver is an instance
// of the correct type before invoking the slot.
//
// The trailing `?` exists because if the extraction fails here it represents
// a genuine type error, should not fall back to e.g. `ExtractErrorMode::NotImplemented`.
quote! {
unsafe { #pyo3_path::impl_::extract_argument::#method::<#cls>(
#arg,
&mut #holder,
) }?
}
}
SelfConversionPolicyInner::Checked => {
let method = if *mutable {
syn::Ident::new("extract_pyclass_ref_mut", *span)
} else {
syn::Ident::new("extract_pyclass_ref", *span)
};
error_mode.handle_error(
quote_spanned! { *span =>
#pyo3_path::impl_::extract_argument::#method::<#cls>(
#arg,
&mut #holder,
)
},
ctx,
)
},
ctx,
)
}
}
}
SelfType::TryFromBoundRef { span, non_null } => {
let bound_ref = if *non_null {
Expand All @@ -391,10 +467,34 @@ impl SelfType {
quote! { unsafe { #pyo3_path::Bound::ref_from_ptr(#py, &#slf) } }
};
let pyo3_path = pyo3_path.to_tokens_spanned(*span);
let receiver = match self_conversion.0 {
SelfConversionPolicyInner::Trusted => {
// Safety: slot wrappers are only installed on the extension type
// itself. CPython's slot dispatch contract ensures the receiver is
// an instance of the correct type (or a compatible subtype) before
// invoking the slot.
//
// The wrapping `Ok(...?)` here is because an error here should not
// be treated by e.g. `ExtractErrorMode::NotImplemented` as falling
// back to the default, but instead a genuine type error.
quote! {
unsafe {
#pyo3_path::PyResult::Ok(
#pyo3_path::impl_::extract_argument::cast_bound_ref_trusted::<#cls>(#bound_ref)?
)
}
}
}
SelfConversionPolicyInner::Checked => {
quote_spanned! { *span =>
#bound_ref.cast::<#cls>()
.map_err(::std::convert::Into::<#pyo3_path::PyErr>::into)
}
}
};
error_mode.handle_error(
quote_spanned! { *span =>
#bound_ref.cast::<#cls>()
.map_err(::std::convert::Into::<#pyo3_path::PyErr>::into)
#receiver
.and_then(
#[allow(
clippy::unnecessary_fallible_conversions,
Expand Down Expand Up @@ -678,6 +778,7 @@ impl<'a> FnSpec<'a> {
ident: &proc_macro2::Ident,
cls: Option<&syn::Type>,
convention: CallingConvention,
self_conversion: SelfConversionPolicy,
ctx: &Ctx,
) -> Result<TokenStream> {
let Ctx {
Expand All @@ -700,9 +801,13 @@ impl<'a> FnSpec<'a> {
}

let rust_call = |args: Vec<TokenStream>, mut holders: Holders| {
let self_arg = self
.tp
.self_arg(cls, ExtractErrorMode::Raise, &mut holders, ctx);
let self_arg = self.tp.self_arg(
cls,
ExtractErrorMode::Raise,
self_conversion,
&mut holders,
ctx,
);
let init_holders = holders.init_holders(ctx);

// We must assign the output_span to the return value of the call,
Expand Down
10 changes: 8 additions & 2 deletions pyo3-macros-backend/src/pyfunction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::{
self, get_pyo3_options, take_attributes, take_pyo3_options, CrateAttribute,
FromPyWithAttribute, NameAttribute, TextSignatureAttribute,
},
method::{self, CallingConvention, FnArg},
method::{self, CallingConvention, FnArg, SelfConversionPolicy},
pymethod::check_generic,
};
use proc_macro2::{Span, TokenStream};
Expand Down Expand Up @@ -430,7 +430,13 @@ pub fn impl_wrap_pyfunction(
);
}
let calling_convention = CallingConvention::from_signature(&spec.signature);
let wrapper = spec.get_wrapper_function(&wrapper_ident, None, calling_convention, ctx)?;
let wrapper = spec.get_wrapper_function(
&wrapper_ident,
None,
calling_convention,
SelfConversionPolicy::checked(),
ctx,
)?;
let methoddef = spec.get_methoddef(
wrapper_ident,
spec.get_doc(&func.attrs).as_ref(),
Expand Down
Loading
Loading