Skip to content

Commit 8c04cdb

Browse files
Binilkksclaude
andcommitted
Add Decimal support to json and pyyaml preconf converters (closes #761)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f47bbe0 commit 8c04cdb

3 files changed

Lines changed: 66 additions & 0 deletions

File tree

src/cattrs/preconf/json.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from base64 import b85decode, b85encode
44
from collections.abc import Set
55
from datetime import date, datetime
6+
from decimal import Decimal
67
from json import dumps, loads
78
from typing import Any, TypeVar, Union
89

@@ -32,6 +33,7 @@ def configure_converter(converter: BaseConverter) -> None:
3233
3334
* bytes are serialized as base85 strings
3435
* datetimes are serialized as ISO 8601
36+
* decimals are serialized as strings to preserve precision
3537
* counters are serialized as dicts
3638
* sets are serialized as lists
3739
* string and int enums are passed through when unstructuring
@@ -45,6 +47,8 @@ def configure_converter(converter: BaseConverter) -> None:
4547
bytes, lambda v: (b85encode(v) if v else b"").decode("utf8")
4648
)
4749
converter.register_structure_hook(bytes, lambda v, _: b85decode(v))
50+
converter.register_unstructure_hook(Decimal, str)
51+
converter.register_structure_hook(Decimal, lambda v, _: Decimal(v))
4852
converter.register_unstructure_hook(datetime, lambda v: v.isoformat())
4953
converter.register_structure_hook(datetime, lambda v, _: datetime.fromisoformat(v))
5054
converter.register_unstructure_hook(date, lambda v: v.isoformat())

src/cattrs/preconf/pyyaml.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Preconfigured converters for pyyaml."""
22

33
from datetime import date, datetime
4+
from decimal import Decimal
45
from functools import partial
56
from typing import Any, TypeVar, Union
67

@@ -38,6 +39,7 @@ def configure_converter(converter: BaseConverter) -> None:
3839
* frozensets are serialized as lists
3940
* string enums are converted into strings explicitly
4041
* datetimes and dates are validated
42+
* decimals are serialized as strings to preserve precision
4143
* typed namedtuples are serialized as lists
4244
4345
.. versionchanged:: 24.1.0
@@ -46,6 +48,8 @@ def configure_converter(converter: BaseConverter) -> None:
4648
converter.register_unstructure_hook(
4749
str, lambda v: v if v.__class__ is str else v.value
4850
)
51+
converter.register_unstructure_hook(Decimal, str)
52+
converter.register_structure_hook(Decimal, lambda v, _: Decimal(str(v)))
4953

5054
# datetime inherits from date, so identity unstructure hook used
5155
# here to prevent the date unstructure hook running.

tests/test_preconf.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,3 +1036,61 @@ def test_literal_dicts_msgspec():
10361036
def test_literal_dicts_tomllib():
10371037
"""Dicts with keys that aren't subclasses of `type` work."""
10381038
test_literal_dicts(tomllib_make_converter)
1039+
1040+
1041+
@pytest.mark.parametrize(
1042+
"converter_factory",
1043+
[
1044+
json_make_converter,
1045+
pyyaml_make_converter,
1046+
],
1047+
)
1048+
def test_decimal_round_trip(converter_factory: Callable[[], Converter]):
1049+
"""decimal.Decimal fields are serialized as strings and round-trip correctly."""
1050+
from decimal import Decimal
1051+
1052+
@define
1053+
class Order:
1054+
amount: Decimal
1055+
1056+
converter = converter_factory()
1057+
order = Order(amount=Decimal("19.99"))
1058+
1059+
unstructured = converter.unstructure(order)
1060+
assert unstructured["amount"] == "19.99"
1061+
assert isinstance(unstructured["amount"], str)
1062+
1063+
restored = converter.structure(unstructured, Order)
1064+
assert restored == order
1065+
assert isinstance(restored.amount, Decimal)
1066+
1067+
1068+
def test_decimal_precision_preserved_json():
1069+
"""Decimal precision is preserved (not lost to float) in the json converter."""
1070+
from decimal import Decimal
1071+
1072+
@define
1073+
class Foo:
1074+
value: Decimal
1075+
1076+
converter = json_make_converter()
1077+
# This value cannot be represented exactly as a float
1078+
original = Foo(value=Decimal("0.1") + Decimal("0.2"))
1079+
restored = converter.structure(converter.unstructure(original), Foo)
1080+
assert restored.value == original.value
1081+
1082+
1083+
def test_decimal_json_dumps_loads():
1084+
"""decimal.Decimal works end-to-end with JsonConverter.dumps()/loads()."""
1085+
from decimal import Decimal
1086+
1087+
@define
1088+
class Invoice:
1089+
total: Decimal
1090+
tax: Decimal
1091+
1092+
converter = json_make_converter()
1093+
invoice = Invoice(total=Decimal("99.99"), tax=Decimal("8.50"))
1094+
s = converter.dumps(invoice)
1095+
restored = converter.loads(s, Invoice)
1096+
assert restored == invoice

0 commit comments

Comments
 (0)