From 324e70e01af24f605aa90f10cb39a579bfafe4ce Mon Sep 17 00:00:00 2001 From: Arpit Khandelwal <71580150+arpittkhandelwal@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:41:06 +0530 Subject: [PATCH 1/5] gh-160945: Fix _pydecimal losing companion signal flags when a signal is trapped --- Lib/_pydecimal.py | 191 ++++++++++++------ Lib/test/test_decimal.py | 53 +++++ ...-10-30-09-39-28.gh-issue-160945.b3kR7x.rst | 5 + 3 files changed, 184 insertions(+), 65 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-30-09-39-28.gh-issue-160945.b3kR7x.rst diff --git a/Lib/_pydecimal.py b/Lib/_pydecimal.py index 4b7bfd07887bd38..7ce6f50d0ea9c38 100644 --- a/Lib/_pydecimal.py +++ b/Lib/_pydecimal.py @@ -62,6 +62,7 @@ import math as _math import numbers as _numbers import sys +from contextlib import contextmanager as _contextmanager try: from collections import namedtuple as _namedtuple @@ -1499,8 +1500,10 @@ def __divmod__(self, other, context=None): ans = context._raise_error(DivisionUndefined, 'divmod(0, 0)') return ans, ans else: - return (context._raise_error(DivisionByZero, 'x // 0', sign), - context._raise_error(InvalidOperation, 'x % 0')) + with context._defer_traps(): + return (context._raise_error(DivisionByZero, 'x // 0', + sign), + context._raise_error(InvalidOperation, 'x % 0')) quotient, remainder = self._divide(other, context) remainder = remainder._fix(context) @@ -1746,9 +1749,10 @@ def _fix(self, context): exp_min = len(self._int) + self._exp - context.prec if exp_min > Etop: # overflow: exp_min > Etop iff self.adjusted() > Emax - ans = context._raise_error(Overflow, 'above Emax', self._sign) - context._raise_error(Inexact) - context._raise_error(Rounded) + with context._defer_traps(): + ans = context._raise_error(Overflow, 'above Emax', self._sign) + context._raise_error(Inexact) + context._raise_error(Rounded) return ans self_is_subnormal = exp_min < Etiny @@ -1770,34 +1774,38 @@ def _fix(self, context): coeff = coeff[:-1] exp_min += 1 - # check whether the rounding pushed the exponent out of range - if exp_min > Etop: - ans = context._raise_error(Overflow, 'above Emax', self._sign) - else: - ans = _dec_from_triple(self._sign, coeff, exp_min) + with context._defer_traps(): + # check whether the rounding pushed the exponent out + # of range + if exp_min > Etop: + ans = context._raise_error(Overflow, 'above Emax', + self._sign) + else: + ans = _dec_from_triple(self._sign, coeff, exp_min) + + # raise the appropriate signals, taking care to respect + # the precedence described in the specification + if changed and self_is_subnormal: + context._raise_error(Underflow) + if self_is_subnormal: + context._raise_error(Subnormal) + if changed: + context._raise_error(Inexact) + context._raise_error(Rounded) + if not ans: + # raise Clamped on underflow to 0 + context._raise_error(Clamped) + return ans - # raise the appropriate signals, taking care to respect - # the precedence described in the specification - if changed and self_is_subnormal: - context._raise_error(Underflow) + with context._defer_traps(): if self_is_subnormal: context._raise_error(Subnormal) - if changed: - context._raise_error(Inexact) - context._raise_error(Rounded) - if not ans: - # raise Clamped on underflow to 0 - context._raise_error(Clamped) - return ans - - if self_is_subnormal: - context._raise_error(Subnormal) - # fold down if clamp == 1 and self has too few digits - if context.clamp == 1 and self._exp > Etop: - context._raise_error(Clamped) - self_padded = self._int + '0'*(self._exp - Etop) - return _dec_from_triple(self._sign, self_padded, Etop) + # fold down if clamp == 1 and self has too few digits + if context.clamp == 1 and self._exp > Etop: + context._raise_error(Clamped) + self_padded = self._int + '0'*(self._exp - Etop) + return _dec_from_triple(self._sign, self_padded, Etop) # here self was representable to begin with; return unchanged return Decimal(self) @@ -2445,8 +2453,9 @@ def __pow__(self, other, modulo=None, context=None): exp = 1-context.prec context._raise_error(Rounded) else: - context._raise_error(Inexact) - context._raise_error(Rounded) + with context._defer_traps(): + context._raise_error(Inexact) + context._raise_error(Rounded) exp = 1-context.prec return _dec_from_triple(result_sign, '1'+'0'*-exp, exp) @@ -2554,11 +2563,13 @@ def __pow__(self, other, modulo=None, context=None): # Inexact, Rounded, Clamped. Overflow needs the correct # arguments. Note that the order of the exceptions is # important here. - if newcontext.flags[Overflow]: - context._raise_error(Overflow, 'above Emax', ans._sign) - for exception in Underflow, Subnormal, Inexact, Rounded, Clamped: - if newcontext.flags[exception]: - context._raise_error(exception) + with context._defer_traps(): + if newcontext.flags[Overflow]: + context._raise_error(Overflow, 'above Emax', ans._sign) + for exception in (Underflow, Subnormal, Inexact, Rounded, + Clamped): + if newcontext.flags[exception]: + context._raise_error(exception) else: ans = ans._fix(context) @@ -2645,17 +2656,18 @@ def quantize(self, exp, rounding=None, context=None): return context._raise_error(InvalidOperation, 'quantize result has too many digits for current context') - # raise appropriate flags - if ans and ans.adjusted() < context.Emin: - context._raise_error(Subnormal) - if ans._exp > self._exp: - if ans != self: - context._raise_error(Inexact) - context._raise_error(Rounded) + with context._defer_traps(): + # raise appropriate flags + if ans and ans.adjusted() < context.Emin: + context._raise_error(Subnormal) + if ans._exp > self._exp: + if ans != self: + context._raise_error(Inexact) + context._raise_error(Rounded) - # call to fix takes care of any necessary folddown, and - # signals Clamped if necessary - ans = ans._fix(context) + # call to fix takes care of any necessary folddown, and + # signals Clamped if necessary + ans = ans._fix(context) return ans def same_quantum(self, other, context=None): @@ -2754,9 +2766,10 @@ def to_integral_exact(self, rounding=None, context=None): if rounding is None: rounding = context.rounding ans = self._rescale(0, rounding) - if ans != self: - context._raise_error(Inexact) - context._raise_error(Rounded) + with context._defer_traps(): + if ans != self: + context._raise_error(Inexact) + context._raise_error(Rounded) return ans def to_integral_value(self, rounding=None, context=None): @@ -3646,21 +3659,22 @@ def next_toward(self, other, context=None): ans = self.next_minus(context) # decide which flags to raise using value of ans - if ans._isinfinity(): - context._raise_error(Overflow, - 'Infinite result from next_toward', - ans._sign) - context._raise_error(Inexact) - context._raise_error(Rounded) - elif ans.adjusted() < context.Emin: - context._raise_error(Underflow) - context._raise_error(Subnormal) - context._raise_error(Inexact) - context._raise_error(Rounded) - # if precision == 1 then we don't raise Clamped for a - # result 0E-Etiny. - if not ans: - context._raise_error(Clamped) + with context._defer_traps(): + if ans._isinfinity(): + context._raise_error(Overflow, + 'Infinite result from next_toward', + ans._sign) + context._raise_error(Inexact) + context._raise_error(Rounded) + elif ans.adjusted() < context.Emin: + context._raise_error(Underflow) + context._raise_error(Subnormal) + context._raise_error(Inexact) + context._raise_error(Rounded) + # if precision == 1 then we don't raise Clamped for a + # result 0E-Etiny. + if not ans: + context._raise_error(Clamped) return ans @@ -3988,6 +4002,12 @@ def __init__(self, prec=None, rounding=None, Emin=None, Emax=None, else: self._ignored_flags = _ignored_flags + # State for _defer_traps: the nesting level of deferral blocks + # and the first trapped exception waiting to be raised by the + # outermost block. Transient: not copied or pickled. + self._deferred_traps = 0 + self._deferred_error = None + if traps is None: self.traps = dc.traps.copy() elif not isinstance(traps, dict): @@ -4046,7 +4066,7 @@ def __setattr__(self, name, value): return object.__setattr__(self, name, value) elif name == 'flags' or name == 'traps': return self._set_signal_dict(name, value) - elif name == '_ignored_flags': + elif name in ('_ignored_flags', '_deferred_traps', '_deferred_error'): return object.__setattr__(self, name, value) else: raise AttributeError( @@ -4134,10 +4154,51 @@ def _raise_error(self, condition, explanation = None, *args): # The errors define how to handle themselves. return condition().handle(self, *args) + if self._deferred_traps: + # A _defer_traps block is recording the signals of a + # multi-signal operation: remember the first trapped + # exception instead of raising it now, so that the + # remaining signals can still set their flags. The block + # raises it on exit. Return the default response, exactly + # as if the signal had not been trapped. + if self._deferred_error is None: + self._deferred_error = error(explanation) + return condition().handle(self, *args) + # Errors should only be risked on copies of the context # self._ignored_flags = [] raise error(explanation) + @_contextmanager + def _defer_traps(self): + """Defer trapped signals until the end of the with block. + + Some operations signal several conditions at once (for + example, Overflow together with Inexact and Rounded). The + specification requires the flag of every condition encountered + to be set, and the C implementation records the full set of + flags before raising a trapped exception. Within this block, + _raise_error does the same: it sets the flag of each signal + and returns its default response, and the first trapped + exception (in the order the signals are raised, which is the + precedence order of the specification) is raised when the + outermost block exits. If the block raises another exception, + any deferred exception is discarded. + """ + self._deferred_traps += 1 + try: + yield + except BaseException: + if self._deferred_traps == 1: + self._deferred_error = None + raise + finally: + self._deferred_traps -= 1 + if self._deferred_traps == 0 and self._deferred_error is not None: + error = self._deferred_error + self._deferred_error = None + raise error + def _ignore_all_flags(self): """Ignore all flags, if they are raised""" return self._ignore_flags(*_signals) diff --git a/Lib/test/test_decimal.py b/Lib/test/test_decimal.py index 424d3f577e9f878..6128e33f0d63aca 100644 --- a/Lib/test/test_decimal.py +++ b/Lib/test/test_decimal.py @@ -4014,6 +4014,59 @@ def raise_error(context, flag): "operation raises different flags depending on flags set: " + "expected %s, got %s" % (expected_flags, new_flags)) + def test_trapped_signal_sets_companion_flags(self): + # If an operation signals several conditions and one of them is + # trapped, the exception is raised, but the flags of all the + # other conditions encountered must be set as well. + Decimal = self.decimal.Decimal + Context = self.decimal.Context + Clamped = self.decimal.Clamped + DivisionByZero = self.decimal.DivisionByZero + Inexact = self.decimal.Inexact + InvalidOperation = self.decimal.InvalidOperation + Overflow = self.decimal.Overflow + Rounded = self.decimal.Rounded + Subnormal = self.decimal.Subnormal + Underflow = self.decimal.Underflow + + def check(trap, operation, expected_flags): + context = Context(prec=9, Emin=-999, Emax=999, traps=[]) + context.traps[trap] = 1 + with self.assertRaises(trap): + operation(context) + flags = {signal for signal, v in context.flags.items() if v} + self.assertEqual(flags, expected_flags) + + # overflow from _fix: Inexact and Rounded are also signaled + check(Overflow, + lambda c: c.multiply(Decimal('1E+800'), Decimal('1E+800')), + {Inexact, Overflow, Rounded}) + + # 1/3 is rounded: Rounded accompanies the trapped Inexact + check(Inexact, + lambda c: c.divide(Decimal(1), Decimal(3)), + {Inexact, Rounded}) + + # underflow to zero signals the full set of conditions; a trap + # on any of them must not suppress the flags of the others + check(Underflow, + lambda c: c.divide(Decimal('1E-999'), Decimal('1E+10')), + {Clamped, Inexact, Rounded, Subnormal, Underflow}) + check(Subnormal, + lambda c: c.divide(Decimal('1E-999'), Decimal('1E+10')), + {Clamped, Inexact, Rounded, Subnormal, Underflow}) + + # divmod by zero signals DivisionByZero (for the quotient) and + # InvalidOperation (for the remainder) + check(DivisionByZero, + lambda c: c.divmod(Decimal(5), Decimal(0)), + {DivisionByZero, InvalidOperation}) + + # to_integral_exact signals Inexact and Rounded together + check(Inexact, + lambda c: c.to_integral_exact(Decimal('2.5')), + {Inexact, Rounded}) + def test_flag_comparisons(self): Context = self.decimal.Context Inexact = self.decimal.Inexact diff --git a/Misc/NEWS.d/next/Library/2026-10-30-09-39-28.gh-issue-160945.b3kR7x.rst b/Misc/NEWS.d/next/Library/2026-10-30-09-39-28.gh-issue-160945.b3kR7x.rst new file mode 100644 index 000000000000000..ca327effa7ba8c9 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-30-09-39-28.gh-issue-160945.b3kR7x.rst @@ -0,0 +1,5 @@ +Fixed :mod:`!_pydecimal` losing the context flags of companion conditions +when a signal was trapped. After an operation that signals several +conditions at once (for example an overflowing multiplication), the flags +for *all* encountered conditions are now set, matching :mod:`!_decimal`; +the exception raised is unchanged. From 4a5a3f1a81e0532d8317bd70709f8a6e7871ff59 Mon Sep 17 00:00:00 2001 From: Arpit Khandelwal <71580150+arpittkhandelwal@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:14:48 +0530 Subject: [PATCH 2/5] Trim _defer_traps prose and drop an unrelated comment reflow --- Lib/_pydecimal.py | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/Lib/_pydecimal.py b/Lib/_pydecimal.py index 7ce6f50d0ea9c38..f2a06e5246af43a 100644 --- a/Lib/_pydecimal.py +++ b/Lib/_pydecimal.py @@ -1775,8 +1775,7 @@ def _fix(self, context): exp_min += 1 with context._defer_traps(): - # check whether the rounding pushed the exponent out - # of range + # check if rounding pushed the exponent out of range if exp_min > Etop: ans = context._raise_error(Overflow, 'above Emax', self._sign) @@ -4002,9 +4001,8 @@ def __init__(self, prec=None, rounding=None, Emin=None, Emax=None, else: self._ignored_flags = _ignored_flags - # State for _defer_traps: the nesting level of deferral blocks - # and the first trapped exception waiting to be raised by the - # outermost block. Transient: not copied or pickled. + # _defer_traps state: nesting level and the deferred exception. + # Transient: not copied or pickled. self._deferred_traps = 0 self._deferred_error = None @@ -4155,12 +4153,9 @@ def _raise_error(self, condition, explanation = None, *args): return condition().handle(self, *args) if self._deferred_traps: - # A _defer_traps block is recording the signals of a - # multi-signal operation: remember the first trapped - # exception instead of raising it now, so that the - # remaining signals can still set their flags. The block - # raises it on exit. Return the default response, exactly - # as if the signal had not been trapped. + # Inside a _defer_traps block: record the first trapped + # exception instead of raising it, and return the default + # response as if the signal had not been trapped. if self._deferred_error is None: self._deferred_error = error(explanation) return condition().handle(self, *args) @@ -4173,17 +4168,9 @@ def _raise_error(self, condition, explanation = None, *args): def _defer_traps(self): """Defer trapped signals until the end of the with block. - Some operations signal several conditions at once (for - example, Overflow together with Inexact and Rounded). The - specification requires the flag of every condition encountered - to be set, and the C implementation records the full set of - flags before raising a trapped exception. Within this block, - _raise_error does the same: it sets the flag of each signal - and returns its default response, and the first trapped - exception (in the order the signals are raised, which is the - precedence order of the specification) is raised when the - outermost block exits. If the block raises another exception, - any deferred exception is discarded. + _raise_error sets the flag of every signal in the block and + raises the first trapped exception on exit, so that a + multi-signal operation sets the same flags as _decimal. """ self._deferred_traps += 1 try: From 22b0491e284cd8a28b014e5ba60898a5dd36e8b5 Mon Sep 17 00:00:00 2001 From: Arpit Khandelwal <71580150+arpittkhandelwal@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:30:17 +0530 Subject: [PATCH 3/5] Address picnixz review: restore divmod line, use set literal, add deferred-attrs test --- Lib/_pydecimal.py | 5 ++--- Lib/test/test_decimal.py | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/Lib/_pydecimal.py b/Lib/_pydecimal.py index f2a06e5246af43a..c5157ca0de814a2 100644 --- a/Lib/_pydecimal.py +++ b/Lib/_pydecimal.py @@ -1501,8 +1501,7 @@ def __divmod__(self, other, context=None): return ans, ans else: with context._defer_traps(): - return (context._raise_error(DivisionByZero, 'x // 0', - sign), + return (context._raise_error(DivisionByZero, 'x // 0', sign), context._raise_error(InvalidOperation, 'x % 0')) quotient, remainder = self._divide(other, context) @@ -4064,7 +4063,7 @@ def __setattr__(self, name, value): return object.__setattr__(self, name, value) elif name == 'flags' or name == 'traps': return self._set_signal_dict(name, value) - elif name in ('_ignored_flags', '_deferred_traps', '_deferred_error'): + elif name in {'_ignored_flags', '_deferred_traps', '_deferred_error'}: return object.__setattr__(self, name, value) else: raise AttributeError( diff --git a/Lib/test/test_decimal.py b/Lib/test/test_decimal.py index 6128e33f0d63aca..d03968d70875191 100644 --- a/Lib/test/test_decimal.py +++ b/Lib/test/test_decimal.py @@ -4287,6 +4287,30 @@ def test_signaldict_repr(self): class PyContextFlags(ContextFlags, unittest.TestCase): decimal = P + def test_deferred_traps_attributes(self): + # The _defer_traps state lives in the _deferred_traps and + # _deferred_error attributes, which __setattr__ must accept. + Context = self.decimal.Context + Decimal = self.decimal.Decimal + Overflow = self.decimal.Overflow + context = Context(prec=9, Emin=-999, Emax=999, traps=[]) + self.assertEqual(context._deferred_traps, 0) + self.assertIsNone(context._deferred_error) + context._deferred_traps = 1 + context._deferred_traps = 0 + context._deferred_error = Overflow('test') + context._deferred_error = None + with self.assertRaises(AttributeError): + context._no_such_attribute = 1 + + # A trapped operation resets the state, so the context + # remains usable afterwards. + context.traps[Overflow] = 1 + with self.assertRaises(Overflow): + context.multiply(Decimal('1E+800'), Decimal('1E+800')) + self.assertEqual(context._deferred_traps, 0) + self.assertIsNone(context._deferred_error) + class SpecialContexts: """Test the context templates.""" From 157fd467d4243b5f9d09b2196900f823549ae6ca Mon Sep 17 00:00:00 2001 From: Arpit Khandelwal <71580150+arpittkhandelwal@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:16:24 +0530 Subject: [PATCH 4/5] Address picnixz review: single-line _raise_error call, move comment above with, drop redundant parens --- Lib/_pydecimal.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Lib/_pydecimal.py b/Lib/_pydecimal.py index c5157ca0de814a2..7d2bcc973203ea8 100644 --- a/Lib/_pydecimal.py +++ b/Lib/_pydecimal.py @@ -1773,11 +1773,10 @@ def _fix(self, context): coeff = coeff[:-1] exp_min += 1 + # check if rounding pushed the exponent out of range with context._defer_traps(): - # check if rounding pushed the exponent out of range if exp_min > Etop: - ans = context._raise_error(Overflow, 'above Emax', - self._sign) + ans = context._raise_error(Overflow, 'above Emax', self._sign) else: ans = _dec_from_triple(self._sign, coeff, exp_min) @@ -2564,8 +2563,7 @@ def __pow__(self, other, modulo=None, context=None): with context._defer_traps(): if newcontext.flags[Overflow]: context._raise_error(Overflow, 'above Emax', ans._sign) - for exception in (Underflow, Subnormal, Inexact, Rounded, - Clamped): + for exception in Underflow, Subnormal, Inexact, Rounded, Clamped: if newcontext.flags[exception]: context._raise_error(exception) From 789b58bece3071f17e27e77e5ea1e4318f162de8 Mon Sep 17 00:00:00 2001 From: Arpit Khandelwal <71580150+arpittkhandelwal@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:47:45 +0530 Subject: [PATCH 5/5] Address picnixz review: discard deferred error on any exceptional exit --- Lib/_pydecimal.py | 8 +++++--- Lib/test/test_decimal.py | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/Lib/_pydecimal.py b/Lib/_pydecimal.py index 7d2bcc973203ea8..1e9434f323214e9 100644 --- a/Lib/_pydecimal.py +++ b/Lib/_pydecimal.py @@ -4165,7 +4165,7 @@ def _raise_error(self, condition, explanation = None, *args): def _defer_traps(self): """Defer trapped signals until the end of the with block. - _raise_error sets the flag of every signal in the block and + _raise_error() sets the flag of every signal in the block and raises the first trapped exception on exit, so that a multi-signal operation sets the same flags as _decimal. """ @@ -4173,8 +4173,10 @@ def _defer_traps(self): try: yield except BaseException: - if self._deferred_traps == 1: - self._deferred_error = None + # The block is aborted, so the deferred error is + # discarded: the propagating exception wins, and the + # context does not retain an exception object. + self._deferred_error = None raise finally: self._deferred_traps -= 1 diff --git a/Lib/test/test_decimal.py b/Lib/test/test_decimal.py index d03968d70875191..64dca8e2a19f8b3 100644 --- a/Lib/test/test_decimal.py +++ b/Lib/test/test_decimal.py @@ -4311,6 +4311,32 @@ def test_deferred_traps_attributes(self): self.assertEqual(context._deferred_traps, 0) self.assertIsNone(context._deferred_error) + def test_deferred_traps_exception_discards_error(self): + # If an exception propagates through a _defer_traps block, the + # deferred error is discarded at every nesting level and the + # state is reset, so the propagating exception wins and the + # context stays usable. + Context = self.decimal.Context + Decimal = self.decimal.Decimal + Overflow = self.decimal.Overflow + context = Context(prec=9, Emin=-999, Emax=999, traps=[Overflow]) + with self.assertRaises(RuntimeError): + with context._defer_traps(): + with context._defer_traps(): + context._raise_error(Overflow, 'test', 1) + raise RuntimeError('boom') + self.assertEqual(context._deferred_traps, 0) + self.assertIsNone(context._deferred_error) + + # The flag was still set, and a later trapped operation + # raises its own error rather than the discarded one. + self.assertTrue(context.flags[Overflow]) + context.clear_flags() + with self.assertRaises(Overflow): + context.multiply(Decimal('1E+800'), Decimal('1E+800')) + self.assertEqual(context._deferred_traps, 0) + self.assertIsNone(context._deferred_error) + class SpecialContexts: """Test the context templates."""