From d7b4be29780fd6868e9e5055210a4273c80969cc Mon Sep 17 00:00:00 2001 From: youknowdot Date: Sun, 11 Oct 2026 07:36:19 +0900 Subject: [PATCH] Align merged runtime contracts with Python 3.15 final Restrict mappingproxy ordering, use final codec registry normalization, and prefer index conversion before float conversion for sleep. Retain universal Python assertions and import the matching final codec/time canonical test hunks plus the non-ASCII codec-name deprecation warning. Upstream source: CPython d220409a13b77de7a6c30abe16ea90db01528edc. The existing codec normalization implementation and bytecode contract remain unchanged. Assisted-by: OpenAI Codex:model identifier unavailable --- Lib/encodings/__init__.py | 6 ++ Lib/test/test_codecs.py | 35 +++++------ Lib/test/test_time.py | 2 +- crates/vm/src/builtins/mappingproxy.rs | 6 +- crates/vm/src/codecs.rs | 26 +++++++- crates/vm/src/stdlib/time.rs | 62 ++++++-------------- extra_tests/snippets/builtin_mappingproxy.py | 22 +++++-- extra_tests/snippets/stdlib_codecs_wide.py | 6 +- extra_tests/snippets/stdlib_time.py | 34 +++++++++-- 9 files changed, 119 insertions(+), 80 deletions(-) diff --git a/Lib/encodings/__init__.py b/Lib/encodings/__init__.py index 8548bbe04..18f5da70c 100644 --- a/Lib/encodings/__init__.py +++ b/Lib/encodings/__init__.py @@ -56,6 +56,12 @@ def normalize_encoding(encoding): if isinstance(encoding, bytes): encoding = str(encoding, "ascii") + if not encoding.isascii(): + import warnings + warnings.warn( + "Support for non-ascii encoding names will be removed in 3.17", + DeprecationWarning, stacklevel=2) + chars = [] punct = False for c in encoding: diff --git a/Lib/test/test_codecs.py b/Lib/test/test_codecs.py index fd21c7b05..bcaffb79c 100644 --- a/Lib/test/test_codecs.py +++ b/Lib/test/test_codecs.py @@ -13,6 +13,7 @@ import warnings from test import support from test.support import os_helper +from test.support import warnings_helper try: import _testlimitedcapi @@ -3875,32 +3876,27 @@ class Rot13UtilTest(unittest.TestCase): class CodecNameNormalizationTest(unittest.TestCase): """Test codec name normalization""" def test_codecs_lookup(self): - FOUND = (1, 2, 3, 4) - NOT_FOUND = (None, None, None, None) def search_function(encoding): - if encoding == "aaa_8": - return FOUND + if encoding.startswith("test."): + return (encoding, 2, 3, 4) else: - return NOT_FOUND + return None codecs.register(search_function) self.addCleanup(codecs.unregister, search_function) - self.assertEqual(FOUND, codecs.lookup('aaa_8')) - self.assertEqual(FOUND, codecs.lookup('AAA-8')) - self.assertEqual(FOUND, codecs.lookup('AAA---8')) - self.assertEqual(FOUND, codecs.lookup('AAA 8')) - self.assertEqual(FOUND, codecs.lookup('aaa\xe9\u20ac-8')) - self.assertEqual(NOT_FOUND, codecs.lookup('AAA.8')) - self.assertEqual(NOT_FOUND, codecs.lookup('AAA...8')) - self.assertEqual(NOT_FOUND, codecs.lookup('BBB-8')) - self.assertEqual(NOT_FOUND, codecs.lookup('BBB.8')) - self.assertEqual(NOT_FOUND, codecs.lookup('a\xe9\u20ac-8')) + self.assertEqual(codecs.lookup('test.aaa_8'), ('test.aaa_8', 2, 3, 4)) + self.assertEqual(codecs.lookup('TEST.AAA-8'), ('test.aaa-8', 2, 3, 4)) + self.assertEqual(codecs.lookup('TEST.AAA 8'), ('test.aaa-8', 2, 3, 4)) + self.assertEqual(codecs.lookup('TEST.AAA---8'), ('test.aaa---8', 2, 3, 4)) + self.assertEqual(codecs.lookup('TEST.AAA 8'), ('test.aaa---8', 2, 3, 4)) + self.assertEqual(codecs.lookup('TEST.AAA.8'), ('test.aaa.8', 2, 3, 4)) + self.assertEqual(codecs.lookup('TEST.AAA...8'), ('test.aaa...8', 2, 3, 4)) + with self.assertWarns(DeprecationWarning): + self.assertEqual(codecs.lookup('TEST.AAA\xe9\u20ac-8'), ('test.aaa\xe9\u20ac-8', 2, 3, 4)) def test_encodings_normalize_encoding(self): - # encodings.normalize_encoding() ignores non-ASCII characters. normalize = encodings.normalize_encoding self.assertEqual(normalize('utf_8'), 'utf_8') - self.assertEqual(normalize('utf\xE9\u20AC\U0010ffff-8'), 'utf_8') self.assertEqual(normalize('utf 8'), 'utf_8') # encodings.normalize_encoding() doesn't convert # characters to lower case. @@ -3908,6 +3904,11 @@ class CodecNameNormalizationTest(unittest.TestCase): self.assertEqual(normalize('utf.8'), 'utf.8') self.assertEqual(normalize('utf...8'), 'utf...8') + # Non-ASCII *encoding* is deprecated. + msg = "Support for non-ascii encoding names will be removed in 3.17" + with warnings_helper.check_warnings((msg, DeprecationWarning)): + self.assertEqual(normalize('utf\xE9\u20AC\U0010ffff-8'), 'utf_8') + class CodecCacheTest(unittest.TestCase): def test_cache_bounded(self): diff --git a/Lib/test/test_time.py b/Lib/test/test_time.py index a14baa7a7..298c45faf 100644 --- a/Lib/test/test_time.py +++ b/Lib/test/test_time.py @@ -170,7 +170,7 @@ class TimeTestCase(unittest.TestCase): # Improved exception #81267 with self.assertRaises(TypeError) as errmsg: time.sleep([]) - self.assertIn("integer or float", str(errmsg.exception)) + self.assertIn("real number", str(errmsg.exception)) def test_sleep(self): for value in [-0.0, 0, 0.0, 1e-100, 1e-9, 1e-6, 1, 1.2]: diff --git a/crates/vm/src/builtins/mappingproxy.rs b/crates/vm/src/builtins/mappingproxy.rs index 7342fb3c0..3e53f1729 100644 --- a/crates/vm/src/builtins/mappingproxy.rs +++ b/crates/vm/src/builtins/mappingproxy.rs @@ -193,9 +193,11 @@ impl Py { op.method_name(&vm.ctx).as_str() )) })?; + if !matches!(op, PyComparisonOp::Eq | PyComparisonOp::Ne) { + return Ok(Either::B(PyComparisonValue::NotImplemented)); + } let obj = zelf.to_object(vm)?; - // CPython parity (Objects/descrobject.c::mappingproxy_richcompare): - // delegate to PyObject_RichCompare on the underlying mapping. + // Equality comparisons preserve the underlying mapping's result. obj.rich_compare(other.to_owned(), op, vm).map(Either::A) } diff --git a/crates/vm/src/codecs.rs b/crates/vm/src/codecs.rs index f3b6b1334..32724984e 100644 --- a/crates/vm/src/codecs.rs +++ b/crates/vm/src/codecs.rs @@ -253,7 +253,7 @@ impl CodecsRegistry { } pub(crate) fn register_manual(&self, name: &str, codec: PyCodec) { - let name = normalize_encoding_name(name); + let name = normalize_registry_name(name); self.inner .write() .search_cache @@ -262,7 +262,7 @@ impl CodecsRegistry { pub fn lookup(&self, encoding: &str, vm: &VirtualMachine) -> PyResult { let original_encoding = encoding; - let encoding = normalize_encoding_name(encoding); + let encoding = normalize_registry_name(encoding); let search_path = { let inner = self.inner.read(); if let Some(codec) = inner.search_cache.get(encoding.as_ref()) { @@ -308,7 +308,7 @@ impl CodecsRegistry { } pub fn forget(&self, encoding: &str) -> Option { - let encoding = normalize_encoding_name(encoding); + let encoding = normalize_registry_name(encoding); self.inner.write().search_cache.remove(encoding.as_ref()) } @@ -623,6 +623,26 @@ impl FastCodec { } } +fn normalize_registry_name(encoding: &str) -> Cow<'_, str> { + if !encoding + .bytes() + .any(|b| b.is_ascii_uppercase() || b == b' ') + { + return encoding.into(); + } + encoding + .chars() + .map(|c| { + if c == ' ' { + '-' + } else { + c.to_ascii_lowercase() + } + }) + .collect::() + .into() +} + fn normalize_encoding_name(encoding: &str) -> Cow<'_, str> { // _Py_normalize_encoding: collapse non-alphanumeric/non-dot chars into // single underscore, strip non-ASCII, lowercase ASCII letters. diff --git a/crates/vm/src/stdlib/time.rs b/crates/vm/src/stdlib/time.rs index 94c469b53..867891eff 100644 --- a/crates/vm/src/stdlib/time.rs +++ b/crates/vm/src/stdlib/time.rs @@ -18,9 +18,9 @@ mod decl { use crate::convert::{ToPyException, ToPyObject}; use crate::{ AsObject, Py, PyObjectRef, PyResult, VirtualMachine, - builtins::{PyFloat, PyStr, PyStrRef, PyTypeRef}, + builtins::{PyStr, PyStrRef, PyTypeRef}, class::PyClassDef, - common::wtf8::{Wtf8Buf, wtf8_concat}, + common::wtf8::Wtf8Buf, function::{Either, FuncArgs, OptionalArg, OptionalOption}, types::{PyStructSequence, PyStructSequenceData, struct_sequence_new}, }; @@ -128,8 +128,21 @@ mod decl { vm.audit("time.sleep", || (object.clone(),))?; let overflow = || vm.new_overflow_error("timestamp out of range for C PyTime_t"); - let nanoseconds = if let Some(float) = object.downcast_ref::() { - let seconds = float.to_f64(); + let nanoseconds = if object.number().is_index() { + let seconds = object.try_index(vm).map_err(|e| { + if e.fast_isinstance(vm.ctx.exceptions.overflow_error) { + overflow() + } else { + e + } + })?; + seconds + .as_bigint() + .to_i64() + .and_then(|seconds| seconds.checked_mul(SEC_TO_NS)) + .ok_or_else(overflow)? + } else { + let seconds = object.try_float(vm)?.to_f64(); if seconds.is_nan() { return Err(vm.new_value_error("Invalid value NaN (not a number)")); } @@ -146,47 +159,6 @@ mod decl { return Err(overflow()); } nanoseconds as i64 - } else { - let seconds = object.try_index(vm).or_else(|e| { - if e.fast_isinstance(vm.ctx.exceptions.overflow_error) { - Err(overflow()) - } else if e.fast_isinstance(vm.ctx.exceptions.type_error) { - // CPython's %T reads stored type metadata, preserving surrogates. - let class = object.class(); - let name = if let Some(heap_type) = &class.heaptype_ext { - let qualname = heap_type.qualname.read().clone(); - let module = if let Some(dict) = class.attributes.as_dict() { - dict.get_item_opt(identifier!(vm, __module__), vm)? - .ok_or_else(|| vm.new_attribute_error("__module__"))? - } else { - class.__module__(vm)? - }; - match module.downcast_ref::() { - Some(module) - if module.as_wtf8() != "builtins" - && module.as_wtf8() != "__main__" => - { - wtf8_concat!(module.as_wtf8(), ".", qualname.as_wtf8()) - } - _ => qualname.as_wtf8().to_owned(), - } - } else { - Wtf8Buf::from(class.slot_name().to_owned()) - }; - Err(vm.new_type_error(wtf8_concat!( - "'", - name, - "' object cannot be interpreted as an integer or float" - ))) - } else { - Err(e) - } - })?; - seconds - .as_bigint() - .to_i64() - .and_then(|seconds| seconds.checked_mul(SEC_TO_NS)) - .ok_or_else(overflow)? }; if nanoseconds < 0 { return Err(vm.new_value_error("sleep length must be non-negative")); diff --git a/extra_tests/snippets/builtin_mappingproxy.py b/extra_tests/snippets/builtin_mappingproxy.py index c4d302136..8adf4d7e9 100644 --- a/extra_tests/snippets/builtin_mappingproxy.py +++ b/extra_tests/snippets/builtin_mappingproxy.py @@ -25,11 +25,13 @@ assert A.__dict__.get("a", "default") is A.a assert A.__dict__.get("not here") is None -# A mappingproxy must preserve arbitrary rich-comparison results unchanged. +# A mappingproxy preserves arbitrary equality-comparison results unchanged. def check_mappingproxy_comparison_results(): import operator from types import MappingProxyType + from testutils import assert_raises + class Result: def __bool__(self): raise AssertionError("comparison result must not be coerced to bool") @@ -58,10 +60,6 @@ def check_mappingproxy_comparison_results(): for operation, reflected in ( (operator.eq, "eq"), (operator.ne, "ne"), - (operator.lt, "gt"), - (operator.le, "ge"), - (operator.gt, "lt"), - (operator.ge, "le"), ): calls.clear() assert operation(MappingProxyType(CustomMapping()), {}) is result @@ -70,5 +68,19 @@ def check_mappingproxy_comparison_results(): assert operation(MappingProxyType({}), Compared()) is result assert calls == [reflected], calls + for operation, reflected in ( + (operator.lt, "gt"), + (operator.le, "ge"), + (operator.gt, "lt"), + (operator.ge, "le"), + ): + calls.clear() + with assert_raises(TypeError): + operation(MappingProxyType(CustomMapping()), {}) + assert calls == [], calls + calls.clear() + assert operation(MappingProxyType({}), Compared()) is result + assert calls == [reflected], calls + check_mappingproxy_comparison_results() diff --git a/extra_tests/snippets/stdlib_codecs_wide.py b/extra_tests/snippets/stdlib_codecs_wide.py index 4b950ba70..0c45cc91c 100644 --- a/extra_tests/snippets/stdlib_codecs_wide.py +++ b/extra_tests/snippets/stdlib_codecs_wide.py @@ -31,7 +31,7 @@ assert ( # Lookup errors preserve the requested spelling, while search hooks receive -# the Python 3.14 normalized registry key. +# the Python 3.15 normalized registry key. import codecs seen_encoding_names = [] @@ -45,8 +45,8 @@ def record_unknown_encoding(name): codecs.register(record_unknown_encoding) try: for requested, normalized in ( - ("NO Such Codec!", "no_such_codec"), - ("x_ weird--Ω .Ab", "x_weird_.ab"), + ("NO Such Codec!", "no-such--codec!"), + ("x_ weird--Ω .Ab", "x_-weird--Ω-.ab"), ): for lookup in ( codecs.lookup, diff --git a/extra_tests/snippets/stdlib_time.py b/extra_tests/snippets/stdlib_time.py index b5f3a944d..614d96105 100644 --- a/extra_tests/snippets/stdlib_time.py +++ b/extra_tests/snippets/stdlib_time.py @@ -234,13 +234,39 @@ class SleepTypeNamespace: pass -for module, prefix in (("__main__", ""), ("sleep_test", "sleep_test.")): +for module in ("__main__", "sleep_test"): SleepTypeNamespace.Invalid.__module__ = module with assert_raises(TypeError) as error: time.sleep(SleepTypeNamespace.Invalid()) - assert str(error.exception) == ( - f"'{prefix}SleepTypeNamespace.Invalid' object cannot be interpreted as an integer or float" - ) + assert str(error.exception) == "must be real number, not Invalid" + + +class FloatSleepDuration: + def __float__(self): + return -0.5 + + +class IndexedSleepDuration(FloatSleepDuration): + def __index__(self): + return -1 + + def __float__(self): + raise AssertionError("sleep must prefer __index__") + + +class InvalidIndexedSleepDuration(FloatSleepDuration): + def __index__(self): + raise TypeError("index conversion failed") + + +for duration in (FloatSleepDuration(), IndexedSleepDuration()): + with assert_raises(ValueError) as error: + time.sleep(duration) + assert str(error.exception) == "sleep length must be non-negative" + +with assert_raises(TypeError) as error: + time.sleep(InvalidIndexedSleepDuration()) +assert str(error.exception) == "index conversion failed" # The optional second argument fills the fields that are not part of the # sequence.