Skip to content

Commit a7a5c10

Browse files
Icxoludavidhewitt
andauthored
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 <[email protected]> * require `eq` for `hash` * prevent manual `__hash__` with `#pyo3(hash)` * combine error messages --------- Co-authored-by: David Hewitt <[email protected]>
1 parent 25c1db4 commit a7a5c10

13 files changed

+328
-5
lines changed

guide/pyclass-parameters.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
| <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. |
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. |
14+
| `hash` | Implements `__hash__` using the `Hash` implementation of the underlying Rust datatype. |
1415
| `mapping` | Inform PyO3 that this class is a [`Mapping`][params-mapping], and so leave its implementation of sequence C-API slots empty. |
1516
| <span style="white-space: pre">`module = "module_name"`</span> | Python code will see the class as being defined in this module. Defaults to `builtins`. |
1617
| <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. |

guide/src/class/object.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,19 @@ impl Number {
121121
}
122122
}
123123
```
124+
To implement `__hash__` using the Rust [`Hash`] trait implementation, the `hash` option can be used.
125+
This option is only available for `frozen` classes to prevent accidental hash changes from mutating the object. If you need
126+
an `__hash__` implementation for a mutable class, use the manual method from above. This option also requires `eq`: According to the
127+
[Python docs](https://docs.python.org/3/reference/datamodel.html#object.__hash__) "If a class does not define an `__eq__()`
128+
method it should not define a `__hash__()` operation either"
129+
```rust
130+
# use pyo3::prelude::*;
131+
#
132+
#[pyclass(frozen, eq, hash)]
133+
#[derive(PartialEq, Hash)]
134+
struct Number(i32);
135+
```
136+
124137

125138
> **Note**: When implementing `__hash__` and comparisons, it is important that the following property holds:
126139
>

newsfragments/4206.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added `#[pyclass(hash)]` option to implement `__hash__` in terms of the `Hash` implementation

