Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/vm/src/builtins/dict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ impl PyDict {
self.entries.items()
}

pub(crate) fn items_with_hashes_vec(&self) -> Vec<(PyObjectRef, PyObjectRef, PyHash)> {
self.entries.items_with_hashes()
}

fn merge_object_with_override(
&self,
other: PyObjectRef,
Expand Down
11 changes: 10 additions & 1 deletion crates/vm/src/dict_inner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,8 @@ impl<T: Clone> Dict<T> {
/// Store a key.
///
/// `hash` must be computed for `key` or read from [`Self::keys_with_hashes`]
/// or [`Self::next_entry_with_hash`] on a container holding this same key.
/// or [`Self::items_with_hashes`] or [`Self::next_entry_with_hash`] on a
/// container holding this same key.
/// A wrong hash lands the entry in a bucket no lookup probes, silently
/// losing the key.
pub(crate) fn insert<K>(
Expand Down Expand Up @@ -1182,6 +1183,14 @@ impl<T: Clone> Dict<T> {
.collect()
}

pub(crate) fn items_with_hashes(&self) -> Vec<(PyObjectRef, T, HashValue)> {
self.read()
.entries
.iter()
.filter_map(|v| v.as_ref().map(|v| (v.key.clone(), v.value.clone(), v.hash)))
.collect()
}

/// Fold stored hashes under the read lock; the callback must not run Python.
pub(crate) fn fold_hashes<Acc, Fold>(&self, init: Acc, f: Fold) -> Acc
where
Expand Down
12 changes: 6 additions & 6 deletions crates/vm/src/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4623,19 +4623,19 @@ impl ExecutingFrame<'_> {
let src_dict = source
.downcast_ref::<PyDict>()
.expect("exact dict must have a PyDict payload");
// Snapshot under a single read lock so a mutation of `source`
// triggered by `dict.set_item` (e.g. via a target subclass, or
// aliasing) can't be observed mid-iteration.
for (key, value) in src_dict.items_vec() {
if dict.contains_key(&*key, vm) {
// Preserve stored hashes: copying an exact dict must not call
// a key's __hash__ before the callable receives its arguments.
// Snapshot under one lock before any equality callbacks run.
for (key, value, hash) in src_dict.items_with_hashes_vec() {
if dict.contains_known_hash(&key, hash, vm)? {
let key_str = key.str(vm)?;
return Err(vm.new_type_error(format!(
"{} got multiple values for keyword argument '{}'",
func_str,
key_str.as_wtf8()
)));
}
dict.set_item(&*key, value, vm)?;
dict.set_item_known_hash(&key, hash, value, vm)?;
}
return Ok(None);
} else {
Expand Down
47 changes: 47 additions & 0 deletions extra_tests/snippets/syntax_function_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,50 @@ def f(a, b, /, c, d, *, e, f):
f(1,2,3,4,5,f=6)
with assert_raises(TypeError):
f(1,2,3,4,5,6)


def test_keyword_dict_stored_hashes():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the newly added test body

This function adds test logic, test data, and assertions to an existing test file, but this repository explicitly limits test-file edits to adding expected-failure decorators or removing those decorators and their TODOs. This addition therefore cannot land under the repository's test-modification policy; remove it or obtain an explicit policy exception before landing.

AGENTS.md reference: AGENTS.md:L273-L279

Useful? React with 👍 / 👎.

class Keyword(str):
def __hash__(self):
assert not getattr(self, "hashed", False), "keyword was rehashed"
self.hashed = True
return str.__hash__(self)

def accept(*, value):
return value

source = {Keyword("value"): 42}
assert accept(**source) == 42
with assert_raises(TypeError) as raised:
accept(value=1, **source)
assert str(raised.exception).endswith(
"got multiple values for keyword argument 'value'"
)

class NonString:
def __hash__(self):
assert not getattr(self, "hashed", False), "non-string key was rehashed"
self.hashed = True
return 0

source = {NonString(): 42}
with assert_raises(TypeError) as raised:
accept(**source)
assert str(raised.exception) == "keywords must be strings"

failure = RuntimeError("keyword comparison failed")

class BadEquality(str):
__hash__ = str.__hash__

def __eq__(self, other):
raise failure

left = {BadEquality("value"): 1}
right = {BadEquality("value"): 2}
with assert_raises(RuntimeError) as raised:
accept(**left, **right)
assert raised.exception is failure


test_keyword_dict_stored_hashes()
Loading