Skip to content

Commit cfe0a25

Browse files
Icxoludavidhewitt
andauthored
move away from the FromPyObject blanket for PyClass + Clone (#5488)
* move `FromPyObject` pyclass blanket into macro * add opt-out option * add tests * add newsfragments * fix typo Co-authored-by: David Hewitt <mail@davidhewitt.dev> --------- Co-authored-by: David Hewitt <mail@davidhewitt.dev>
1 parent bcab64d commit cfe0a25

File tree

10 files changed

+136
-4
lines changed

10 files changed

+136
-4
lines changed

guide/pyclass-parameters.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
| `rename_all = "renaming_rule"` | Applies renaming rules to every getters and setters of a struct, or every variants of an enum. Possible values are: "camelCase", "kebab-case", "lowercase", "PascalCase", "SCREAMING-KEBAB-CASE", "SCREAMING_SNAKE_CASE", "snake_case", "UPPERCASE". |
2222
| `sequence` | Inform PyO3 that this class is a [`Sequence`][params-sequence], and so leave its C-API mapping length slot empty. |
2323
| `set_all` | Generates setters for all fields of the pyclass. |
24+
| `skip_from_py_object` | Prevents this PyClass from participating in the `FromPyObject: PyClass + Clone` blanket implementation. This allows a custom `FromPyObject` impl, even if `self` is `Clone`. |
2425
| `str` | Implements `__str__` using the `Display` implementation of the underlying Rust datatype or by passing an optional format string `str="<format string>"`. *Note: The optional format string is only allowed for structs. `name` and `rename_all` are incompatible with the optional format string. Additional details can be found in the discussion on this [PR](https://github.com/PyO3/pyo3/pull/4233).* |
2526
| `subclass` | Allows other Python classes and `#[pyclass]` to inherit from this class. Enums cannot be subclassed. |
2627
| `unsendable` | Required if your struct is not [`Send`][params-3]. Rather than using `unsendable`, consider implementing your struct in a thread-safe way by e.g. substituting [`Rc`][params-4] with [`Arc`][params-5]. By using `unsendable`, your class will panic when accessed by another thread. Also note the Python's GC is multi-threaded and while unsendable classes will not be traversed on foreign threads to avoid UB, this can lead to memory leaks. |

guide/src/conversions/traits.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,29 @@ struct RustyStruct {
535535
# }
536536
```
537537

538+
#### ⚠ Phase-Out of `FromPyObject` blanket implementation for cloneable PyClasses ⚠
539+
Historically PyO3 has provided a blanket implementation for `#[pyclass]` types that also implement `Clone`, to allow extraction of such types by value. Over time this has turned out problematic for a few reasons, the major one being the prevention of custom conversions by downstream crates if their type is `Clone`. Over the next few releases the blanket implementation is gradually phased out, and eventually replaced by an opt-in option. As a first step of this migration a new `skip_from_py_object` option for `#[pyclass]` was introduced, to opt-out of the blanket implementation and allow downstream users to provide their own implementation:
540+
```rust
541+
# #![allow(dead_code)]
542+
# use pyo3::prelude::*;
543+
544+
#[pyclass(skip_from_py_object)] // opt-out of the PyO3 FromPyObject blanket
545+
#[derive(Clone)]
546+
struct Number(i32);
547+
548+
impl<'py> FromPyObject<'_, 'py> for Number {
549+
type Error = PyErr;
550+
551+
fn extract(obj: pyo3::Borrowed<'_, 'py, pyo3::PyAny>) -> Result<Self, Self::Error> {
552+
if let Ok(obj) = obj.cast::<Self>() { // first try extraction via class object
553+
Ok(obj.borrow().clone())
554+
} else {
555+
obj.extract::<i32>().map(Self) // otherwise try integer directly
556+
}
557+
}
558+
}
559+
```
560+
538561
### `IntoPyObject`
539562
The [`IntoPyObject`] trait defines the to-python conversion for a Rust type. All types in PyO3 implement this trait,
540563
as does a `#[pyclass]` which doesn't use `extends`.

newsfragments/5488.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
add `skip_from_py_object` pyclass option, to opt-out of the `FromPyObject: PyClass + Clone` blanket impl

pyo3-macros-backend/src/attributes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ pub mod kw {
5353
syn::custom_keyword!(warn);
5454
syn::custom_keyword!(message);
5555
syn::custom_keyword!(category);
56+
syn::custom_keyword!(skip_from_py_object);
5657
}
5758

5859
fn take_int(read: &mut &str, tracker: &mut usize) -> String {

pyo3-macros-backend/src/pyclass.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ pub struct PyClassPyO3Options {
9090
pub unsendable: Option<kw::unsendable>,
9191
pub weakref: Option<kw::weakref>,
9292
pub generic: Option<kw::generic>,
93+
pub skip_from_py_object: Option<kw::skip_from_py_object>,
9394
}
9495

9596
pub enum PyClassPyO3Option {
@@ -115,6 +116,7 @@ pub enum PyClassPyO3Option {
115116
Unsendable(kw::unsendable),
116117
Weakref(kw::weakref),
117118
Generic(kw::generic),
119+
SkipFromPyObject(kw::skip_from_py_object),
118120
}
119121

120122
impl Parse for PyClassPyO3Option {
@@ -164,6 +166,8 @@ impl Parse for PyClassPyO3Option {
164166
input.parse().map(PyClassPyO3Option::Weakref)
165167
} else if lookahead.peek(attributes::kw::generic) {
166168
input.parse().map(PyClassPyO3Option::Generic)
169+
} else if lookahead.peek(attributes::kw::skip_from_py_object) {
170+
input.parse().map(PyClassPyO3Option::SkipFromPyObject)
167171
} else {
168172
Err(lookahead.error())
169173
}
@@ -243,6 +247,9 @@ impl PyClassPyO3Options {
243247
set_option!(weakref);
244248
}
245249
PyClassPyO3Option::Generic(generic) => set_option!(generic),
250+
PyClassPyO3Option::SkipFromPyObject(skip_from_py_object) => {
251+
set_option!(skip_from_py_object)
252+
}
246253
}
247254
Ok(())
248255
}
@@ -2497,7 +2504,15 @@ impl<'a> PyClassImplsBuilder<'a> {
24972504
quote! {}
24982505
};
24992506

2507+
let extract_pyclass_with_clone = if self.attr.options.skip_from_py_object.is_none() {
2508+
quote!( impl #pyo3_path::impl_::pyclass::ExtractPyClassWithClone for #cls {} )
2509+
} else {
2510+
TokenStream::new()
2511+
};
2512+
25002513
Ok(quote! {
2514+
#extract_pyclass_with_clone
2515+
25012516
#assertions
25022517

25032518
#pyclass_base_type_impl

src/conversion.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! Defines conversions between Rust and Python types.
22
use crate::err::PyResult;
3+
use crate::impl_::pyclass::ExtractPyClassWithClone;
34
#[cfg(feature = "experimental-inspect")]
45
use crate::inspect::types::TypeInfo;
56
use crate::pyclass::boolean_struct::False;
@@ -529,7 +530,7 @@ impl<'py, T> FromPyObjectOwned<'py> for T where T: for<'a> FromPyObject<'a, 'py>
529530

530531
impl<'a, 'py, T> FromPyObject<'a, 'py> for T
531532
where
532-
T: PyClass + Clone,
533+
T: PyClass + Clone + ExtractPyClassWithClone,
533534
{
534535
type Error = PyClassGuardError<'a, 'py>;
535536

@@ -601,3 +602,38 @@ impl<'py> IntoPyObject<'py> for () {
601602
/// })
602603
/// ```
603604
mod test_no_clone {}
605+
606+
#[cfg(test)]
607+
mod tests {
608+
#[test]
609+
#[cfg(feature = "macros")]
610+
fn test_pyclass_skip_from_py_object() {
611+
use crate::{types::PyAnyMethods, FromPyObject, IntoPyObject, PyErr, Python};
612+
613+
#[crate::pyclass(crate = "crate", skip_from_py_object)]
614+
#[derive(Clone)]
615+
struct Foo(i32);
616+
617+
impl<'py> FromPyObject<'_, 'py> for Foo {
618+
type Error = PyErr;
619+
620+
fn extract(obj: crate::Borrowed<'_, 'py, crate::PyAny>) -> Result<Self, Self::Error> {
621+
if let Ok(obj) = obj.cast::<Self>() {
622+
Ok(obj.borrow().clone())
623+
} else {
624+
obj.extract::<i32>().map(Self)
625+
}
626+
}
627+
}
628+
Python::attach(|py| {
629+
let foo1 = 42i32.into_pyobject(py)?;
630+
assert_eq!(foo1.extract::<Foo>()?.0, 42);
631+
632+
let foo2 = Foo(0).into_pyobject(py)?;
633+
assert_eq!(foo2.extract::<Foo>()?.0, 0);
634+
635+
Ok::<_, PyErr>(())
636+
})
637+
.unwrap();
638+
}
639+
}

src/impl_/pyclass.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1426,6 +1426,8 @@ impl<const IMPLEMENTS_INTOPYOBJECT: bool> ConvertField<false, IMPLEMENTS_INTOPYO
14261426
}
14271427
}
14281428

1429+
pub trait ExtractPyClassWithClone {}
1430+
14291431
#[cfg(test)]
14301432
#[cfg(feature = "macros")]
14311433
mod tests {

tests/ui/invalid_pyclass_args.stderr

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `hash`, `immutable_type`, `mapping`, `module`, `name`, `ord`, `rename_all`, `sequence`, `set_all`, `str`, `subclass`, `unsendable`, `weakref`, `generic`
1+
error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `hash`, `immutable_type`, `mapping`, `module`, `name`, `ord`, `rename_all`, `sequence`, `set_all`, `str`, `subclass`, `unsendable`, `weakref`, `generic`, `skip_from_py_object`
22
--> tests/ui/invalid_pyclass_args.rs:4:11
33
|
44
4 | #[pyclass(extend=pyo3::types::PyDict)]
@@ -46,7 +46,7 @@ error: expected string literal
4646
25 | #[pyclass(module = my_module)]
4747
| ^^^^^^^^^
4848

49-
error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `hash`, `immutable_type`, `mapping`, `module`, `name`, `ord`, `rename_all`, `sequence`, `set_all`, `str`, `subclass`, `unsendable`, `weakref`, `generic`
49+
error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `hash`, `immutable_type`, `mapping`, `module`, `name`, `ord`, `rename_all`, `sequence`, `set_all`, `str`, `subclass`, `unsendable`, `weakref`, `generic`, `skip_from_py_object`
5050
--> tests/ui/invalid_pyclass_args.rs:28:11
5151
|
5252
28 | #[pyclass(weakrev)]

tests/ui/invalid_pyfunction_argument.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,13 @@ fn invalid_pyfunction_argument(arg: AtomicPtr<()>) {
66
let _ = arg;
77
}
88

9+
#[pyclass(skip_from_py_object)]
10+
#[derive(Clone)]
11+
struct Foo;
12+
13+
#[pyfunction]
14+
fn skip_from_py_object_without_custom_from_py_object(arg: Foo) {
15+
let _ = arg;
16+
}
17+
918
fn main() {}

tests/ui/invalid_pyfunction_argument.stderr

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,26 @@ error[E0277]: `AtomicPtr<()>` cannot be used as a Python function argument
66
|
77
= note: implement `FromPyObject` to enable using `AtomicPtr<()>` as a function argument
88
= note: `Python<'py>` is also a valid argument type to pass the Python token into `#[pyfunction]`s and `#[pymethods]`
9+
= help: the trait `PyClass` is implemented for `Foo`
10+
= note: required for `AtomicPtr<()>` to implement `FromPyObject<'_, '_>`
11+
= note: required for `AtomicPtr<()>` to implement `PyFunctionArgument<'_, '_, '_, true>`
12+
note: required by a bound in `extract_argument`
13+
--> src/impl_/extract_argument.rs
14+
|
15+
| pub fn extract_argument<'a, 'holder, 'py, T, const IMPLEMENTS_FROMPYOBJECT: bool>(
16+
| ---------------- required by a bound in this function
17+
...
18+
| T: PyFunctionArgument<'a, 'holder, 'py, IMPLEMENTS_FROMPYOBJECT>,
19+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `extract_argument`
20+
21+
error[E0277]: `AtomicPtr<()>` cannot be used as a Python function argument
22+
--> tests/ui/invalid_pyfunction_argument.rs:5:37
23+
|
24+
5 | fn invalid_pyfunction_argument(arg: AtomicPtr<()>) {
25+
| ^^^^^^^^^ the trait `Clone` is not implemented for `AtomicPtr<()>`
26+
|
27+
= note: implement `FromPyObject` to enable using `AtomicPtr<()>` as a function argument
28+
= note: `Python<'py>` is also a valid argument type to pass the Python token into `#[pyfunction]`s and `#[pymethods]`
929
= help: the following other types implement trait `PyFunctionArgument<'a, 'holder, 'py, IMPLEMENTS_FROMPYOBJECT>`:
1030
`&'a pyo3::Bound<'py, T>` implements `PyFunctionArgument<'a, '_, 'py, false>`
1131
`&'holder T` implements `PyFunctionArgument<'a, 'holder, '_, false>`
@@ -26,7 +46,7 @@ error[E0277]: `AtomicPtr<()>` cannot be used as a Python function argument
2646
--> tests/ui/invalid_pyfunction_argument.rs:5:37
2747
|
2848
5 | fn invalid_pyfunction_argument(arg: AtomicPtr<()>) {
29-
| ^^^^^^^^^ the trait `Clone` is not implemented for `AtomicPtr<()>`
49+
| ^^^^^^^^^ the trait `ExtractPyClassWithClone` is not implemented for `AtomicPtr<()>`
3050
|
3151
= note: implement `FromPyObject` to enable using `AtomicPtr<()>` as a function argument
3252
= note: `Python<'py>` is also a valid argument type to pass the Python token into `#[pyfunction]`s and `#[pymethods]`
@@ -45,3 +65,27 @@ note: required by a bound in `extract_argument`
4565
...
4666
| T: PyFunctionArgument<'a, 'holder, 'py, IMPLEMENTS_FROMPYOBJECT>,
4767
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `extract_argument`
68+
69+
error[E0277]: `Foo` cannot be used as a Python function argument
70+
--> tests/ui/invalid_pyfunction_argument.rs:14:59
71+
|
72+
14 | fn skip_from_py_object_without_custom_from_py_object(arg: Foo) {
73+
| ^^^ the trait `ExtractPyClassWithClone` is not implemented for `Foo`
74+
|
75+
= note: implement `FromPyObject` to enable using `Foo` as a function argument
76+
= note: `Python<'py>` is also a valid argument type to pass the Python token into `#[pyfunction]`s and `#[pymethods]`
77+
= help: the following other types implement trait `PyFunctionArgument<'a, 'holder, 'py, IMPLEMENTS_FROMPYOBJECT>`:
78+
`&'a pyo3::Bound<'py, T>` implements `PyFunctionArgument<'a, '_, 'py, false>`
79+
`&'holder T` implements `PyFunctionArgument<'a, 'holder, '_, false>`
80+
`&'holder mut T` implements `PyFunctionArgument<'a, 'holder, '_, false>`
81+
`Option<T>` implements `PyFunctionArgument<'a, 'holder, 'py, false>`
82+
= note: required for `Foo` to implement `FromPyObject<'_, '_>`
83+
= note: required for `Foo` to implement `PyFunctionArgument<'_, '_, '_, true>`
84+
note: required by a bound in `extract_argument`
85+
--> src/impl_/extract_argument.rs
86+
|
87+
| pub fn extract_argument<'a, 'holder, 'py, T, const IMPLEMENTS_FROMPYOBJECT: bool>(
88+
| ---------------- required by a bound in this function
89+
...
90+
| T: PyFunctionArgument<'a, 'holder, 'py, IMPLEMENTS_FROMPYOBJECT>,
91+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `extract_argument`

0 commit comments

Comments
 (0)