Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

pyclass: mapping flag #2265

Merged
merged 2 commits into from Apr 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Added `as_bytes` on `Py<PyBytes>`. [#2235](https://github.com/PyO3/pyo3/pull/2235)
- Add `#[pyclass(mapping)]` option to leave sequence slots empty in container implementations. [#2265](https://github.com/PyO3/pyo3/pull/2265)

### Packaging

Expand Down
1 change: 1 addition & 0 deletions guide/src/class.md
Expand Up @@ -203,6 +203,7 @@ Python::with_gil(|py|{
[params-4]: https://doc.rust-lang.org/stable/std/rc/struct.Rc.html
[params-5]: https://doc.rust-lang.org/stable/std/sync/struct.Rc.html
[params-6]: https://docs.python.org/3/library/weakref.html
[params-mapping]: ./class/protocols.md#mapping--sequence-types

These parameters are covered in various sections of this guide.

Expand Down
14 changes: 13 additions & 1 deletion guide/src/class/protocols.md
Expand Up @@ -192,6 +192,18 @@ and `Return` a final value - see its docs for further details and an example.

### Mapping & Sequence types

The magic methods in this section can be used to implement Python container types. They are two main categories of container in Python: "mappings" such as `dict`, with arbitrary keys, and "sequences" such as `list` and `tuple`, with integer keys.

The Python C-API which PyO3 is built upon has separate "slots" for sequences and mappings. When writing a `class` in pure Python, there is no such distinction in the implementation - a `__getitem__` implementation will fill the slots for both the mapping and sequence forms, for example.

By default PyO3 reproduces the Python behaviour of filling both mapping and sequence slots. This makes sense for the "simple" case which matches Python, and also for sequences, where the mapping slot is used anyway to implement slice indexing.

Mapping types usually will not want the sequence slots filled. Having them filled will lead to outcomes which may be unwanted, such as:
- The mapping type will successfully cast to [`PySequence`]. This may lead to consumers of the type handling it incorrectly.
- Python provides a default implementation of `__iter__` for sequences, which calls `__getitem__` with consecutive positive integers starting from 0 until an `IndexError` is returned. Unless the mapping only contains consecutive positive integer keys, this `__iter__` implementation will likely not be the intended behavior.

Use the `#[pyclass(mapping)]` annotation to instruct PyO3 to only fill the mapping slots, leaving the sequence ones empty. This will apply to `__getitem__`, `__setitem__`, and `__delitem__`.

- `__len__(<self>) -> usize`

Implements the built-in function `len()` for the sequence.
Expand Down Expand Up @@ -265,7 +277,6 @@ and `Return` a final value - see its docs for further details and an example.
Used by the `*=` operator, after trying the numeric multiplication via
the `__imul__` method.


### Descriptors

- `__get__(<self>, object, object) -> object`
Expand Down Expand Up @@ -599,3 +610,4 @@ For details, look at the `#[pymethods]` regarding GC methods.
[`PyObjectProtocol`]: {{#PYO3_DOCS_URL}}/pyo3/class/basic/trait.PyObjectProtocol.html
[`PySequenceProtocol`]: {{#PYO3_DOCS_URL}}/pyo3/class/sequence/trait.PySequenceProtocol.html
[`PyIterProtocol`]: {{#PYO3_DOCS_URL}}/pyo3/class/iter/trait.PyIterProtocol.html
[`PySequence`]: {{#PYO3_DOCS_URL}}/pyo3/types/struct.PySequence.html
6 changes: 6 additions & 0 deletions pyo3-ffi/src/object.rs
Expand Up @@ -337,6 +337,12 @@ extern "C" {
// Flag bits for printing:
pub const Py_PRINT_RAW: c_int = 1; // No string quotes etc.

#[cfg(all(Py_3_10, not(Py_LIMITED_API)))]
pub const Py_TPFLAGS_SEQUENCE: c_ulong = 1 << 5;

#[cfg(all(Py_3_10, not(Py_LIMITED_API)))]
pub const Py_TPFLAGS_MAPPING: c_ulong = 1 << 6;

#[cfg(Py_3_10)]
pub const Py_TPFLAGS_DISALLOW_INSTANTIATION: c_ulong = 1 << 7;

Expand Down
1 change: 1 addition & 0 deletions pyo3-macros-backend/src/attributes.rs
Expand Up @@ -18,6 +18,7 @@ pub mod kw {
syn::custom_keyword!(gc);
syn::custom_keyword!(get);
syn::custom_keyword!(item);
syn::custom_keyword!(mapping);
syn::custom_keyword!(module);
syn::custom_keyword!(name);
syn::custom_keyword!(pass_module);
Expand Down
7 changes: 7 additions & 0 deletions pyo3-macros-backend/src/pyclass.rs
Expand Up @@ -54,6 +54,7 @@ pub struct PyClassPyO3Options {
pub dict: Option<kw::dict>,
pub extends: Option<ExtendsAttribute>,
pub freelist: Option<FreelistAttribute>,
pub mapping: Option<kw::mapping>,
pub module: Option<ModuleAttribute>,
pub name: Option<NameAttribute>,
pub subclass: Option<kw::subclass>,
Expand All @@ -69,6 +70,7 @@ enum PyClassPyO3Option {
Dict(kw::dict),
Extends(ExtendsAttribute),
Freelist(FreelistAttribute),
Mapping(kw::mapping),
Module(ModuleAttribute),
Name(NameAttribute),
Subclass(kw::subclass),
Expand All @@ -90,6 +92,8 @@ impl Parse for PyClassPyO3Option {
input.parse().map(PyClassPyO3Option::Extends)
} else if lookahead.peek(attributes::kw::freelist) {
input.parse().map(PyClassPyO3Option::Freelist)
} else if lookahead.peek(attributes::kw::mapping) {
input.parse().map(PyClassPyO3Option::Mapping)
} else if lookahead.peek(attributes::kw::module) {
input.parse().map(PyClassPyO3Option::Module)
} else if lookahead.peek(kw::name) {
Expand Down Expand Up @@ -145,6 +149,7 @@ impl PyClassPyO3Options {
PyClassPyO3Option::Dict(dict) => set_option!(dict),
PyClassPyO3Option::Extends(extends) => set_option!(extends),
PyClassPyO3Option::Freelist(freelist) => set_option!(freelist),
PyClassPyO3Option::Mapping(mapping) => set_option!(mapping),
PyClassPyO3Option::Module(module) => set_option!(module),
PyClassPyO3Option::Name(name) => set_option!(name),
PyClassPyO3Option::Subclass(subclass) => set_option!(subclass),
Expand Down Expand Up @@ -749,6 +754,7 @@ impl<'a> PyClassImplsBuilder<'a> {
.map(|extends_attr| extends_attr.value.clone())
.unwrap_or_else(|| parse_quote! { _pyo3::PyAny });
let is_subclass = self.attr.options.extends.is_some();
let is_mapping: bool = self.attr.options.mapping.is_some();

let dict_offset = if self.attr.options.dict.is_some() {
quote! {
Expand Down Expand Up @@ -830,6 +836,7 @@ impl<'a> PyClassImplsBuilder<'a> {
const DOC: &'static str = #doc;
const IS_BASETYPE: bool = #is_basetype;
const IS_SUBCLASS: bool = #is_subclass;
const IS_MAPPING: bool = #is_mapping;

type Layout = _pyo3::PyCell<Self>;
type BaseType = #base;
Expand Down
1 change: 1 addition & 0 deletions pyo3-macros/docs/pyclass_parameters.md
Expand Up @@ -6,6 +6,7 @@
| `dict` | Gives instances of this class an empty `__dict__` to store custom attributes. |
| <span style="white-space: pre">`extends = BaseType`</span> | Use a custom baseclass. Defaults to [`PyAny`][params-1] |
| <span style="white-space: pre">`freelist = N`</span> | Implements a [free list][params-2] of size N. This can improve performance for types that are often created and deleted in quick succession. Profile your code to see whether `freelist` is right for you. |
| `mapping` | Inform PyO3 that this class is a [`Mapping`][params-mapping], and so leave its implementation of sequence C-API slots empty. |
| <span style="white-space: pre">`module = "module_name"`</span> | Python code will see the class as being defined in this module. Defaults to `builtins`. |
| <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. |
| <span style="white-space: pre">`text_signature = "(arg1, arg2, ...)"`</span> | Sets the text signature for the Python class' `__new__` method. |
Expand Down
1 change: 1 addition & 0 deletions pyo3-macros/src/lib.rs
Expand Up @@ -93,6 +93,7 @@ pub fn pyproto(_: TokenStream, input: TokenStream) -> TokenStream {
/// [params-4]: std::rc::Rc
/// [params-5]: std::sync::Arc
/// [params-6]: https://docs.python.org/3/library/weakref.html
/// [params-mapping]: https://pyo3.rs/latest/class/protocols.html#mapping--sequence-types
#[proc_macro_attribute]
pub fn pyclass(attr: TokenStream, input: TokenStream) -> TokenStream {
use syn::Item;
Expand Down
3 changes: 3 additions & 0 deletions src/impl_/pyclass.rs
Expand Up @@ -155,6 +155,9 @@ pub trait PyClassImpl: Sized {
/// #[pyclass(extends=...)]
const IS_SUBCLASS: bool = false;

/// #[pyclass(mapping)]
const IS_MAPPING: bool = false;

/// Layout
type Layout: PyLayout<Self>;

Expand Down
40 changes: 23 additions & 17 deletions src/pyclass.rs
Expand Up @@ -55,6 +55,7 @@ where
T::weaklist_offset(),
&T::for_all_items,
T::IS_BASETYPE,
T::IS_MAPPING,
)
} {
Ok(type_object) => type_object,
Expand All @@ -75,6 +76,7 @@ unsafe fn create_type_object_impl(
weaklist_offset: Option<ffi::Py_ssize_t>,
for_all_items: &dyn Fn(&mut dyn FnMut(&PyClassItems)),
is_basetype: bool,
is_mapping: bool,
) -> PyResult<*mut ffi::PyTypeObject> {
let mut slots = Vec::new();

Expand Down Expand Up @@ -147,26 +149,30 @@ unsafe fn create_type_object_impl(
slots.extend_from_slice(items.slots);
});

// If mapping methods implemented, define sequence methods get implemented too.
// CPython does the same for Python `class` statements.
if !is_mapping {
// If mapping methods implemented, define sequence methods get implemented too.
// CPython does the same for Python `class` statements.

// NB we don't implement sq_length to avoid annoying CPython behaviour of automatically adding
// the length to negative indices.
// NB we don't implement sq_length to avoid annoying CPython behaviour of automatically adding
// the length to negative indices.

if has_getitem {
push_slot(
&mut slots,
ffi::Py_sq_item,
get_sequence_item_from_mapping as _,
);
}
// Don't add these methods for "pure" mappings.

if has_setitem {
push_slot(
&mut slots,
ffi::Py_sq_ass_item,
assign_sequence_item_from_mapping as _,
);
if has_getitem {
push_slot(
&mut slots,
ffi::Py_sq_item,
get_sequence_item_from_mapping as _,
);
}

if has_setitem {
push_slot(
&mut slots,
ffi::Py_sq_ass_item,
assign_sequence_item_from_mapping as _,
);
}
}

if !has_new {
Expand Down
23 changes: 22 additions & 1 deletion tests/test_mapping.rs
Expand Up @@ -7,10 +7,12 @@ use pyo3::prelude::*;
use pyo3::py_run;
use pyo3::types::IntoPyDict;
use pyo3::types::PyList;
use pyo3::types::PyMapping;
use pyo3::types::PySequence;

mod common;

#[pyclass]
#[pyclass(mapping)]
struct Mapping {
index: HashMap<String, usize>,
}
Expand Down Expand Up @@ -55,6 +57,13 @@ impl Mapping {
Ok(())
}
}

fn get(&self, py: Python<'_>, key: &str, default: Option<PyObject>) -> Option<PyObject> {
self.index
.get(key)
.map(|value| value.into_py(py))
.or(default)
}
}

/// Return a dict with `m = Mapping(['1', '2', '3'])`.
Expand Down Expand Up @@ -103,3 +112,15 @@ fn test_delitem() {
py_expect_exception!(py, *d, "del m[-1]", PyTypeError);
py_expect_exception!(py, *d, "del m['4']", PyKeyError);
}

#[test]
fn mapping_is_not_sequence() {
Python::with_gil(|py| {
let mut index = HashMap::new();
index.insert("Foo".into(), 1);
index.insert("Bar".into(), 2);
let m = Py::new(py, Mapping { index }).unwrap();
assert!(m.as_ref(py).downcast::<PyMapping>().is_ok());
assert!(m.as_ref(py).downcast::<PySequence>().is_err());
});
}
4 changes: 2 additions & 2 deletions tests/ui/invalid_pyclass_args.stderr
@@ -1,4 +1,4 @@
error: expected one of: `crate`, `dict`, `extends`, `freelist`, `module`, `name`, `subclass`, `text_signature`, `unsendable`, `weakref`, `gc`
error: expected one of: `crate`, `dict`, `extends`, `freelist`, `mapping`, `module`, `name`, `subclass`, `text_signature`, `unsendable`, `weakref`, `gc`
--> tests/ui/invalid_pyclass_args.rs:3:11
|
3 | #[pyclass(extend=pyo3::types::PyDict)]
Expand Down Expand Up @@ -34,7 +34,7 @@ error: expected string literal
18 | #[pyclass(module = my_module)]
| ^^^^^^^^^

error: expected one of: `crate`, `dict`, `extends`, `freelist`, `module`, `name`, `subclass`, `text_signature`, `unsendable`, `weakref`, `gc`
error: expected one of: `crate`, `dict`, `extends`, `freelist`, `mapping`, `module`, `name`, `subclass`, `text_signature`, `unsendable`, `weakref`, `gc`
--> tests/ui/invalid_pyclass_args.rs:21:11
|
21 | #[pyclass(weakrev)]
Expand Down