add pyclass `hash` option (#4206)

* add pyclass `hash` option

* add newsfragment

* require `frozen` option for `hash`

* simplify `hash` without `frozen` error message

Co-authored-by: David Hewitt <mail@davidhewitt.dev>

* require `eq` for `hash`

* prevent manual `__hash__` with `#pyo3(hash)`

* combine error messages

---------

Co-authored-by: David Hewitt <mail@davidhewitt.dev>
This commit is contained in:
Icxolu 2024-06-01 16:20:20 +02:00 committed by GitHub
parent 25c1db4767
commit a7a5c10b8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 328 additions and 5 deletions

View File

@ -11,6 +11,7 @@
| <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. |
| <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. |
| `get_all` | Generates getters for all fields of the pyclass. |
| `hash` | Implements `__hash__` using the `Hash` implementation of the underlying Rust datatype. |
| `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. |

View File

@ -121,6 +121,19 @@ impl Number {
}
}
```
To implement `__hash__` using the Rust [`Hash`] trait implementation, the `hash` option can be used.
This option is only available for `frozen` classes to prevent accidental hash changes from mutating the object. If you need
an `__hash__` implementation for a mutable class, use the manual method from above. This option also requires `eq`: According to the
[Python docs](https://docs.python.org/3/reference/datamodel.html#object.__hash__) "If a class does not define an `__eq__()`
method it should not define a `__hash__()` operation either"
```rust
# use pyo3::prelude::*;
#
#[pyclass(frozen, eq, hash)]
#[derive(PartialEq, Hash)]
struct Number(i32);
```
> **Note**: When implementing `__hash__` and comparisons, it is important that the following property holds:
>

View File

@ -0,0 +1 @@
Added `#[pyclass(hash)]` option to implement `__hash__` in terms of the `Hash` implementation

View File

@ -22,6 +22,7 @@ pub mod kw {
syn::custom_keyword!(frozen);
syn::custom_keyword!(get);
syn::custom_keyword!(get_all);
syn::custom_keyword!(hash);
syn::custom_keyword!(item);
syn::custom_keyword!(from_item_all);
syn::custom_keyword!(mapping);

View File

@ -12,7 +12,7 @@ use crate::pyfunction::ConstructorAttribute;
use crate::pyimpl::{gen_py_const, PyClassMethodsType};
use crate::pymethod::{
impl_py_getter_def, impl_py_setter_def, MethodAndMethodDef, MethodAndSlotDef, PropertyType,
SlotDef, __GETITEM__, __INT__, __LEN__, __REPR__, __RICHCMP__,
SlotDef, __GETITEM__, __HASH__, __INT__, __LEN__, __REPR__, __RICHCMP__,
};
use crate::utils::Ctx;
use crate::utils::{self, apply_renaming_rule, PythonDoc};
@ -21,6 +21,7 @@ use proc_macro2::{Ident, Span, TokenStream};
use quote::{format_ident, quote, quote_spanned};
use syn::ext::IdentExt;
use syn::parse::{Parse, ParseStream};
use syn::parse_quote_spanned;
use syn::punctuated::Punctuated;
use syn::{parse_quote, spanned::Spanned, Result, Token};
@ -65,6 +66,7 @@ pub struct PyClassPyO3Options {
pub get_all: Option<kw::get_all>,
pub freelist: Option<FreelistAttribute>,
pub frozen: Option<kw::frozen>,
pub hash: Option<kw::hash>,
pub mapping: Option<kw::mapping>,
pub module: Option<ModuleAttribute>,
pub name: Option<NameAttribute>,
@ -85,6 +87,7 @@ enum PyClassPyO3Option {
Freelist(FreelistAttribute),
Frozen(kw::frozen),
GetAll(kw::get_all),
Hash(kw::hash),
Mapping(kw::mapping),
Module(ModuleAttribute),
Name(NameAttribute),
@ -115,6 +118,8 @@ impl Parse for PyClassPyO3Option {
input.parse().map(PyClassPyO3Option::Frozen)
} else if lookahead.peek(attributes::kw::get_all) {
input.parse().map(PyClassPyO3Option::GetAll)
} else if lookahead.peek(attributes::kw::hash) {
input.parse().map(PyClassPyO3Option::Hash)
} else if lookahead.peek(attributes::kw::mapping) {
input.parse().map(PyClassPyO3Option::Mapping)
} else if lookahead.peek(attributes::kw::module) {
@ -180,6 +185,7 @@ impl PyClassPyO3Options {
PyClassPyO3Option::Freelist(freelist) => set_option!(freelist),
PyClassPyO3Option::Frozen(frozen) => set_option!(frozen),
PyClassPyO3Option::GetAll(get_all) => set_option!(get_all),
PyClassPyO3Option::Hash(hash) => set_option!(hash),
PyClassPyO3Option::Mapping(mapping) => set_option!(mapping),
PyClassPyO3Option::Module(module) => set_option!(module),
PyClassPyO3Option::Name(name) => set_option!(name),
@ -363,8 +369,12 @@ fn impl_class(
let (default_richcmp, default_richcmp_slot) =
pyclass_richcmp(&args.options, &syn::parse_quote!(#cls), ctx)?;
let (default_hash, default_hash_slot) =
pyclass_hash(&args.options, &syn::parse_quote!(#cls), ctx)?;
let mut slots = Vec::new();
slots.extend(default_richcmp_slot);
slots.extend(default_hash_slot);
let py_class_impl = PyClassImplsBuilder::new(
cls,
@ -393,6 +403,7 @@ fn impl_class(
#[allow(non_snake_case)]
impl #cls {
#default_richcmp
#default_hash
}
})
}
@ -798,9 +809,11 @@ fn impl_simple_enum(
let (default_richcmp, default_richcmp_slot) =
pyclass_richcmp_simple_enum(&args.options, &ty, repr_type, ctx);
let (default_hash, default_hash_slot) = pyclass_hash(&args.options, &ty, ctx)?;
let mut default_slots = vec![default_repr_slot, default_int_slot];
default_slots.extend(default_richcmp_slot);
default_slots.extend(default_hash_slot);
let pyclass_impls = PyClassImplsBuilder::new(
cls,
@ -827,6 +840,7 @@ fn impl_simple_enum(
#default_repr
#default_int
#default_richcmp
#default_hash
}
})
}
@ -858,9 +872,11 @@ fn impl_complex_enum(
let pytypeinfo = impl_pytypeinfo(cls, &args, None, ctx);
let (default_richcmp, default_richcmp_slot) = pyclass_richcmp(&args.options, &ty, ctx)?;
let (default_hash, default_hash_slot) = pyclass_hash(&args.options, &ty, ctx)?;
let mut default_slots = vec![];
default_slots.extend(default_richcmp_slot);
default_slots.extend(default_hash_slot);
let impl_builder = PyClassImplsBuilder::new(
cls,
@ -967,6 +983,7 @@ fn impl_complex_enum(
#[allow(non_snake_case)]
impl #cls {
#default_richcmp
#default_hash
}
#(#variant_cls_zsts)*
@ -1783,6 +1800,35 @@ fn pyclass_richcmp(
}
}
fn pyclass_hash(
options: &PyClassPyO3Options,
cls: &syn::Type,
ctx: &Ctx,
) -> Result<(Option<syn::ImplItemFn>, Option<MethodAndSlotDef>)> {
if options.hash.is_some() {
ensure_spanned!(
options.frozen.is_some(), options.hash.span() => "The `hash` option requires the `frozen` option.";
options.eq.is_some(), options.hash.span() => "The `hash` option requires the `eq` option.";
);
}
// FIXME: Use hash.map(...).unzip() on MSRV >= 1.66
match options.hash {
Some(opt) => {
let mut hash_impl = parse_quote_spanned! { opt.span() =>
fn __pyo3__generated____hash__(&self) -> u64 {
let mut s = ::std::collections::hash_map::DefaultHasher::new();
::std::hash::Hash::hash(self, &mut s);
::std::hash::Hasher::finish(&s)
}
};
let hash_slot =
generate_protocol_slot(cls, &mut hash_impl, &__HASH__, "__hash__", ctx).unwrap();
Ok((Some(hash_impl), Some(hash_slot)))
}
None => Ok((None, None)),
}
}
/// Implements most traits used by `#[pyclass]`.
///
/// Specifically, it implements traits that only depend on class name,

View File

@ -910,7 +910,7 @@ impl PropertyType<'_> {
const __STR__: SlotDef = SlotDef::new("Py_tp_str", "reprfunc");
pub const __REPR__: SlotDef = SlotDef::new("Py_tp_repr", "reprfunc");
const __HASH__: SlotDef = SlotDef::new("Py_tp_hash", "hashfunc")
pub const __HASH__: SlotDef = SlotDef::new("Py_tp_hash", "hashfunc")
.ret_ty(Ty::PyHashT)
.return_conversion(TokenGenerator(
|Ctx { pyo3_path }: &Ctx| quote! { #pyo3_path::callback::HashCallbackOutput },

View File

@ -25,7 +25,20 @@ macro_rules! ensure_spanned {
if !($condition) {
bail_spanned!($span => $msg);
}
};
($($condition:expr, $span:expr => $msg:expr;)*) => {
if let Some(e) = [$(
(!($condition)).then(|| err_spanned!($span => $msg)),
)*]
.into_iter()
.flatten()
.reduce(|mut acc, e| {
acc.combine(e);
acc
}) {
return Err(e);
}
};
}
/// Check if the given type `ty` is `pyo3::Python`.

View File

@ -200,6 +200,34 @@ fn class_with_object_field() {
});
}
#[pyclass(frozen, eq, hash)]
#[derive(PartialEq, Hash)]
struct ClassWithHash {
value: usize,
}
#[test]
fn class_with_hash() {
Python::with_gil(|py| {
use pyo3::types::IntoPyDict;
let class = ClassWithHash { value: 42 };
let hash = {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
class.hash(&mut hasher);
hasher.finish() as isize
};
let env = [
("obj", Py::new(py, class).unwrap().into_any()),
("hsh", hash.into_py(py)),
]
.into_py_dict_bound(py);
py_assert!(py, *env, "hash(obj) == hsh");
});
}
#[pyclass(unsendable, subclass)]
struct UnsendableBase {
value: std::rc::Rc<usize>,

View File

@ -220,3 +220,63 @@ fn test_renaming_all_enum_variants() {
);
});
}
#[pyclass(frozen, eq, eq_int, hash)]
#[derive(PartialEq, Hash)]
enum SimpleEnumWithHash {
A,
B,
}
#[test]
fn test_simple_enum_with_hash() {
Python::with_gil(|py| {
use pyo3::types::IntoPyDict;
let class = SimpleEnumWithHash::A;
let hash = {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
class.hash(&mut hasher);
hasher.finish() as isize
};
let env = [
("obj", Py::new(py, class).unwrap().into_any()),
("hsh", hash.into_py(py)),
]
.into_py_dict_bound(py);
py_assert!(py, *env, "hash(obj) == hsh");
});
}
#[pyclass(eq, hash)]
#[derive(PartialEq, Hash)]
enum ComplexEnumWithHash {
A(u32),
B { msg: String },
}
#[test]
fn test_complex_enum_with_hash() {
Python::with_gil(|py| {
use pyo3::types::IntoPyDict;
let class = ComplexEnumWithHash::B {
msg: String::from("Hello"),
};
let hash = {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
class.hash(&mut hasher);
hasher.finish() as isize
};
let env = [
("obj", Py::new(py, class).unwrap().into_any()),
("hsh", hash.into_py(py)),
]
.into_py_dict_bound(py);
py_assert!(py, *env, "hash(obj) == hsh");
});
}

View File

@ -52,4 +52,23 @@ impl EqOptAndManualRichCmp {
#[pyclass(eq_int)]
struct NoEqInt {}
#[pyclass(frozen, eq, hash)]
#[derive(PartialEq)]
struct HashOptRequiresHash;
#[pyclass(hash)]
#[derive(Hash)]
struct HashWithoutFrozenAndEq;
#[pyclass(frozen, eq, hash)]
#[derive(PartialEq, Hash)]
struct HashOptAndManualHash {}
#[pymethods]
impl HashOptAndManualHash {
fn __hash__(&self) -> u64 {
todo!()
}
}
fn main() {}

View File

@ -1,4 +1,4 @@
error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `mapping`, `module`, `name`, `rename_all`, `sequence`, `set_all`, `subclass`, `unsendable`, `weakref`
error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `hash`, `mapping`, `module`, `name`, `rename_all`, `sequence`, `set_all`, `subclass`, `unsendable`, `weakref`
--> tests/ui/invalid_pyclass_args.rs:3:11
|
3 | #[pyclass(extend=pyo3::types::PyDict)]
@ -46,7 +46,7 @@ error: expected string literal
24 | #[pyclass(module = my_module)]
| ^^^^^^^^^
error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `mapping`, `module`, `name`, `rename_all`, `sequence`, `set_all`, `subclass`, `unsendable`, `weakref`
error: expected one of: `crate`, `dict`, `eq`, `eq_int`, `extends`, `freelist`, `frozen`, `get_all`, `hash`, `mapping`, `module`, `name`, `rename_all`, `sequence`, `set_all`, `subclass`, `unsendable`, `weakref`
--> tests/ui/invalid_pyclass_args.rs:27:11
|
27 | #[pyclass(weakrev)]
@ -64,6 +64,18 @@ error: `eq_int` can only be used on simple enums.
52 | #[pyclass(eq_int)]
| ^^^^^^
error: The `hash` option requires the `frozen` option.
--> tests/ui/invalid_pyclass_args.rs:59:11
|
59 | #[pyclass(hash)]
| ^^^^
error: The `hash` option requires the `eq` option.
--> tests/ui/invalid_pyclass_args.rs:59:11
|
59 | #[pyclass(hash)]
| ^^^^
error[E0592]: duplicate definitions with name `__pymethod___richcmp____`
--> tests/ui/invalid_pyclass_args.rs:36:1
|
@ -75,6 +87,17 @@ error[E0592]: duplicate definitions with name `__pymethod___richcmp____`
|
= note: this error originates in the attribute macro `pyclass` (in Nightly builds, run with -Z macro-backtrace for more info)
error[E0592]: duplicate definitions with name `__pymethod___hash____`
--> tests/ui/invalid_pyclass_args.rs:63:1
|
63 | #[pyclass(frozen, eq, hash)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ duplicate definitions for `__pymethod___hash____`
...
67 | #[pymethods]
| ------------ other definition for `__pymethod___hash____`
|
= note: this error originates in the attribute macro `pyclass` (in Nightly builds, run with -Z macro-backtrace for more info)
error[E0369]: binary operation `==` cannot be applied to type `&EqOptRequiresEq`
--> tests/ui/invalid_pyclass_args.rs:33:11
|
@ -144,3 +167,51 @@ note: candidate #2 is defined in an impl for the type `EqOptAndManualRichCmp`
40 | #[pymethods]
| ^^^^^^^^^^^^
= note: this error originates in the attribute macro `pymethods` (in Nightly builds, run with -Z macro-backtrace for more info)
error[E0277]: the trait bound `HashOptRequiresHash: Hash` is not satisfied
--> tests/ui/invalid_pyclass_args.rs:55:23
|
55 | #[pyclass(frozen, eq, hash)]
| ^^^^ the trait `Hash` is not implemented for `HashOptRequiresHash`
|
help: consider annotating `HashOptRequiresHash` with `#[derive(Hash)]`
|
57 + #[derive(Hash)]
58 | struct HashOptRequiresHash;
|
error[E0034]: multiple applicable items in scope
--> tests/ui/invalid_pyclass_args.rs:63:1
|
63 | #[pyclass(frozen, eq, hash)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ multiple `__pymethod___hash____` found
|
note: candidate #1 is defined in an impl for the type `HashOptAndManualHash`
--> tests/ui/invalid_pyclass_args.rs:63:1
|
63 | #[pyclass(frozen, eq, hash)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
note: candidate #2 is defined in an impl for the type `HashOptAndManualHash`
--> tests/ui/invalid_pyclass_args.rs:67:1
|
67 | #[pymethods]
| ^^^^^^^^^^^^
= note: this error originates in the attribute macro `pyclass` which comes from the expansion of the attribute macro `pymethods` (in Nightly builds, run with -Z macro-backtrace for more info)
error[E0034]: multiple applicable items in scope
--> tests/ui/invalid_pyclass_args.rs:67:1
|
67 | #[pymethods]
| ^^^^^^^^^^^^ multiple `__pymethod___hash____` found
|
note: candidate #1 is defined in an impl for the type `HashOptAndManualHash`
--> tests/ui/invalid_pyclass_args.rs:63:1
|
63 | #[pyclass(frozen, eq, hash)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
note: candidate #2 is defined in an impl for the type `HashOptAndManualHash`
--> tests/ui/invalid_pyclass_args.rs:67:1
|
67 | #[pymethods]
| ^^^^^^^^^^^^
= note: this error originates in the attribute macro `pymethods` (in Nightly builds, run with -Z macro-backtrace for more info)

View File

@ -46,4 +46,32 @@ enum NoEqInt {
B { msg: String },
}
#[pyclass(frozen, eq, eq_int, hash)]
#[derive(PartialEq)]
enum SimpleHashOptRequiresHash {
A,
B,
}
#[pyclass(frozen, eq, hash)]
#[derive(PartialEq)]
enum ComplexHashOptRequiresHash {
A(i32),
B { msg: String },
}
#[pyclass(hash)]
#[derive(Hash)]
enum SimpleHashOptRequiresFrozenAndEq {
A,
B,
}
#[pyclass(hash)]
#[derive(Hash)]
enum ComplexHashOptRequiresEq {
A(i32),
B { msg: String },
}
fn main() {}

View File

@ -36,6 +36,24 @@ error: `eq_int` can only be used on simple enums.
43 | #[pyclass(eq_int)]
| ^^^^^^
error: The `hash` option requires the `frozen` option.
--> tests/ui/invalid_pyclass_enum.rs:63:11
|
63 | #[pyclass(hash)]
| ^^^^
error: The `hash` option requires the `eq` option.
--> tests/ui/invalid_pyclass_enum.rs:63:11
|
63 | #[pyclass(hash)]
| ^^^^
error: The `hash` option requires the `eq` option.
--> tests/ui/invalid_pyclass_enum.rs:70:11
|
70 | #[pyclass(hash)]
| ^^^^
error[E0369]: binary operation `==` cannot be applied to type `&SimpleEqOptRequiresPartialEq`
--> tests/ui/invalid_pyclass_enum.rs:31:11
|
@ -103,3 +121,27 @@ help: consider annotating `ComplexEqOptRequiresPartialEq` with `#[derive(Partial
38 + #[derive(PartialEq)]
39 | enum ComplexEqOptRequiresPartialEq {
|
error[E0277]: the trait bound `SimpleHashOptRequiresHash: Hash` is not satisfied
--> tests/ui/invalid_pyclass_enum.rs:49:31
|
49 | #[pyclass(frozen, eq, eq_int, hash)]
| ^^^^ the trait `Hash` is not implemented for `SimpleHashOptRequiresHash`
|
help: consider annotating `SimpleHashOptRequiresHash` with `#[derive(Hash)]`
|
51 + #[derive(Hash)]
52 | enum SimpleHashOptRequiresHash {
|
error[E0277]: the trait bound `ComplexHashOptRequiresHash: Hash` is not satisfied
--> tests/ui/invalid_pyclass_enum.rs:56:23
|
56 | #[pyclass(frozen, eq, hash)]
| ^^^^ the trait `Hash` is not implemented for `ComplexHashOptRequiresHash`
|
help: consider annotating `ComplexHashOptRequiresHash` with `#[derive(Hash)]`
|
58 + #[derive(Hash)]
59 | enum ComplexHashOptRequiresHash {
|