Skip to content

Commit

Permalink
✨ Implement optional number to str coercion (#7508)
Browse files Browse the repository at this point in the history
Co-authored-by: sydney-runkle <54324534+sydney-runkle@users.noreply.github.com>
  • Loading branch information
lig and sydney-runkle committed Sep 21, 2023
1 parent 1cb0b78 commit e5323ff
Show file tree
Hide file tree
Showing 7 changed files with 367 additions and 394 deletions.
2 changes: 1 addition & 1 deletion docs/migration.md
Expand Up @@ -333,7 +333,7 @@ This applies to all validation decorators.

Pydantic V2 includes some changes to type coercion. For example:

* int, float, and decimal values are no longer coerced to strings.
* coercing `int`, `float`, and `Decimal` values to strings is now optional and disabled by default, see [Coerce Numbers to Strings](./usage/model_config.md#coerce-numbers-to-strings).
* iterable of pairs is no longer coerced to a dict.

See the [Conversion table](usage/conversion_table.md) for details on Pydantic V2 type coercion defaults.
Expand Down
46 changes: 45 additions & 1 deletion docs/usage/model_config.md
Expand Up @@ -528,7 +528,8 @@ See [Strict Mode](strict_mode.md) for more details.
See the [Conversion Table](conversion_table.md) for more details on how Pydantic converts data in both strict and lax
modes.

### Arbitrary Types Allowed

## Arbitrary Types Allowed

You can allow arbitrary types using the `arbitrary_types_allowed` setting in the model's config:

Expand Down Expand Up @@ -584,6 +585,49 @@ print(type(model2.pet))
#> <class '__main__.Pet'>
```


## Coerce Numbers to Strings

Pydantic no longer allows number types (`int`, `float`, `Decimal`) to be coerced as type `str` by default.

Set [`coerce_numbers_to_str=True`](../api/config.md#pydantic.config.ConfigDict.coerce_numbers_to_str) to enable coercing of numbers to strings.

```py
from decimal import Decimal

from pydantic import BaseModel, ConfigDict, ValidationError


class Model(BaseModel):
value: str


try:
print(Model(value=42))
except ValidationError as e:
print(e)
"""
1 validation error for Model
value
Input should be a valid string [type=string_type, input_value=42, input_type=int]
"""


class Model(BaseModel):
model_config = ConfigDict(coerce_numbers_to_str=True)

value: str


repr(Model(value=42).value)
#> "42"
repr(Model(value=42.13).value)
#> "42.13"
repr(Model(value=Decimal('42.13')).value)
#> "42.13"
```


## Protected Namespaces

Pydantic prevents collisions between model attributes and `BaseModel`'s own methods by
Expand Down
657 changes: 266 additions & 391 deletions pdm.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions pydantic/_internal/_config.py
Expand Up @@ -78,6 +78,7 @@ class ConfigWrapper:
schema_generator: type[GenerateSchema] | None
json_schema_serialization_defaults_required: bool
json_schema_mode_override: Literal['validation', 'serialization', None]
coerce_numbers_to_str: bool

def __init__(self, config: ConfigDict | dict[str, Any] | type[Any] | None, *, check: bool = True):
if check:
Expand Down Expand Up @@ -173,6 +174,7 @@ def dict_not_none(**kwargs: Any) -> Any:
str_max_length=self.config_dict.get('str_max_length'),
str_min_length=self.config_dict.get('str_min_length'),
hide_input_in_errors=self.config_dict.get('hide_input_in_errors'),
coerce_numbers_to_str=self.config_dict.get('coerce_numbers_to_str'),
)
)
return core_config
Expand Down Expand Up @@ -243,6 +245,7 @@ def _context_manager() -> Iterator[None]:
schema_generator=None,
json_schema_serialization_defaults_required=False,
json_schema_mode_override=None,
coerce_numbers_to_str=False,
)


Expand Down
7 changes: 7 additions & 0 deletions pydantic/config.py
Expand Up @@ -230,5 +230,12 @@ class without an annotation and has a type that is not in this tuple (or otherwi
Defaults to `None`.
"""

coerce_numbers_to_str: bool
"""
If `True`, enables automatic coercion of any `Number` type to `str` in "lax" (non-strict) mode.
Defaults to `False`.
"""


__getattr__ = getattr_migration(__name__)
2 changes: 1 addition & 1 deletion pyproject.toml
Expand Up @@ -61,7 +61,7 @@ requires-python = '>=3.7'
dependencies = [
'typing-extensions>=4.6.1',
'annotated-types>=0.4.0',
"pydantic-core==2.8.0",
"pydantic-core==2.9.0",
]
dynamic = ['version', 'readme']

Expand Down
44 changes: 44 additions & 0 deletions tests/test_types.py
Expand Up @@ -12,6 +12,7 @@
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal
from enum import Enum, IntEnum
from numbers import Number
from pathlib import Path
from typing import (
Any,
Expand Down Expand Up @@ -5870,3 +5871,46 @@ def test_decimal_float_precision() -> None:
assert ta.validate_python('1.1') == Decimal('1.1')
assert ta.validate_json('1') == Decimal('1')
assert ta.validate_python(1) == Decimal('1')


def test_coerce_numbers_to_str_disabled_in_strict_mode() -> None:
class Model(BaseModel):
model_config = ConfigDict(strict=True, coerce_numbers_to_str=True)
value: str

with pytest.raises(ValidationError, match='value'):
Model.model_validate({'value': 42})
with pytest.raises(ValidationError, match='value'):
Model.model_validate_json('{"value": 42}')


@pytest.mark.parametrize(
('number', 'expected_str'),
[
pytest.param(42, '42', id='42'),
pytest.param(42.0, '42.0', id='42.0'),
pytest.param(Decimal('42.0'), '42.0', id="Decimal('42.0')"),
],
)
def test_coerce_numbers_to_str(number: Number, expected_str: str) -> None:
class Model(BaseModel):
model_config = ConfigDict(coerce_numbers_to_str=True)
value: str

assert Model.model_validate({'value': number}).model_dump() == {'value': expected_str}


@pytest.mark.parametrize(
('number', 'expected_str'),
[
pytest.param('42', '42', id='42'),
pytest.param('42.0', '42', id='42.0'),
pytest.param('42.13', '42.13', id='42.13'),
],
)
def test_coerce_numbers_to_str_from_json(number: str, expected_str: str) -> None:
class Model(BaseModel):
model_config = ConfigDict(coerce_numbers_to_str=True)
value: str

assert Model.model_validate_json(f'{{"value": {number}}}').model_dump() == {'value': expected_str}

0 comments on commit e5323ff

Please sign in to comment.