diff --git a/Lib/_pydecimal.py b/Lib/_pydecimal.py index 4b7bfd07887bd3..1e9434f323214e 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,9 @@ 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 +1748,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 +1773,36 @@ 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) + # check if rounding pushed the exponent out of range + with context._defer_traps(): + 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 +2450,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 +2560,12 @@ 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 +2652,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 +2762,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 +3655,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 +3998,11 @@ def __init__(self, prec=None, rounding=None, Emin=None, Emax=None, else: self._ignored_flags = _ignored_flags + # _defer_traps state: nesting level and the deferred exception. + # 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 +4061,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 +4149,42 @@ def _raise_error(self, condition, explanation = None, *args): # The errors define how to handle themselves. return condition().handle(self, *args) + if self._deferred_traps: + # 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) + # 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. + + _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: + yield + except BaseException: + # 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 + 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 424d3f577e9f87..64dca8e2a19f8b 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 @@ -4234,6 +4287,56 @@ 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) + + 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.""" 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 00000000000000..ca327effa7ba8c --- /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.