diff --git a/Lib/_pydatetime.py b/Lib/_pydatetime.py index 6f53f45d524c991..50126d039e830a2 100644 --- a/Lib/_pydatetime.py +++ b/Lib/_pydatetime.py @@ -1570,10 +1570,11 @@ def _cmp(self, other, allow_mixed=False): return 2 # arbitrary non-zero value else: raise TypeError("cannot compare naive and aware times") - myhhmm = self._hour * 60 + self._minute - myoff//timedelta(minutes=1) - othhmm = other._hour * 60 + other._minute - otoff//timedelta(minutes=1) - return _cmp((myhhmm, self._second, self._microsecond), - (othhmm, other._second, other._microsecond)) + myus = (((self._hour * 60 + self._minute) * 60 + self._second) * 1000000 + + self._microsecond - myoff._to_microseconds()) + otus = (((other._hour * 60 + other._minute) * 60 + other._second) * 1000000 + + other._microsecond - otoff._to_microseconds()) + return _cmp(myus, otus) def __hash__(self): """Hash.""" @@ -1583,17 +1584,13 @@ def __hash__(self): else: t = self tzoff = t.utcoffset() - if not tzoff: # zero or None + if tzoff is None: self._hashcode = hash(t._getstate()[0]) else: - h, m = divmod(timedelta(hours=self.hour, minutes=self.minute) - tzoff, - timedelta(hours=1)) - assert not m % timedelta(minutes=1), "whole minute" - m //= timedelta(minutes=1) - if 0 <= h < 24: - self._hashcode = hash(time(h, m, self.second, self.microsecond)) - else: - self._hashcode = hash((h, m, self.second, self.microsecond)) + self._hashcode = hash(timedelta(hours=t.hour, + minutes=t.minute, + seconds=t.second, + microseconds=t.microsecond) - tzoff) return self._hashcode # Conversion to string diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index dae71a6b6679a0f..716c662ad453f4a 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -4693,6 +4693,33 @@ def tzname(self, dt): return self.tz Badtzname.tz = '\ud800' self.assertEqual(t.strftime("%Z"), '\ud800') + def test_subminute_offset_equality(self): + t1 = self.theclass(12, tzinfo=timezone.utc) + t2 = self.theclass(12, 0, 1, tzinfo=timezone(timedelta(seconds=1))) + self.assertEqual(t1, t2) + t2 = self.theclass(12, 0, 0, 1, tzinfo=timezone(timedelta(microseconds=1))) + self.assertEqual(t1, t2) + t2 = self.theclass(11, 59, 59, 999999, tzinfo=timezone(timedelta(microseconds=-1))) + self.assertEqual(t1, t2) + + def test_subminute_offset_ordering(self): + t1 = self.theclass(0, tzinfo=timezone.utc) + t2 = self.theclass(0, tzinfo=timezone(timedelta(microseconds=1))) + self.assertGreater(t1, t2) + + t1 = self.theclass(13, 59, 59, 900000, tzinfo=timezone(timedelta(hours=2))) + t2 = self.theclass(14, tzinfo=timezone(timedelta(hours=2, microseconds=900000))) + self.assertGreater(t1, t2) + + def test_subminute_offset_hash(self): + t1 = self.theclass(12, tzinfo=timezone.utc) + t2 = self.theclass(12, 0, 1, tzinfo=timezone(timedelta(seconds=1))) + self.assertEqual(hash(t1), hash(t2)) + t2 = self.theclass(12, 0, 0, 1, tzinfo=timezone(timedelta(microseconds=1))) + self.assertEqual(hash(t1), hash(t2)) + t2 = self.theclass(11, 59, 59, 999999, tzinfo=timezone(timedelta(microseconds=-1))) + self.assertEqual(hash(t1), hash(t2)) + def test_hash_edge_cases(self): # Offsets that overflow a basic time. t1 = self.theclass(0, 1, 2, 3, tzinfo=FixedOffset(1439, "")) diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index 389cd043d6c0f39..bbcf8d285a67743 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -796,6 +796,9 @@ def test_complexstr(self): self.assertEqual(str(s[1:]), 'bc') self.assertEqual(str(s[::-1]), 'cbA') self.assertEqual(str(s + curses.complexstr(['Z'])), 'AbcZ') + # Concatenating anything else raises instead of returning NotImplemented. + self.assertRaises(TypeError, lambda: s + 'Z') + self.assertRaises(TypeError, lambda: s + cc('Z')) # The empty complexstr. self.assertEqual(len(curses.complexstr([])), 0) self.assertEqual(str(curses.complexstr('')), '') @@ -3383,6 +3386,23 @@ def test_close(self): # close() is idempotent. screen.close() + def test_close_then_write_with_attr_keeps_no_reference(self): + # A write with an *attr* argument on a detached window fails while + # setting the rendition, and has to release the bytes it converted. + s = self.make_pty() + screen = curses.newterm('xterm', s, s) + win = screen.stdscr + screen.close() + writes = [lambda b: win.addstr(b, curses.A_BOLD), + lambda b: win.addnstr(b, 4, curses.A_BOLD), + lambda b: win.insstr(b, curses.A_BOLD), + lambda b: win.insnstr(b, 4, curses.A_BOLD)] + data = b'x' * 8 + nrefs = sys.getrefcount(data) + for write in writes: + self.assertRaises(curses.error, write, data) + self.assertEqual(sys.getrefcount(data), nrefs) + @requires_curses_func('panel') def test_close_then_panel_replace(self): # A detached window has no underlying curses window, so replace() @@ -3479,10 +3499,10 @@ class SLKTests(NewtermTestBase): # slk_init() must run before newterm()/initscr(), so each test sets up its # own screen rather than reusing the one TestCurses builds in setUp(). - def make_slk_screen(self, fmt=0): + def make_slk_screen(self, fmt=0, term='xterm'): s = self.make_pty() curses.slk_init(fmt) - return curses.newterm('xterm', s, s) + return curses.newterm(term, s, s) def test_init_reserves_a_line(self): # Every layout takes the bottom line for the labels; the index-line @@ -3565,6 +3585,26 @@ def test_color(self): curses.slk_attr_set(curses.A_BOLD, 0) curses.slk_color(0) + def test_color_wide_pair(self): + # Drive a terminal with enough color pairs to reach past a short, + # rather than relying on whatever $TERM happens to be. + try: + self.make_slk_screen(term='xterm-256color') + except curses.error: + self.skipTest('no xterm-256color terminfo entry') + if not curses.has_colors(): + self.skipTest('requires colors support') + curses.start_color() + if not (curses.has_extended_color_support() + and curses.COLOR_PAIRS > SHORT_MAX + 1): + self.skipTest('requires extended color support') + # A pair that does not fit in a short is still a valid pair here. + curses.slk_color(SHORT_MAX + 1) + # The low 16 bits of this are pair 5, but the pair itself is out of + # range, so it must raise instead of selecting pair 5. + self.assertRaises(curses.error, curses.slk_color, + curses.COLOR_PAIRS * 2 + 5) + @unittest.skipUnless(hasattr(curses, 'newterm'), 'requires curses.newterm()') @unittest.skipIf(BROKEN_NEWTERM, 'ncurses < 6.5 mishandles repeated newterm()') diff --git a/Misc/NEWS.d/next/Library/2026-08-01-20-21-37.gh-issue-99772.q7M3vP.rst b/Misc/NEWS.d/next/Library/2026-08-01-20-21-37.gh-issue-99772.q7M3vP.rst new file mode 100644 index 000000000000000..45df5211bb637ff --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-01-20-21-37.gh-issue-99772.q7M3vP.rst @@ -0,0 +1,2 @@ +Fix comparisons and hashing of :class:`datetime.time` objects with sub-minute +UTC offsets. diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index 2ff15dd31d21803..24cfbcdd503ceea 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -1590,7 +1590,10 @@ complexstr_concat(PyObject *a, PyObject *b) { cursesmodule_state *state = get_cursesmodule_state_by_cls(Py_TYPE(a)); if (!Py_IS_TYPE(b, state->complexstr_type)) { - Py_RETURN_NOTIMPLEMENTED; + PyErr_Format(PyExc_TypeError, + "can only concatenate complexstr to complexstr, not %T", + b); + return NULL; } PyCursesComplexStrObject *sa = _PyCursesComplexStrObject_CAST(a); PyCursesComplexStrObject *sb = _PyCursesComplexStrObject_CAST(b); @@ -4361,6 +4364,7 @@ _curses_window_insnstr_impl(PyCursesWindowObject *self, int group_left_1, curses_wattrset(self, attr, "insnstr") < 0) { curses_release_wstr(strtype, wstr); + Py_XDECREF(bytesobj); return NULL; } } @@ -8863,7 +8867,13 @@ _curses_slk_color_impl(PyObject *module, int pair) /*[clinic end generated code: output=ffe4de805f9c65f5 input=b1e691a9cc6177ee]*/ { PyCursesStatefulInitialised(module); - return curses_check_err(module, slk_color((short)pair), "slk_color", NULL); + int rtn; +#if _NCURSES_EXTENDED_COLOR_FUNCS + rtn = extended_slk_color(pair); +#else + rtn = slk_color((short)pair); +#endif + return curses_check_err(module, rtn, "slk_color", NULL); } #endif /* HAVE_CURSES_SLK_COLOR */ diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 9a771cd0ad5fbf0..bd76b3bd81cce40 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -5051,22 +5051,33 @@ time_richcompare(PyObject *self, PyObject *other, int op) } /* The hard case: both aware with different UTC offsets */ else if (offset1 != Py_None && offset2 != Py_None) { - int offsecs1, offsecs2; + long long norm_us1, norm_us2; assert(offset1 != offset2); /* else last "if" handled it */ - offsecs1 = TIME_GET_HOUR(self) * 3600 + - TIME_GET_MINUTE(self) * 60 + - TIME_GET_SECOND(self) - - GET_TD_DAYS(offset1) * 86400 - - GET_TD_SECONDS(offset1); - offsecs2 = TIME_GET_HOUR(other) * 3600 + - TIME_GET_MINUTE(other) * 60 + - TIME_GET_SECOND(other) - - GET_TD_DAYS(offset2) * 86400 - - GET_TD_SECONDS(offset2); - diff = offsecs1 - offsecs2; - if (diff == 0) - diff = TIME_GET_MICROSECOND(self) - - TIME_GET_MICROSECOND(other); + norm_us1 = + ((TIME_GET_HOUR(self) * 3600 + + TIME_GET_MINUTE(self) * 60 + + TIME_GET_SECOND(self)) * 1000000LL + + TIME_GET_MICROSECOND(self)) - + ((GET_TD_DAYS(offset1) * 86400LL + + GET_TD_SECONDS(offset1)) * 1000000LL + + GET_TD_MICROSECONDS(offset1)); + norm_us2 = + ((TIME_GET_HOUR(other) * 3600 + + TIME_GET_MINUTE(other) * 60 + + TIME_GET_SECOND(other)) * 1000000LL + + TIME_GET_MICROSECOND(other)) - + ((GET_TD_DAYS(offset2) * 86400LL + + GET_TD_SECONDS(offset2)) * 1000000LL + + GET_TD_MICROSECONDS(offset2)); + if (norm_us1 < norm_us2) { + diff = -1; + } + else if (norm_us1 > norm_us2) { + diff = 1; + } + else { + diff = 0; + } result = diff_to_bool(diff, op); } else if (op == Py_EQ) { diff --git a/Modules/_struct.c b/Modules/_struct.c index 352312fb0b4c19b..8caadf091767e3a 100644 --- a/Modules/_struct.c +++ b/Modules/_struct.c @@ -2424,7 +2424,9 @@ s_pack_internal(PyStructObject *soself, PyObject *const *args, memcpy(res + 1, p, n); if (n > 255) n = 255; - *res = Py_SAFE_DOWNCAST(n, Py_ssize_t, unsigned char); + if (n > 0) { + *res = Py_SAFE_DOWNCAST(n, Py_ssize_t, unsigned char); + } } else { if (e->pack(state, res, v, e) < 0) { if (PyLong_Check(v) && PyErr_ExceptionMatches(PyExc_OverflowError))