pyo3-macros-backend/src/attributes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub mod kw {
2222
syn::custom_keyword!(frozen);
2323
syn::custom_keyword!(get);
2424
syn::custom_keyword!(get_all);
25+
syn::custom_keyword!(hash);
2526
syn::custom_keyword!(item);
2627
syn::custom_keyword!(from_item_all);
2728
syn::custom_keyword!(mapping);

pyo3-macros-backend/src/pyclass.rs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use crate::pyfunction::ConstructorAttribute;
1212
use crate::pyimpl::{gen_py_const, PyClassMethodsType};
1313
use crate::pymethod::{
1414
impl_py_getter_def, impl_py_setter_def, MethodAndMethodDef, MethodAndSlotDef, PropertyType,
15-
SlotDef, __GETITEM__, __INT__, __LEN__, __REPR__, __RICHCMP__,
15+
SlotDef, __GETITEM__, __HASH__, __INT__, __LEN__, __REPR__, __RICHCMP__,
1616
};
1717
use crate::utils::Ctx;
1818
use crate::utils::{self, apply_renaming_rule, PythonDoc};
@@ -21,6 +21,7 @@ use proc_macro2::{Ident, Span, TokenStream};
2121
use quote::{format_ident, quote, quote_spanned};
2222
use syn::ext::IdentExt;
2323
use syn::parse::{Parse, ParseStream};
24+
use syn::parse_quote_spanned;
2425
use syn::punctuated::Punctuated;
2526
use syn::{parse_quote, spanned::Spanned, Result, Token};
2627

@@ -65,6 +66,7 @@ pub struct PyClassPyO3Options {
6566
pub get_all: Option<kw::get_all>,
6667
pub freelist: Option<FreelistAttribute>,
6768
pub frozen: Option<kw::frozen>,
69+
pub hash: Option<kw::hash>,
6870
pub mapping: Option<kw::mapping>,
6971
pub module: Option<ModuleAttribute>,
7072
pub name: Option<NameAttribute>,
@@ -85,6 +87,7 @@ enum PyClassPyO3Option {
8587
Freelist(FreelistAttribute),
8688
Frozen(kw::frozen),
8789
GetAll(kw::get_all),
90+
Hash(kw::hash),
8891
Mapping(kw::mapping),
8992
Module(ModuleAttribute),
9093
Name(NameAttribute),
@@ -115,6 +118,8 @@ impl Parse for PyClassPyO3Option {
115118
input.parse().map(PyClassPyO3Option::Frozen)
116119
} else if lookahead.peek(attributes::kw::get_all) {
117120
input.parse().map(PyClassPyO3Option::GetAll)
121+
} else if lookahead.peek(attributes::kw::hash) {
122+
input.parse().map(PyClassPyO3Option::Hash)
118123
} else if lookahead.peek(attributes::kw::mapping) {
119124
input.parse().map(PyClassPyO3Option::Mapping)
120125
} else if lookahead.peek(attributes::kw::module) {
@@ -180,6 +185,7 @@ impl PyClassPyO3Options {
180185
PyClassPyO3Option::Freelist(freelist) => set_option!(freelist),
181186
PyClassPyO3Option::Frozen(frozen) => set_option!(frozen),
182187
PyClassPyO3Option::GetAll(get_all) => set_option!(get_all),
188+
PyClassPyO3Option::Hash(hash) => set_option!(hash),
183189
PyClassPyO3Option::Mapping(mapping) => set_option!(mapping),
184190
PyClassPyO3Option::Module(module) => set_option!(module),
185191
PyClassPyO3Option::Name(name) => set_option!(name),
@@ -363,8 +369,12 @@ fn impl_class(
363369
let (default_richcmp, default_richcmp_slot) =
364370
pyclass_richcmp(&args.options, &syn::parse_quote!(#cls), ctx)?;
365371

372+
let (default_hash, default_hash_slot) =
373+
pyclass_hash(&args.options, &syn::parse_quote!(#cls), ctx)?;
374+
366375
let mut slots = Vec::new();
367376
slots.extend(default_richcmp_slot);
377+
slots.extend(default_hash_slot);
368378

369379
let py_class_impl = PyClassImplsBuilder::new(
370380
cls,
@@ -393,6 +403,7 @@ fn impl_class(
393403
#[allow(non_snake_case)]
394404
impl #cls {
395405
#default_richcmp
406+
#default_hash
396407
}
397408
})
398409
}
@@ -798,9 +809,11 @@ fn impl_simple_enum(
798809

799810
let (default_richcmp, default_richcmp_slot) =
800811
pyclass_richcmp_simple_enum(&args.options, &ty, repr_type, ctx);
812+
let (default_hash, default_hash_slot) = pyclass_hash(&args.options, &ty, ctx)?;
801813

802814
let mut default_slots = vec![default_repr_slot, default_int_slot];
803815
default_slots.extend(default_richcmp_slot);
816+
default_slots.extend(default_hash_slot);
804817

805818
let pyclass_impls = PyClassImplsBuilder::new(
806819
cls,
@@ -827,6 +840,7 @@ fn impl_simple_enum(
827840
#default_repr
828841
#default_int
829842
#default_richcmp
843+
#default_hash
830844
}
831845
})
832846
}
@@ -858,9 +872,11 @@ fn impl_complex_enum(
858872
let pytypeinfo = impl_pytypeinfo(cls, &args, None, ctx);
859873

860874
let (default_richcmp, default_richcmp_slot) = pyclass_richcmp(&args.options, &ty, ctx)?;
875+
let (default_hash, default_hash_slot) = pyclass_hash(&args.options, &ty, ctx)?;
861876

862877
let mut default_slots = vec![];
863878
default_slots.extend(default_richcmp_slot);
879+
default_slots.extend(default_hash_slot);
864880

865881
let impl_builder = PyClassImplsBuilder::new(
866882
cls,
@@ -967,6 +983,7 @@ fn impl_complex_enum(
967983
#[allow(non_snake_case)]
968984
impl #cls {
969985
#default_richcmp
986+
#default_hash
970987
}
971988

972989
#(#variant_cls_zsts)*
@@ -1783,6 +1800,35 @@ fn pyclass_richcmp(
17831800
}
17841801
}
17851802

1803+
fn pyclass_hash(
1804+
options: &PyClassPyO3Options,
1805+
cls: &syn::Type,
1806+
ctx: &Ctx,
1807+
) -> Result<(Option<syn::ImplItemFn>, Option<MethodAndSlotDef>)> {
1808+
if options.hash.is_some() {
1809+
ensure_spanned!(
1810+
options.frozen.is_some(), options.hash.span() => "The `hash` option requires the `frozen` option.";
1811+
options.eq.is_some(), options.hash.span() => "The `hash` option requires the `eq` option.";
1812+
);
1813+
}
1814+
// FIXME: Use hash.map(...).unzip() on MSRV >= 1.66
1815+
match options.hash {
1816+
Some(opt) => {
1817+
let mut hash_impl = parse_quote_spanned! { opt.span() =>
1818+
fn __pyo3__generated____hash__(&self) -> u64 {
1819+
let mut s = ::std::collections::hash_map::DefaultHasher::new();
1820+
::std::hash::Hash::hash(self, &mut s);
1821+
::std::hash::Hasher::finish(&s)
1822+
}
1823+
};
1824+
let hash_slot =
1825+
generate_protocol_slot(cls, &mut hash_impl, &__HASH__, "__hash__", ctx).unwrap();
1826+
Ok((Some(hash_impl), Some(hash_slot)))
1827+
}
1828+
None => Ok((None, None)),
1829+
}
1830+
}
1831+
17861832
/// Implements most traits used by `#[pyclass]`.
17871833
///
17881834
/// Specifically, it implements traits that only depend on class name,

pyo3-macros-backend/src/pymethod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -910,7 +910,7 @@ impl PropertyType<'_> {
910910

911911
const __STR__: SlotDef = SlotDef::new("Py_tp_str", "reprfunc");
912912
pub const __REPR__: SlotDef = SlotDef::new("Py_tp_repr", "reprfunc");
913-
const __HASH__: SlotDef = SlotDef::new("Py_tp_hash", "hashfunc")
913+
pub const __HASH__: SlotDef = SlotDef::new("Py_tp_hash", "hashfunc")
914914
.ret_ty(Ty::PyHashT)
915915
.return_conversion(TokenGenerator(
916916
|Ctx { pyo3_path }: &Ctx| quote! { #pyo3_path::callback::HashCallbackOutput },

pyo3-macros-backend/src/utils.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,20 @@ macro_rules! ensure_spanned {
2525
if !($condition) {
2626
bail_spanned!($span => $msg);
2727
}
28-
}
28+
};
29+
($($condition:expr, $span:expr => $msg:expr;)*) => {
30+
if let Some(e) = [$(
31+
(!($condition)).then(|| err_spanned!($span => $msg)),
32+
)*]
33+
.into_iter()
34+
.flatten()
35+
.reduce(|mut acc, e| {
36+
acc.combine(e);
37+
acc
38+
}) {
39+
return Err(e);
40+
}
41+
};
2942
}
3043

3144
/// Check if the given type `ty` is `pyo3::Python`.

tests/test_class_basics.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,34 @@ fn class_with_object_field() {
200200
});
201201
}
202202

203+
#[pyclass(frozen, eq, hash)]
204+
#[derive(PartialEq, Hash)]
205+
struct ClassWithHash {
206+
value: usize,
207+
}
208+
209+
#[test]
210+
fn class_with_hash() {
211+
Python::with_gil(|py| {
212+
use pyo3::types::IntoPyDict;
213+
let class = ClassWithHash { value: 42 };
214+
let hash = {
215+
use std::hash::{Hash, Hasher};
216+
let mut hasher = std::collections::hash_map::DefaultHasher::new();
217+
class.hash(&mut hasher);
218+
hasher.finish() as isize
219+
};
220+
221+
let env = [
222+
("obj", Py::new(py, class).unwrap().into_any()),
223+
("hsh", hash.into_py(py)),
224+
]
225+
.into_py_dict_bound(py);
226+
227+
py_assert!(py, *env, "hash(obj) == hsh");
228+
});
229+
}
230+
203231
#[pyclass(unsendable, subclass)]
204232
struct UnsendableBase {
205233
value: std::rc::Rc<usize>,

tests/test_enum.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,63 @@ fn test_renaming_all_enum_variants() {
220220
);
221221
});
222222
}
223+
224+
#[pyclass(frozen, eq, eq_int, hash)]
225+
#[derive(PartialEq, Hash)]
226+
enum SimpleEnumWithHash {
227+
A,
228+
B,
229+
}
230+
231+
#[test]
232+
fn test_simple_enum_with_hash() {
233+
Python::with_gil(|py| {
234+
use pyo3::types::IntoPyDict;
235+
let class = SimpleEnumWithHash::A;
236+
let hash = {
237+
use std::hash::{Hash, Hasher};
238+
let mut hasher = std::collections::hash_map::DefaultHasher::new();
239+
class.hash(&mut hasher);
240+
hasher.finish() as isize
241+
};
242+
243+
let env = [
244+
("obj", Py::new(py, class).unwrap().into_any()),
245+
("hsh", hash.into_py(py)),
246+
]
247+
.into_py_dict_bound(py);
248+
249+
py_assert!(py, *env, "hash(obj) == hsh");
250+
});
251+
}
252+
253+
#[pyclass(eq, hash)]
254+
#[derive(PartialEq, Hash)]
255+
enum ComplexEnumWithHash {
256+
A(u32),
257+
B { msg: String },
258+
}
259+
260+
#[test]
261+
fn test_complex_enum_with_hash() {
262+
Python::with_gil(|py| {
263+
use pyo3::types::IntoPyDict;
264+
let class = ComplexEnumWithHash::B {
265+
msg: String::from("Hello"),
266+
};
267+
let hash = {
268+
use std::hash::{Hash, Hasher};
269+
let mut hasher = std::collections::hash_map::DefaultHasher::new();
270+
class.hash(&mut hasher);
271+
hasher.finish() as isize
272+
};
273+
274+
let env = [
275+
("obj", Py::new(py, class).unwrap().into_any()),
276+
("hsh", hash.into_py(py)),
277+
]
278+
.into_py_dict_bound(py);
279+
280+
py_assert!(py, *env, "hash(obj) == hsh");
281+
});
282+
}

tests/ui/invalid_pyclass_args.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,23 @@ impl EqOptAndManualRichCmp {
5252
#[pyclass(eq_int)]
5353
struct NoEqInt {}
5454

55+
#[pyclass(frozen, eq, hash)]
56+
#[derive(PartialEq)]
57+
struct HashOptRequiresHash;
58+
59+
#[pyclass(hash)]
60+
#[derive(Hash)]
61+
struct HashWithoutFrozenAndEq;
62+
63+
#[pyclass(frozen, eq, hash)]
64+
#[derive(PartialEq, Hash)]
65+
struct HashOptAndManualHash {}
66+
67+
#[pymethods]
68+
impl HashOptAndManualHash {
69+
fn __hash__(&self) -> u64 {
70+
todo!()
71+
}
72+
}
73+
5574
fn main() {}

0 commit comments

Comments
 (0)