Skip to content

Commit b0255b1

Browse files
fix(data-classes): reject inexact numbers and preserve scientific zero
1 parent 54e5c4a commit b0255b1

3 files changed

Lines changed: 87 additions & 13 deletions

File tree

‎aws_lambda_powertools/shared/dynamodb_deserializer.py‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@
1515
traps=[Clamped, Overflow, Inexact, Rounded, Underflow],
1616
)
1717

18-
# Strips trailing zeros from numbers longer than 38 digits without trapping
18+
# Allow removing trailing zeros, but reject any loss of precision.
1919
_NORMALIZE_CONTEXT = Context(prec=38)
20+
_NORMALIZE_CONTEXT.traps[Inexact] = True
2021

2122

2223
class TypeDeserializer:
@@ -78,10 +79,8 @@ def _deserialize_bool(self, value: bool) -> bool:
7879
return value
7980

8081
def _deserialize_n(self, value: str) -> Decimal:
81-
# value is None or "."? It's zero
82-
# then return early
83-
value = value.lstrip("0")
84-
if not value or value == ".":
82+
# Preserve the existing fallback for empty strings and a lone decimal point.
83+
if value in ("", "."):
8584
return DYNAMODB_CONTEXT.create_decimal(0)
8685

8786
number = Decimal(value)

‎tests/unit/data_classes/required_dependencies/test_dynamo_db_stream_event.py‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from decimal import Clamped, Context, Inexact, Overflow, Rounded, Underflow
44

5+
import pytest
6+
57
from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import (
68
DynamoDBRecordEventName,
79
DynamoDBStreamEvent,
@@ -97,19 +99,19 @@ def test_dynamodb_stream_record_deserialization_large_int():
9799
}
98100

99101

100-
def test_dynamodb_stream_record_deserialization_large_int_without_trailing_zeros():
102+
@pytest.mark.parametrize("image", ["Keys", "NewImage", "OldImage"])
103+
def test_dynamodb_stream_record_deserialization_rejects_inexact_numbers(image):
101104
data = {
102-
"Keys": {"key1": {"attr1": "value1"}},
103-
"NewImage": {
105+
image: {
104106
"Name": {"S": "Joe"},
105107
"Age": {"N": "000000011011111111111112222222222221111111111111111111111"},
106108
},
107109
}
108110
record = StreamRecord(data)
109-
assert record.new_image == {
110-
"Name": "Joe",
111-
"Age": DECIMAL_CONTEXT.create_decimal("1.1011111111111112222222222221111111111E+49"),
112-
}
111+
attribute = {"Keys": "keys", "NewImage": "new_image", "OldImage": "old_image"}[image]
112+
113+
with pytest.raises(Inexact):
114+
getattr(record, attribute)
113115

114116

115117
def test_dynamodb_stream_record_deserialization_zero_value():

‎tests/unit/shared/test_dynamodb_deserializer.py‎

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from decimal import Decimal
3+
from decimal import ROUND_UP, Decimal, Inexact, Overflow, Rounded, localcontext
44
from typing import Any
55

66
import pytest
@@ -65,3 +65,76 @@ def test_deserializer_error():
6565
)
6666
def test_deserializer_keeps_value_of_numbers_with_38_digits_of_precision(value):
6767
assert TypeDeserializer().deserialize({"N": value}) == Decimal(value)
68+
69+
70+
@pytest.mark.parametrize(
71+
"value",
72+
[
73+
"9" * 39,
74+
"-" + "9" * 39,
75+
"1" * 37 + "25",
76+
"1" * 37 + "35",
77+
"1." + "2" * 38,
78+
"123456789012345678901234567890123456789000",
79+
"123456789012345678901234567890123456789E-100",
80+
"1" * 39 + "E-9999999",
81+
],
82+
)
83+
def test_deserializer_rejects_inexact_numbers(value):
84+
with pytest.raises(Inexact):
85+
TypeDeserializer().deserialize({"N": value})
86+
87+
88+
@pytest.mark.parametrize("value", ["0E+0", "0E-100", "0e-130", "000E+5", "+0E-10", "-0E-100"])
89+
def test_deserializer_preserves_scientific_zero(value):
90+
assert TypeDeserializer().deserialize({"N": value}) == Decimal(0)
91+
92+
93+
@pytest.mark.parametrize("value", ["", ".", "0", "000", "000.", "0.000", "-0", "+0"])
94+
def test_deserializer_preserves_existing_zero_handling(value):
95+
assert TypeDeserializer().deserialize({"N": value}) == Decimal(0)
96+
97+
98+
@pytest.mark.parametrize(
99+
"value",
100+
[
101+
"00012345678901234567890123456789012345678000",
102+
"-12345678901234567890123456789012345678000",
103+
"1.2345678901234567890123456789012345678000",
104+
"12345678901234567890123456789012345678000E-40",
105+
"1000000000000000000000000000000000000000E-167",
106+
"99999999999999999999999999999999999999000E+85",
107+
],
108+
)
109+
def test_deserializer_removes_only_exact_trailing_zeros(value):
110+
# Application Decimal settings must not affect deserialization.
111+
with localcontext() as context:
112+
context.prec = 6
113+
context.rounding = ROUND_UP
114+
context.traps[Inexact] = True
115+
context.traps[Rounded] = True
116+
assert TypeDeserializer().deserialize({"N": value}) == Decimal(value)
117+
118+
119+
@pytest.mark.parametrize(
120+
"value",
121+
[
122+
{"NS": ["1", "9" * 39]},
123+
{"L": [{"N": "9" * 39}]},
124+
{"M": {"amount": {"N": "9" * 39}}},
125+
],
126+
)
127+
def test_deserializer_rejects_inexact_numbers_in_collections(value):
128+
with pytest.raises(Inexact):
129+
TypeDeserializer().deserialize(value)
130+
131+
132+
def test_deserializer_keeps_distinct_number_set_values():
133+
values = ["1.2345678901234567890123456789012345677", "1.2345678901234567890123456789012345678"]
134+
135+
assert TypeDeserializer().deserialize({"NS": values}) == {Decimal(value) for value in values}
136+
137+
138+
def test_deserializer_normalization_preserves_overflow_errors():
139+
with pytest.raises(Overflow):
140+
TypeDeserializer().deserialize({"N": "1" * 38 + "00E+9999999"})

0 commit comments

Comments
 (0)