Repository: samuelcolvin/pydantic
Pull request #3507 (2021-12-10 17:08:03)

✨ Add support for decimal-specific configs in `Field()`
<!-- Thank you for your contribution! -->
<!-- Unless your change is trivial, please create an issue to discuss the change before creating a PR -->
<!-- See https://pydantic-docs.helpmanual.io/contributing/ for help on Contributing -->

## Change Summary

<!-- Please give a short summary of the changes. -->

This adds support for `Decimal`-specific configs, that would only be supported with `condecimal()`, in `Field()`.

I want this in particular here to then be able to also support it in SQLModel. :nerd_face: 

This means that an example like this:

```Python
from pydantic import BaseModel, condecimal


class Model(BaseModel):
    decimal_max_digits_and_places: condecimal(max_digits=2, decimal_places=2)
```

could also be written like this:

```Python
from decimal import Decimal
from pydantic import BaseModel, Field


class Model(BaseModel):
    decimal_max_digits_and_places: Decimal = Field(max_digits=2, decimal_places=2)
```

The main advantage is that editors and tools can provide better support when the type is static and not the return of a function (that works at runtime, but tools struggle with it during static analysis, etc).

E.g. the autocompletion for `condecimal`:

![Selection_007](https://user-images.githubusercontent.com/1326112/145613379-a01186ff-d5bf-4268-a437-ca46a0c311ae.png)

And the autocompletion for the new alternative:

![Selection_008](https://user-images.githubusercontent.com/1326112/145613456-a1f6a2a1-b652-4138-8be8-d11a9f871a69.png)

## Related issue number

<!-- Are there any issues opened that will be resolved by merging this change? -->
<!-- WARNING: please use "fix #123" style references so the issue is closed when this PR is merged. -->

## Checklist

* [x] Unit tests for the changes exist
* [x] Tests pass on CI and coverage remains at 100%
* [x] Documentation reflects the changes where applicable
* [x] `changes/<pull request or issue id>-<github username>.md` file added describing change
  (see [changes/README.md](https://github.com/samuelcolvin/pydantic/blob/master/changes/README.md) for details)
* [ ] My PR is ready to review, **please add a comment including the phrase "please review" to assign reviewers**


tiangolo: please review

Patch:
diff --git a/changes/3444-tharradine.md b/changes/3444-tharradine.md
new file mode 100644
index 000000000..6c9047c8a
--- /dev/null
+++ b/changes/3444-tharradine.md
@@ -0,0 +1,1 @@
+Fix issue where `None` was considered invalid when using a `Union` type containing `Any` or `object`
\ No newline at end of file
diff --git a/changes/3507-tiangolo.md b/changes/3507-tiangolo.md
new file mode 100644
index 000000000..95185be18
--- /dev/null
+++ b/changes/3507-tiangolo.md
@@ -0,0 +1,1 @@
+Add support for `Decimal`-specific validation configurations in `Field()`, additionally to using `condecimal()`, to allow better suppport from editors and tooling
\ No newline at end of file
diff --git a/docs/usage/schema.md b/docs/usage/schema.md
index 7618b362f..c0062d98e 100644
--- a/docs/usage/schema.md
+++ b/docs/usage/schema.md
@@ -65,6 +65,10 @@ It has the following arguments:
   JSON Schema
 * `multiple_of`: for numeric values, this adds a validation of "a multiple of" and an annotation of `multipleOf` to the
   JSON Schema
+* `max_digits`: for `Decimal` values, this adds a validation to have a maximum number of digits within the decimal. It
+  does not include a zero before the decimal point or trailing decimal zeroes.
+* `decimal_places`: for `Decimal` values, this adds a validation to have at most a number of decimal places allowed. It
+  does not include trailing decimal zeroes.
 * `min_items`: for list values, this adds a corresponding validation and an annotation of `minItems` to the
   JSON Schema
 * `max_items`: for list values, this adds a corresponding validation and an annotation of `maxItems` to the
diff --git a/pydantic/fields.py b/pydantic/fields.py
index bbe97d7d2..2c7a6724b 100644
--- a/pydantic/fields.py
+++ b/pydantic/fields.py
@@ -34,7 +34,6 @@ from .typing import (
     Callable,
     ForwardRef,
     NoArgAnyCallable,
-    NoneType,
     display_as_type,
     get_args,
     get_origin,
@@ -101,6 +100,8 @@ class FieldInfo(Representation):
         'lt',
         'le',
         'multiple_of',
+        'max_digits',
+        'decimal_places',
         'min_items',
         'max_items',
         'unique_items',
@@ -122,6 +123,8 @@ class FieldInfo(Representation):
         'ge': None,
         'le': None,
         'multiple_of': None,
+        'max_digits': None,
+        'decimal_places': None,
         'min_items': None,
         'max_items': None,
         'unique_items': None,
@@ -143,6 +146,8 @@ class FieldInfo(Representation):
         self.lt = kwargs.pop('lt', None)
         self.le = kwargs.pop('le', None)
         self.multiple_of = kwargs.pop('multiple_of', None)
+        self.max_digits = kwargs.pop('max_digits', None)
+        self.decimal_places = kwargs.pop('decimal_places', None)
         self.min_items = kwargs.pop('min_items', None)
         self.max_items = kwargs.pop('max_items', None)
         self.unique_items = kwargs.pop('unique_items', None)
@@ -209,6 +214,8 @@ def Field(
     lt: float = None,
     le: float = None,
     multiple_of: float = None,
+    max_digits: int = None,
+    decimal_places: int = None,
     min_items: int = None,
     max_items: int = None,
     unique_items: bool = None,
@@ -245,6 +252,10 @@ def Field(
       schema will have a ``maximum`` validation keyword
     :param multiple_of: only applies to numbers, requires the field to be "a multiple of". The
       schema will have a ``multipleOf`` validation keyword
+    :param max_digits: only applies to Decimals, requires the field to have a maximum number
+      of digits within the decimal. It does not include a zero before the decimal point or trailing decimal zeroes.
+    :param decimal_places: only applies to Decimals, requires the field to have at most a number of decimal places
+      allowed. It does not include trailing decimal zeroes.
     :param min_items: only applies to lists, requires the field to have a minimum number of
       elements. The schema will have a ``minItems`` validation keyword
     :param max_items: only applies to lists, requires the field to have a maximum number of
@@ -276,6 +287,8 @@ def Field(
         lt=lt,
         le=le,
         multiple_of=multiple_of,
+        max_digits=max_digits,
+        decimal_places=decimal_places,
         min_items=min_items,
         max_items=max_items,
         unique_items=unique_items,
@@ -574,10 +587,11 @@ class ModelField(Representation):
         elif is_union(origin):
             types_ = []
             for type_ in get_args(self.type_):
-                if type_ is NoneType:
+                if is_none_type(type_) or type_ is Any or type_ is object:
                     if self.required is Undefined:
                         self.required = False
                     self.allow_none = True
+                if is_none_type(type_):
                     continue
                 types_.append(type_)
 
diff --git a/pydantic/schema.py b/pydantic/schema.py
index 431503f70..b55114d4b 100644
--- a/pydantic/schema.py
+++ b/pydantic/schema.py
@@ -1056,6 +1056,8 @@ def get_annotation_with_constraints(annotation: Any, field_info: FieldInfo) -> T
             ):
                 # Is numeric type
                 attrs = ('gt', 'lt', 'ge', 'le', 'multiple_of')
+                if issubclass(type_, Decimal):
+                    attrs += ('max_digits', 'decimal_places')
                 numeric_type = next(t for t in numeric_types if issubclass(type_, t))  # pragma: no branch
                 constraint_func = _map_types_constraint[numeric_type]
 


Test patch:
diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py
index c5bb67a0b..227881b8f 100644
--- a/tests/test_edge_cases.py
+++ b/tests/test_edge_cases.py
@@ -83,6 +83,23 @@ def test_union_int_str():
     ]
 
 
+def test_union_int_any():
+    class Model(BaseModel):
+        v: Union[int, Any]
+
+    m = Model(v=123)
+    assert m.v == 123
+
+    m = Model(v='123')
+    assert m.v == 123
+
+    m = Model(v='foobar')
+    assert m.v == 'foobar'
+
+    m = Model(v=None)
+    assert m.v is None
+
+
 def test_union_priority():
     class ModelOne(BaseModel):
         v: Union[int, str] = ...
diff --git a/tests/test_types.py b/tests/test_types.py
index fbb42ed01..2a3161316 100644
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -1840,11 +1840,11 @@ def test_anystr_lower_disabled():
 
 
 @pytest.mark.parametrize(
-    'type_,value,result',
+    'type_args,value,result',
     [
-        (condecimal(gt=Decimal('42.24')), Decimal('43'), Decimal('43')),
+        (dict(gt=Decimal('42.24')), Decimal('43'), Decimal('43')),
         (
-            condecimal(gt=Decimal('42.24')),
+            dict(gt=Decimal('42.24')),
             Decimal('42'),
             [
                 {
@@ -1855,9 +1855,9 @@ def test_anystr_lower_disabled():
                 }
             ],
         ),
-        (condecimal(lt=Decimal('42.24')), Decimal('42'), Decimal('42')),
+        (dict(lt=Decimal('42.24')), Decimal('42'), Decimal('42')),
         (
-            condecimal(lt=Decimal('42.24')),
+            dict(lt=Decimal('42.24')),
             Decimal('43'),
             [
                 {
@@ -1868,10 +1868,10 @@ def test_anystr_lower_disabled():
                 }
             ],
         ),
-        (condecimal(ge=Decimal('42.24')), Decimal('43'), Decimal('43')),
-        (condecimal(ge=Decimal('42.24')), Decimal('42.24'), Decimal('42.24')),
+        (dict(ge=Decimal('42.24')), Decimal('43'), Decimal('43')),
+        (dict(ge=Decimal('42.24')), Decimal('42.24'), Decimal('42.24')),
         (
-            condecimal(ge=Decimal('42.24')),
+            dict(ge=Decimal('42.24')),
             Decimal('42'),
             [
                 {
@@ -1882,10 +1882,10 @@ def test_anystr_lower_disabled():
                 }
             ],
         ),
-        (condecimal(le=Decimal('42.24')), Decimal('42'), Decimal('42')),
-        (condecimal(le=Decimal('42.24')), Decimal('42.24'), Decimal('42.24')),
+        (dict(le=Decimal('42.24')), Decimal('42'), Decimal('42')),
+        (dict(le=Decimal('42.24')), Decimal('42.24'), Decimal('42.24')),
         (
-            condecimal(le=Decimal('42.24')),
+            dict(le=Decimal('42.24')),
             Decimal('43'),
             [
                 {
@@ -1896,9 +1896,9 @@ def test_anystr_lower_disabled():
                 }
             ],
         ),
-        (condecimal(max_digits=2, decimal_places=2), Decimal('0.99'), Decimal('0.99')),
+        (dict(max_digits=2, decimal_places=2), Decimal('0.99'), Decimal('0.99')),
         (
-            condecimal(max_digits=2, decimal_places=1),
+            dict(max_digits=2, decimal_places=1),
             Decimal('0.99'),
             [
                 {
@@ -1910,7 +1910,7 @@ def test_anystr_lower_disabled():
             ],
         ),
         (
-            condecimal(max_digits=3, decimal_places=1),
+            dict(max_digits=3, decimal_places=1),
             Decimal('999'),
             [
                 {
@@ -1921,11 +1921,11 @@ def test_anystr_lower_disabled():
                 }
             ],
         ),
-        (condecimal(max_digits=4, decimal_places=1), Decimal('999'), Decimal('999')),
-        (condecimal(max_digits=20, decimal_places=2), Decimal('742403889818000000'), Decimal('742403889818000000')),
-        (condecimal(max_digits=20, decimal_places=2), Decimal('7.42403889818E+17'), Decimal('7.42403889818E+17')),
+        (dict(max_digits=4, decimal_places=1), Decimal('999'), Decimal('999')),
+        (dict(max_digits=20, decimal_places=2), Decimal('742403889818000000'), Decimal('742403889818000000')),
+        (dict(max_digits=20, decimal_places=2), Decimal('7.42403889818E+17'), Decimal('7.42403889818E+17')),
         (
-            condecimal(max_digits=20, decimal_places=2),
+            dict(max_digits=20, decimal_places=2),
             Decimal('7424742403889818000000'),
             [
                 {
@@ -1936,9 +1936,9 @@ def test_anystr_lower_disabled():
                 }
             ],
         ),
-        (condecimal(max_digits=5, decimal_places=2), Decimal('7304E-1'), Decimal('7304E-1')),
+        (dict(max_digits=5, decimal_places=2), Decimal('7304E-1'), Decimal('7304E-1')),
         (
-            condecimal(max_digits=5, decimal_places=2),
+            dict(max_digits=5, decimal_places=2),
             Decimal('7304E-3'),
             [
                 {
@@ -1949,9 +1949,9 @@ def test_anystr_lower_disabled():
                 }
             ],
         ),
-        (condecimal(max_digits=5, decimal_places=5), Decimal('70E-5'), Decimal('70E-5')),
+        (dict(max_digits=5, decimal_places=5), Decimal('70E-5'), Decimal('70E-5')),
         (
-            condecimal(max_digits=5, decimal_places=5),
+            dict(max_digits=5, decimal_places=5),
             Decimal('70E-6'),
             [
                 {
@@ -1964,7 +1964,7 @@ def test_anystr_lower_disabled():
         ),
         *[
             (
-                condecimal(decimal_places=2, max_digits=10),
+                dict(decimal_places=2, max_digits=10),
                 value,
                 [{'loc': ('foo',), 'msg': 'value is not a valid decimal', 'type': 'value_error.decimal.not_finite'}],
             )
@@ -1985,7 +1985,7 @@ def test_anystr_lower_disabled():
         ],
         *[
             (
-                condecimal(decimal_places=2, max_digits=10),
+                dict(decimal_places=2, max_digits=10),
                 Decimal(value),
                 [{'loc': ('foo',), 'msg': 'value is not a valid decimal', 'type': 'value_error.decimal.not_finite'}],
             )
@@ -2005,7 +2005,7 @@ def test_anystr_lower_disabled():
             )
         ],
         (
-            condecimal(multiple_of=Decimal('5')),
+            dict(multiple_of=Decimal('5')),
             Decimal('42'),
             [
                 {
@@ -2018,16 +2018,18 @@ def test_anystr_lower_disabled():
         ),
     ],
 )
-def test_decimal_validation(type_, value, result):
-    model = create_model('DecimalModel', foo=(type_, ...))
-
-    if not isinstance(result, Decimal):
-        with pytest.raises(ValidationError) as exc_info:
-            model(foo=value)
-        assert exc_info.value.errors() == result
-        assert exc_info.value.json().startswith('[')
-    else:
-        assert model(foo=value).foo == result
+def test_decimal_validation(type_args, value, result):
+    modela = create_model('DecimalModel', foo=(condecimal(**type_args), ...))
+    modelb = create_model('DecimalModel', foo=(Decimal, Field(..., **type_args)))
+
+    for model in (modela, modelb):
+        if not isinstance(result, Decimal):
+            with pytest.raises(ValidationError) as exc_info:
+                model(foo=value)
+            assert exc_info.value.errors() == result
+            assert exc_info.value.json().startswith('[')
+        else:
+            assert model(foo=value).foo == result
 
 
 @pytest.mark.parametrize('value,result', (('/test/path', Path('/test/path')), (Path('/test/path'), Path('/test/path'))))