Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Implement optional number to str coercion #7508

Merged
merged 4 commits into from Sep 21, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
42 changes: 41 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,45 @@ 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 pydantic import BaseModel, ConfigDict, ValidationError
lig marked this conversation as resolved.
Show resolved Hide resolved


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"
lig marked this conversation as resolved.
Show resolved Hide resolved
```


## 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}