From 7d06dd0859685f6f8802a409aaa91e98916663bf Mon Sep 17 00:00:00 2001 From: dot Date: Mon, 12 Oct 2026 08:12:32 +0900 Subject: [PATCH] Preserve final dictionary hash-error context and formatting Share exact-TypeError rewriting between dict and frozendict without attaching a cause. Propagate string and repr callback failures using final BaseException argument formatting, leaving global diagnostic behavior unchanged. Add bounded Python coverage for context, identity, and formatting failures. Assisted-by: OpenAI Codex:model identifier unavailable --- crates/vm/src/builtins/dict.rs | 28 +++----- extra_tests/snippets/builtin_dict.py | 102 +++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 17 deletions(-) diff --git a/crates/vm/src/builtins/dict.rs b/crates/vm/src/builtins/dict.rs index a483812d6..e59a95d13 100644 --- a/crates/vm/src/builtins/dict.rs +++ b/crates/vm/src/builtins/dict.rs @@ -41,9 +41,18 @@ pub(crate) fn key_hash( vm: &VirtualMachine, ) -> PyResult { match key.key_hash(vm) { + // Exact `TypeError` only: a `__hash__` raising a subclass must + // propagate unchanged, matching CPython's exact-type check. Err(exc) if exc.class().is(vm.ctx.exceptions.type_error) => { let name = key.to_pyobject(vm).class().fully_qualified_name(vm)?; - let message = exc.as_object().str(vm)?; + // Match BaseException_str without the diagnostic formatter's + // fallback: an argument's failing __str__/__repr__ must propagate. + let args = exc.args(); + let message = match args.as_slice() { + [] => vm.ctx.empty_str.to_owned(), + [arg] => arg.str(vm)?, + _ => args.as_object().str(vm)?, + }; Err(vm.new_type_error(format!( "cannot use '{name}' as a {mapping_name} key ({message})" ))) @@ -520,22 +529,7 @@ impl PyDict { /// Hashing up front also means a `__hash__` that fails only intermittently /// is still reported. fn hash_or_unhashable(key: &K, vm: &VirtualMachine) -> PyResult { - match key.key_hash(vm) { - Ok(hash) => Ok(hash), - // Exact `TypeError` only: a `__hash__` raising a *subclass* of - // `TypeError` (e.g. a user `MyTypeError`) must propagate unchanged, - // matching CPython's exact-type check. - Err(cause) if cause.class().is(vm.ctx.exceptions.type_error) => { - let message = cause.as_object().str(vm)?; - let key = key.to_pyobject(vm); - let name = key.class().fully_qualified_name(vm)?; - let err = - vm.new_type_error(format!("cannot use '{name}' as a dict key ({message})")); - err.set_cause(Some(cause)); - Err(err) - } - Err(other) => Err(other), - } + key_hash(key, "dict", vm) } /// Set item variant which can be called with multiple diff --git a/extra_tests/snippets/builtin_dict.py b/extra_tests/snippets/builtin_dict.py index ef4b3ec1e..220dd6b32 100644 --- a/extra_tests/snippets/builtin_dict.py +++ b/extra_tests/snippets/builtin_dict.py @@ -2125,3 +2125,105 @@ frozendict(**kwargs) -> new immutable dictionary initialized with the name=value frozen_result = unittest.TextTestRunner(verbosity=2).run(frozen_suite) assert frozen_result.wasSuccessful() assert not frozen_result.skipped + + +# Final 3.15 rewrites exact hash TypeError without an explicit cause, and +# propagates formatting failures instead of replacing them with diagnostics. +def check_mapping_hash_error_context(): + class SpecialTypeError(TypeError): + pass + + for mapping_type in (dict, frozendict): + for case in ( + "plain", + "empty", + "multiple", + "bad_str", + "bad_repr", + "subclass", + "subclass_bad_str", + "list", + ): + for handled in (False, True): + for operation in ("getitem", "get", "contains"): + callback_error = RuntimeError("format failed") + callbacks = [] + + class BadText: + def __str__(self): + callbacks.append("str") + raise callback_error + + def __repr__(self): + callbacks.append("repr") + raise callback_error + + args = { + "plain": ("hash failed",), + "empty": (), + "multiple": ("one", 2), + "bad_str": (BadText(),), + "bad_repr": ("one", BadText()), + "subclass": ("hash failed",), + "subclass_bad_str": (BadText(),), + "list": (), + }[case] + error_type = ( + SpecialTypeError if case.startswith("subclass") else TypeError + ) + original = error_type(*args) + outer = ValueError("outer") + + class Key: + def __hash__(self): + raise original + + key = [] if case == "list" else Key() + mapping = mapping_type() + + def invoke(): + try: + if operation == "getitem": + mapping[key] + elif operation == "get": + mapping.get(key) + else: + key in mapping + except Exception as error: + assert error.__cause__ is None + assert error.__context__ is (outer if handled else None) + assert error.__suppress_context__ is False + if case.startswith("subclass"): + assert error is original + assert callbacks == [] + elif case in ("bad_str", "bad_repr"): + assert error is callback_error + assert callbacks == [ + "str" if case == "bad_str" else "repr" + ] + else: + assert type(error) is TypeError + assert error is not original + name = "list" if case == "list" else Key.__qualname__ + message = { + "plain": "hash failed", + "empty": "", + "multiple": "('one', 2)", + "list": "unhashable type: 'list'", + }[case] + assert str(error) == ( + f"cannot use '{name}' as a {mapping_type.__name__} key ({message})" + ) + else: + raise AssertionError("hash failure was not propagated") + + if handled: + try: + raise outer + except ValueError: + invoke() + else: + invoke() + + +check_mapping_hash_error_context()