Skip to content

Commit eab9d05

Browse files
authored
fix pydantic#2293: Properly encode Decimals without any decimal places. (pydantic#2294)
* fix pydantic#2293: Properly encode Decimals without any decimal places. * doc: Added changelog entry. * refactor: Move ConstrainedDecimal test from separate file into test_json * docs: Remove prefix from changelog. * test: Changed test_con_decimal_encode to @samuelcolvins recommendations
1 parent c8883e3 commit eab9d05

File tree

3 files changed

+43
-2
lines changed

3 files changed

+43
-2
lines changed

changes/2293-hultner.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Properly encode `Decimal` with, or without any decimal places.

pydantic/json.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,35 @@ def isoformat(o: Union[datetime.date, datetime.time]) -> str:
1818
return o.isoformat()
1919

2020

21+
def decimal_encoder(dec_value: Decimal) -> Union[int, float]:
22+
"""
23+
Encodes a Decimal as int of there's no exponent, otherwise float
24+
25+
This is useful when we use ConstrainedDecimal to represent Numeric(x,0)
26+
where a integer (but not int typed) is used. Encoding this as a float
27+
results in failed round-tripping between encode and prase.
28+
Our Id type is a prime example of this.
29+
30+
>>> decimal_encoder(Decimal("1.0"))
31+
1.0
32+
33+
>>> decimal_encoder(Decimal("1"))
34+
1
35+
"""
36+
if dec_value.as_tuple().exponent >= 0:
37+
return int(dec_value)
38+
else:
39+
return float(dec_value)
40+
41+
2142
ENCODERS_BY_TYPE: Dict[Type[Any], Callable[[Any], Any]] = {
2243
bytes: lambda o: o.decode(),
2344
Color: str,
2445
datetime.date: isoformat,
2546
datetime.datetime: isoformat,
2647
datetime.time: isoformat,
2748
datetime.timedelta: lambda td: td.total_seconds(),
28-
Decimal: float,
49+
Decimal: decimal_encoder,
2950
Enum: lambda o: o.value,
3051
frozenset: list,
3152
deque: list,

tests/test_json.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from pydantic.color import Color
1717
from pydantic.dataclasses import dataclass as pydantic_dataclass
1818
from pydantic.json import pydantic_encoder, timedelta_isoformat
19-
from pydantic.types import DirectoryPath, FilePath, SecretBytes, SecretStr
19+
from pydantic.types import ConstrainedDecimal, DirectoryPath, FilePath, SecretBytes, SecretStr
2020

2121

2222
class MyEnum(Enum):
@@ -170,6 +170,25 @@ class Config:
170170
assert m.json() == '{"x": "P0DT0H2M3.000000S"}'
171171

172172

173+
def test_con_decimal_encode() -> None:
174+
"""
175+
Makes sure a decimal with decimal_places = 0, as well as one with places
176+
can handle a encode/decode roundtrip.
177+
"""
178+
179+
class Id(ConstrainedDecimal):
180+
max_digits = 22
181+
decimal_places = 0
182+
ge = 0
183+
184+
class Obj(BaseModel):
185+
id: Id
186+
price: Decimal = Decimal('0.01')
187+
188+
assert Obj(id=1).json() == '{"id": 1, "price": 0.01}'
189+
assert Obj.parse_raw('{"id": 1, "price": 0.01}') == Obj(id=1)
190+
191+
173192
def test_json_encoder_simple_inheritance():
174193
class Parent(BaseModel):
175194
dt: datetime.datetime = datetime.datetime.now()

0 commit comments

Comments
 (0)