Skip to content

Commit 936a1c6

Browse files
Icxoludavidhewitt
andauthored
introduce immutable_type option for pyclasses (PyO3#5101)
* introduce `immutable_type` option for pyclasses * allow `immutable_type` from 3.10 on the unlimited api * fix `is_abi3_before` check --------- Co-authored-by: David Hewitt <[email protected]>
1 parent 4bb903e commit 936a1c6

File tree

13 files changed

+142
-16
lines changed

13 files changed

+142
-16
lines changed

guide/pyclass-parameters.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
| <span style="white-space: pre">`frozen`</span> | Declares that your pyclass is immutable. It removes the borrow checker overhead when retrieving a shared reference to the Rust struct, but disables the ability to get a mutable reference. |
1313
| `get_all` | Generates getters for all fields of the pyclass. |
1414
| `hash` | Implements `__hash__` using the `Hash` implementation of the underlying Rust datatype. |
15+
| `immutable_type` | Makes the type object immutable. Supported on 3.14+ with the `abi3` feature active, or 3.10+ otherwise. |
1516
| `mapping` | Inform PyO3 that this class is a [`Mapping`][params-mapping], and so leave its implementation of sequence C-API slots empty. |
1617
| <span style="white-space: pre">`module = "module_name"`</span> | Python code will see the class as being defined in this module. Defaults to `builtins`. |
1718
| <span style="white-space: pre">`name = "python_name"`</span> | Sets the name that Python sees this class as. Defaults to the name of the Rust struct. |

newsfragments/5101.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
added `immutable_type` pyclass option (on Python 3.14+ with `abi3`, or 3.10+ otherwise) for immutable type objects

pyo3-macros-backend/src/attributes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ pub mod kw {
2727
syn::custom_keyword!(hash);
2828
syn::custom_keyword!(into_py_with);
2929
syn::custom_keyword!(item);
30+
syn::custom_keyword!(immutable_type);
3031
syn::custom_keyword!(from_item_all);
3132
syn::custom_keyword!(mapping);
3233
syn::custom_keyword!(module);

pyo3-macros-backend/src/pyclass.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use crate::pymethod::{
2525
MethodAndSlotDef, PropertyType, SlotDef, __GETITEM__, __HASH__, __INT__, __LEN__, __REPR__,
2626
__RICHCMP__, __STR__,
2727
};
28-
use crate::pyversions::is_abi3_before;
28+
use crate::pyversions::{is_abi3_before, is_py_before};
2929
use crate::utils::{self, apply_renaming_rule, Ctx, LitCStr, PythonDoc};
3030
use crate::PyFunctionOptions;
3131

@@ -71,6 +71,7 @@ pub struct PyClassPyO3Options {
7171
pub freelist: Option<FreelistAttribute>,
7272
pub frozen: Option<kw::frozen>,
7373
pub hash: Option<kw::hash>,
74+
pub immutable_type: Option<kw::immutable_type>,
7475
pub mapping: Option<kw::mapping>,
7576
pub module: Option<ModuleAttribute>,
7677
pub name: Option<NameAttribute>,
@@ -94,6 +95,7 @@ pub enum PyClassPyO3Option {
9495
Frozen(kw::frozen),
9596
GetAll(kw::get_all),
9697
Hash(kw::hash),
98+
ImmutableType(kw::immutable_type),
9799
Mapping(kw::mapping),
98100
Module(ModuleAttribute),
99101
Name(NameAttribute),
@@ -128,6 +130,8 @@ impl Parse for PyClassPyO3Option {
128130
input.parse().map(PyClassPyO3Option::GetAll)
129131
} else if lookahead.peek(attributes::kw::hash) {
130132
input.parse().map(PyClassPyO3Option::Hash)
133+
} else if lookahead.peek(attributes::kw::immutable_type) {
134+
input.parse().map(PyClassPyO3Option::ImmutableType)
131135
} else if lookahead.peek(attributes::kw::mapping) {
132136
input.parse().map(PyClassPyO3Option::Mapping)
133137
} else if lookahead.peek(attributes::kw::module) {
@@ -203,6 +207,13 @@ impl PyClassPyO3Options {
203207
PyClassPyO3Option::Freelist(freelist) => set_option!(freelist),
204208
PyClassPyO3Option::Frozen(frozen) => set_option!(frozen),
205209
PyClassPyO3Option::GetAll(get_all) => set_option!(get_all),
210+
PyClassPyO3Option::ImmutableType(immutable_type) => {
211+
ensure_spanned!(
212+
!(is_py_before(3, 10) || is_abi3_before(3, 14)),
213+
immutable_type.span() => "`immutable_type` requires Python >= 3.10 or >= 3.14 (ABI3)"
214+
);
215+
set_option!(immutable_type)
216+
}
206217
PyClassPyO3Option::Hash(hash) => set_option!(hash),
207218
PyClassPyO3Option::Mapping(mapping) => set_option!(mapping),
208219
PyClassPyO3Option::Module(module) => set_option!(module),
@@ -2145,6 +2156,7 @@ impl<'a> PyClassImplsBuilder<'a> {
21452156
let is_subclass = self.attr.options.extends.is_some();
21462157
let is_mapping: bool = self.attr.options.mapping.is_some();
21472158
let is_sequence: bool = self.attr.options.sequence.is_some();
2159+
let is_immutable_type = self.attr.options.immutable_type.is_some();
21482160

21492161
ensure_spanned!(
21502162
!(is_mapping && is_sequence),
@@ -2278,6 +2290,7 @@ impl<'a> PyClassImplsBuilder<'a> {
22782290
const IS_SUBCLASS: bool = #is_subclass;
22792291
const IS_MAPPING: bool = #is_mapping;
22802292
const IS_SEQUENCE: bool = #is_sequence;
2293+
const IS_IMMUTABLE_TYPE: bool = #is_immutable_type;
22812294

22822295
type BaseType = #base;
22832296
type ThreadChecker = #thread_checker;

pyo3-macros-backend/src/pyversions.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,10 @@ use pyo3_build_config::PythonVersion;
22

33
pub fn is_abi3_before(major: u8, minor: u8) -> bool {
44
let config = pyo3_build_config::get();
5-
config.abi3 && config.version < PythonVersion { major, minor }
5+
config.abi3 && !config.is_free_threaded() && config.version < PythonVersion { major, minor }
6+
}
7+
8+
pub fn is_py_before(major: u8, minor: u8) -> bool {
9+
let config = pyo3_build_config::get();
10+
config.version < PythonVersion { major, minor }
611
}

src/impl_/pyclass.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,9 @@ pub trait PyClassImpl: Sized + 'static {
176176
/// #[pyclass(sequence)]
177177
const IS_SEQUENCE: bool = false;
178178

179+
/// #[pyclass(immutable_type)]
180+
const IS_IMMUTABLE_TYPE: bool = false;
181+
179182
/// Base class
180183
type BaseType: PyTypeInfo + PyClassBaseType;
181184

src/impl_/pyclass/lazy_type_object.rs

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ use std::{
44
thread::{self, ThreadId},
55
};
66

7+
#[cfg(Py_3_14)]
8+
use crate::err::error_on_minusone;
9+
#[cfg(Py_3_14)]
10+
use crate::types::PyTypeMethods;
711
use crate::{
812
exceptions::PyRuntimeError,
913
ffi,
@@ -75,12 +79,13 @@ impl LazyTypeObjectInner {
7579
items_iter: PyClassItemsIter,
7680
) -> PyResult<&Bound<'py, PyType>> {
7781
(|| -> PyResult<_> {
78-
let type_object = self
79-
.value
80-
.get_or_try_init(py, || init(py))?
81-
.type_object
82-
.bind(py);
83-
self.ensure_init(type_object, name, items_iter)?;
82+
let PyClassTypeObject {
83+
type_object,
84+
is_immutable_type,
85+
..
86+
} = self.value.get_or_try_init(py, || init(py))?;
87+
let type_object = type_object.bind(py);
88+
self.ensure_init(type_object, *is_immutable_type, name, items_iter)?;
8489
Ok(type_object)
8590
})()
8691
.map_err(|err| {
@@ -95,6 +100,7 @@ impl LazyTypeObjectInner {
95100
fn ensure_init(
96101
&self,
97102
type_object: &Bound<'_, PyType>,
103+
#[allow(unused_variables)] is_immutable_type: bool,
98104
name: &str,
99105
items_iter: PyClassItemsIter,
100106
) -> PyResult<()> {
@@ -181,6 +187,28 @@ impl LazyTypeObjectInner {
181187
// return from the function.
182188
let result = self.tp_dict_filled.get_or_try_init(py, move || {
183189
let result = initialize_tp_dict(py, type_object.as_ptr(), items);
190+
#[cfg(Py_3_14)]
191+
if is_immutable_type {
192+
// freeze immutable types after __dict__ is initialized
193+
let res = unsafe { ffi::PyType_Freeze(type_object.as_type_ptr()) };
194+
error_on_minusone(py, res)?;
195+
}
196+
#[cfg(all(Py_3_10, not(Py_LIMITED_API), not(Py_3_14)))]
197+
if is_immutable_type {
198+
use crate::types::PyTypeMethods as _;
199+
#[cfg(not(Py_GIL_DISABLED))]
200+
unsafe {
201+
(*type_object.as_type_ptr()).tp_flags |= ffi::Py_TPFLAGS_IMMUTABLETYPE
202+
};
203+
#[cfg(Py_GIL_DISABLED)]
204+
unsafe {
205+
(*type_object.as_type_ptr()).tp_flags.fetch_or(
206+
ffi::Py_TPFLAGS_IMMUTABLETYPE,
207+
std::sync::atomic::Ordering::Relaxed,
208+
)
209+
};
210+
unsafe { ffi::PyType_Modified(type_object.as_type_ptr()) };
211+
}
184212

185213
// Initialization successfully complete, can clear the thread list.
186214
// (No further calls to get_or_init() will try to init, on any thread.)

src/pyclass/create_type_object.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use std::{
2323

2424
pub(crate) struct PyClassTypeObject {
2525
pub type_object: Py<PyType>,
26+
pub is_immutable_type: bool,
2627
#[allow(dead_code)] // This is purely a cache that must live as long as the type object
2728
getset_destructors: Vec<GetSetDefDestructor>,
2829
}
@@ -40,6 +41,7 @@ where
4041
dealloc_with_gc: unsafe extern "C" fn(*mut ffi::PyObject),
4142
is_mapping: bool,
4243
is_sequence: bool,
44+
is_immutable_type: bool,
4345
doc: &'static CStr,
4446
dict_offset: Option<ffi::Py_ssize_t>,
4547
weaklist_offset: Option<ffi::Py_ssize_t>,
@@ -61,6 +63,7 @@ where
6163
tp_dealloc_with_gc: dealloc_with_gc,
6264
is_mapping,
6365
is_sequence,
66+
is_immutable_type,
6467
has_new: false,
6568
has_dealloc: false,
6669
has_getitem: false,
@@ -88,6 +91,7 @@ where
8891
tp_dealloc_with_gc::<T>,
8992
T::IS_MAPPING,
9093
T::IS_SEQUENCE,
94+
T::IS_IMMUTABLE_TYPE,
9195
T::doc(py)?,
9296
T::dict_offset(),
9397
T::weaklist_offset(),
@@ -116,6 +120,7 @@ struct PyTypeBuilder {
116120
tp_dealloc_with_gc: ffi::destructor,
117121
is_mapping: bool,
118122
is_sequence: bool,
123+
is_immutable_type: bool,
119124
has_new: bool,
120125
has_dealloc: bool,
121126
has_getitem: bool,
@@ -505,6 +510,7 @@ impl PyTypeBuilder {
505510

506511
Ok(PyClassTypeObject {
507512
type_object,
513+
is_immutable_type: self.is_immutable_type,
508514
getset_destructors,
509515
})
510516
}

tests/test_class_attributes.rs

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#![cfg(feature = "macros")]
22

33
use pyo3::prelude::*;
4+
use pyo3::py_run;
45

56
#[path = "../src/tests/common.rs"]
67
mod common;
@@ -66,14 +67,68 @@ fn class_attributes() {
6667
});
6768
}
6869

69-
// Ignored because heap types are not immutable:
70-
// https://github.com/python/cpython/blob/master/Objects/typeobject.c#L3399-L3409
7170
#[test]
72-
#[ignore]
73-
fn class_attributes_are_immutable() {
71+
fn class_attributes_mutable() {
72+
#[pyclass]
73+
struct Foo {}
74+
75+
#[pymethods]
76+
impl Foo {
77+
#[classattr]
78+
const MY_CONST: &'static str = "foobar";
79+
80+
#[classattr]
81+
fn a() -> i32 {
82+
5
83+
}
84+
}
85+
7486
Python::with_gil(|py| {
75-
let foo_obj = py.get_type::<Foo>();
76-
py_expect_exception!(py, foo_obj, "foo_obj.a = 6", PyTypeError);
87+
let obj = py.get_type::<Foo>();
88+
py_run!(py, obj, "obj.MY_CONST = 'BAZ'");
89+
py_run!(py, obj, "obj.a = 42");
90+
py_assert!(py, obj, "obj.MY_CONST == 'BAZ'");
91+
py_assert!(py, obj, "obj.a == 42");
92+
});
93+
}
94+
95+
#[test]
96+
#[cfg(any(Py_3_14, all(Py_3_10, not(Py_LIMITED_API))))]
97+
fn immutable_type_object() {
98+
#[pyclass(immutable_type)]
99+
struct ImmutableType {}
100+
101+
#[pymethods]
102+
impl ImmutableType {
103+
#[classattr]
104+
const MY_CONST: &'static str = "foobar";
105+
106+
#[classattr]
107+
fn a() -> i32 {
108+
5
109+
}
110+
}
111+
112+
#[pyclass(immutable_type)]
113+
enum SimpleImmutable {
114+
Variant = 42,
115+
}
116+
117+
#[pyclass(immutable_type)]
118+
enum ComplexImmutable {
119+
Variant(u32),
120+
}
121+
122+
Python::with_gil(|py| {
123+
let obj = py.get_type::<ImmutableType>();
124+
py_expect_exception!(py, obj, "obj.MY_CONST = 'FOOBAR'", PyTypeError);
125+
py_expect_exception!(py, obj, "obj.a = 6", PyTypeError);
126+
127+
let obj = py.get_type::<SimpleImmutable>();
128+
py_expect_exception!(py, obj, "obj.Variant = 0", PyTypeError);
129+
130+
let obj = py.get_type::<ComplexImmutable>();
131+
py_expect_exception!(py, obj, "obj.Variant = 0", PyTypeError);
77132
});
78133
}
79134

tests/test_compile_error.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ fn test_compile_errors() {
7171
t.compile_fail("tests/ui/duplicate_pymodule_submodule.rs");
7272
#[cfg(all(not(Py_LIMITED_API), Py_3_11))]
7373
t.compile_fail("tests/ui/invalid_base_class.rs");
74+
#[cfg(any(not(Py_3_10), all(not(Py_3_14), Py_LIMITED_API)))]
75+
t.compile_fail("tests/ui/immutable_type.rs");
7476
t.pass("tests/ui/ambiguous_associated_items.rs");
7577
t.pass("tests/ui/pyclass_probe.rs");
7678
}

0 commit comments

Comments
 (0)