From 7329839fb53184940436369f6e372ee3ac928b3f Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Mon, 28 Sep 2026 14:53:58 +0200 Subject: [PATCH] fix(core): align hashability of dict views, namespace, and typing builtins (#8844) --- crates/stdlib/src/_queue.rs | 25 ++++--------------- crates/stdlib/src/openssl.rs | 2 +- crates/stdlib/src/ssl.rs | 2 +- crates/stdlib/src/ssl_wasm.rs | 2 +- crates/vm/src/builtins/dict.rs | 6 ++++- crates/vm/src/builtins/namespace.rs | 2 +- .../src/stdlib/_collections/ordered_dict.rs | 4 +-- crates/vm/src/stdlib/typevar.rs | 4 +-- extra_tests/snippets/builtin_dict.py | 25 +++++++++++++++++++ extra_tests/snippets/stdlib_types.py | 7 ++++++ extra_tests/snippets/syntax_type_hint.py | 19 ++++++++++++++ 11 files changed, 69 insertions(+), 29 deletions(-) diff --git a/crates/stdlib/src/_queue.rs b/crates/stdlib/src/_queue.rs index 75afc58e8..332409a50 100644 --- a/crates/stdlib/src/_queue.rs +++ b/crates/stdlib/src/_queue.rs @@ -7,11 +7,11 @@ mod _queue { use std::time::Instant; use crate::vm::{ - AsObject, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, + AsObject, Py, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, builtins::{PyBaseExceptionRef, PyException, PyGenericAlias, PyStr, PyType, PyTypeRef}, - function::{PyComparisonValue, TimeoutSeconds}, + function::TimeoutSeconds, protocol::PyNumberMethods, - types::{AsNumber, Comparable, Constructor, PyComparisonOp, Representable}, + types::{AsNumber, Constructor, Representable}, }; type BufInner = VecDeque; @@ -143,7 +143,7 @@ mod _queue { } #[pyattr] - #[pyclass(module = "_queue", name = "SimpleQueue", unhashable = true)] + #[pyclass(module = "_queue", name = "SimpleQueue")] #[derive(Debug, PyPayload)] struct PySimpleQueue { buf: Buf, @@ -227,7 +227,7 @@ mod _queue { } #[pyclass( - with(Constructor, Comparable, Representable), + with(Constructor, Representable), flags(BASETYPE, HAS_WEAKREF, IMMUTABLETYPE) )] impl PySimpleQueue { @@ -329,21 +329,6 @@ mod _queue { } } - impl Comparable for PySimpleQueue { - fn cmp( - zelf: &Py, - other: &PyObject, - op: PyComparisonOp, - _vm: &VirtualMachine, - ) -> PyResult { - Ok(if let Some(res) = op.identical_optimization(zelf, other) { - res.into() - } else { - PyComparisonValue::NotImplemented - }) - } - } - impl Representable for PySimpleQueue { fn repr(zelf: &Py, vm: &VirtualMachine) -> PyResult> { Ok(vm.ctx.new_str(format!( diff --git a/crates/stdlib/src/openssl.rs b/crates/stdlib/src/openssl.rs index b44707173..55cc0fa3d 100644 --- a/crates/stdlib/src/openssl.rs +++ b/crates/stdlib/src/openssl.rs @@ -3298,7 +3298,7 @@ mod _ssl { } #[pyattr] - #[pyclass(module = "ssl", name = "SSLSession")] + #[pyclass(module = "ssl", name = "SSLSession", unhashable = true)] #[derive(PyPayload)] struct PySslSession { session: *mut sys::SSL_SESSION, diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 932f08757..1f4340074 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -3979,7 +3979,7 @@ mod _ssl { // SSLSession - represents a cached SSL session // NOTE: This is an EMULATION - actual session data is managed by Rustls internally #[pyattr] - #[pyclass(name = "SSLSession", module = "ssl")] + #[pyclass(name = "SSLSession", module = "ssl", unhashable = true)] #[derive(Debug, PyPayload)] struct PySSLSession { context_identity: Arc<()>, diff --git a/crates/stdlib/src/ssl_wasm.rs b/crates/stdlib/src/ssl_wasm.rs index 057f9b1f7..a9328c952 100644 --- a/crates/stdlib/src/ssl_wasm.rs +++ b/crates/stdlib/src/ssl_wasm.rs @@ -737,7 +737,7 @@ mod _ssl { } #[pyattr] - #[pyclass(name = "SSLSession", module = "ssl")] + #[pyclass(name = "SSLSession", module = "ssl", unhashable = true)] #[derive(Debug, PyPayload)] struct PySSLSession { session_id: Vec, diff --git a/crates/vm/src/builtins/dict.rs b/crates/vm/src/builtins/dict.rs index 11cb0d47f..edbc2d936 100644 --- a/crates/vm/src/builtins/dict.rs +++ b/crates/vm/src/builtins/dict.rs @@ -1165,10 +1165,11 @@ macro_rules! dict_view { $class_name: literal, $iter_class_name: literal, $reverse_iter_class_name: literal, + $unhashable: literal, $project_fn: expr, $result_fn: expr ) => { - #[pyclass(module = false, name = $class_name)] + #[pyclass(module = false, name = $class_name, unhashable = $unhashable)] #[derive(Debug)] pub(crate) struct $name { pub(crate) dict: PyDictRef, @@ -1456,6 +1457,7 @@ dict_view! { "dict_keys", "dict_keyiterator", "dict_reversekeyiterator", + true, |key: &PyObject, _value| key.to_owned(), |_vm: &VirtualMachine, key: PyObjectRef| key } @@ -1470,6 +1472,7 @@ dict_view! { "dict_values", "dict_valueiterator", "dict_reversevalueiterator", + false, |_key: &PyObject, value: &PyObjectRef| value.clone(), |_vm: &VirtualMachine, value: PyObjectRef| value } @@ -1484,6 +1487,7 @@ dict_view! { "dict_items", "dict_itemiterator", "dict_reverseitemiterator", + true, |key: &PyObject, value: &PyObjectRef| (key.to_owned(), value.clone()), // Builds a tuple, so it runs after the dict's read guard is released. |vm: &VirtualMachine, (key, value): (PyObjectRef, PyObjectRef)| diff --git a/crates/vm/src/builtins/namespace.rs b/crates/vm/src/builtins/namespace.rs index a9ae6a9af..a872fd6c9 100644 --- a/crates/vm/src/builtins/namespace.rs +++ b/crates/vm/src/builtins/namespace.rs @@ -11,7 +11,7 @@ use crate::{ }; use rustpython_common::wtf8::Wtf8Buf; -#[pyclass(module = "types", name = "SimpleNamespace")] +#[pyclass(module = "types", name = "SimpleNamespace", unhashable = true)] // The dict lives in the object extension, not the payload. The offset is // the dict cell at the front of that extension. #[pymember(name = "__dict__", offset = ::rustpython_vm::object::dict_member_offset())] diff --git a/crates/vm/src/stdlib/_collections/ordered_dict.rs b/crates/vm/src/stdlib/_collections/ordered_dict.rs index 7312784d9..fe641a167 100644 --- a/crates/vm/src/stdlib/_collections/ordered_dict.rs +++ b/crates/vm/src/stdlib/_collections/ordered_dict.rs @@ -978,7 +978,7 @@ pub(crate) mod ordered_dict { } #[pyattr] - #[pyclass(name = "odict_keys", traverse)] + #[pyclass(name = "odict_keys", unhashable = true, traverse)] #[derive(Debug, PyPayload)] struct PyODictKeys { od: PyRef, @@ -1122,7 +1122,7 @@ pub(crate) mod ordered_dict { } #[pyattr] - #[pyclass(name = "odict_items", traverse)] + #[pyclass(name = "odict_items", unhashable = true, traverse)] #[derive(Debug, PyPayload)] pub(crate) struct PyOrderedDictItems { od: PyRef, diff --git a/crates/vm/src/stdlib/typevar.rs b/crates/vm/src/stdlib/typevar.rs index 4557b77dc..30ac4857e 100644 --- a/crates/vm/src/stdlib/typevar.rs +++ b/crates/vm/src/stdlib/typevar.rs @@ -845,7 +845,7 @@ pub(crate) mod typevar { } #[pyattr] - #[pyclass(name = "ParamSpecArgs", module = "typing")] + #[pyclass(name = "ParamSpecArgs", module = "typing", unhashable = true)] #[derive(Debug, PyPayload)] pub struct ParamSpecArgs { #[pymember] @@ -905,7 +905,7 @@ pub(crate) mod typevar { } #[pyattr] - #[pyclass(name = "ParamSpecKwargs", module = "typing")] + #[pyclass(name = "ParamSpecKwargs", module = "typing", unhashable = true)] #[derive(Debug, PyPayload)] pub struct ParamSpecKwargs { #[pymember] diff --git a/extra_tests/snippets/builtin_dict.py b/extra_tests/snippets/builtin_dict.py index 53b7fd944..14fdb67ec 100644 --- a/extra_tests/snippets/builtin_dict.py +++ b/extra_tests/snippets/builtin_dict.py @@ -687,3 +687,28 @@ class MergeMapping(dict): # Generic mappings retain their lookup hooks instead of exposing dict storage. for merge in (dict, merge_with_update, merge_with_ior): assert merge(MergeMapping(stored=0)) == {"virtual": 42} + +# Test hashability of dict and OrderedDict views +import collections.abc + +d = {"a": 1, "b": 2} +assert type(d.keys()).__hash__ is None +assert type(d.items()).__hash__ is None +assert type(d.values()).__hash__ is not None +assert not isinstance(d.keys(), collections.abc.Hashable) +assert not isinstance(d.items(), collections.abc.Hashable) +with assert_raises(TypeError): + hash(d.keys()) +with assert_raises(TypeError): + hash(d.items()) + +od = collections.OrderedDict([("a", 1)]) +assert type(od.keys()).__hash__ is None +assert type(od.items()).__hash__ is None +assert type(od.values()).__hash__ is not None +assert not isinstance(od.keys(), collections.abc.Hashable) +assert not isinstance(od.items(), collections.abc.Hashable) +with assert_raises(TypeError): + hash(od.keys()) +with assert_raises(TypeError): + hash(od.items()) diff --git a/extra_tests/snippets/stdlib_types.py b/extra_tests/snippets/stdlib_types.py index 4bccd2985..d913095f2 100644 --- a/extra_tests/snippets/stdlib_types.py +++ b/extra_tests/snippets/stdlib_types.py @@ -12,6 +12,13 @@ assert ns.b == "Rust" with assert_raises(AttributeError): _ = ns.c +import collections.abc + +assert types.SimpleNamespace.__hash__ is None +assert not isinstance(ns, collections.abc.Hashable) +with assert_raises(TypeError): + hash(ns) + def _run_missing_type_params_regression(): args = _ast.arguments( diff --git a/extra_tests/snippets/syntax_type_hint.py b/extra_tests/snippets/syntax_type_hint.py index b6c25e59f..fd798819b 100644 --- a/extra_tests/snippets/syntax_type_hint.py +++ b/extra_tests/snippets/syntax_type_hint.py @@ -10,3 +10,22 @@ print(curry.__annotations__) assert curry.__annotations__['foo'] is int assert curry.__annotations__['return'] is float assert curry.__annotations__['bla'] is int + +import collections.abc +import typing + +P = typing.ParamSpec('P') +assert P.args.__hash__ is None +assert P.kwargs.__hash__ is None +assert not isinstance(P.args, collections.abc.Hashable) +assert not isinstance(P.kwargs, collections.abc.Hashable) +try: + hash(P.args) + assert False, "expected TypeError" +except TypeError: + pass +try: + hash(P.kwargs) + assert False, "expected TypeError" +except TypeError: + pass