From 72edca75328e61406756d553a23ca6d7c8a9067e Mon Sep 17 00:00:00 2001 From: dmontagu <35119617+dmontagu@users.noreply.github.com> Date: Sat, 10 Aug 2019 04:00:29 -0700 Subject: [PATCH] Make bool_validator strict (#617) * Make bool_validator strict * incorporate feedback * Add RelaxedBool * Fix position in schema.py * update BoolError message * Incorporate feedback * Update history * Add changes * Update docs/index.rst Co-Authored-By: Samuel Colvin * Update docs/index.rst Co-Authored-By: Samuel Colvin * Update tests/test_types.py Co-Authored-By: Samuel Colvin * Incorporate feedback * Update booleans.py * Remove RelaxedBool * tweak docs and update changes to new format --- changes/617-dmontagu.rst | 1 + docs/examples/booleans.py | 33 +++++++++++++++++++ docs/examples/exotic.py | 5 +-- docs/index.rst | 26 +++++++++++++-- pydantic/errors.py | 4 +++ pydantic/validators.py | 12 +++++-- tests/test_edge_cases.py | 4 +-- tests/test_types.py | 68 +++++++++++++++++++++++++++++++++------ 8 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 changes/617-dmontagu.rst create mode 100644 docs/examples/booleans.py diff --git a/changes/617-dmontagu.rst b/changes/617-dmontagu.rst new file mode 100644 index 0000000..8bc7c70 --- /dev/null +++ b/changes/617-dmontagu.rst @@ -0,0 +1 @@ +**Breaking Change:** modify parsing behavior for ``bool`` diff --git a/docs/examples/booleans.py b/docs/examples/booleans.py new file mode 100644 index 0000000..90679c6 --- /dev/null +++ b/docs/examples/booleans.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel, StrictBool, ValidationError + +class BooleansModel(BaseModel): + standard: bool + strict: StrictBool + +print(BooleansModel(standard=False, strict=False)) +# BooleansModel standard=False strict=False + +print(BooleansModel(standard='False', strict=False)) +# BooleansModel standard=False strict=False + +try: + BooleansModel(standard='False', strict='False') +except ValidationError as e: + print(str(e)) +""" +1 validation error +strict + value is not a valid boolean (type=value_error.strictbool) +""" + +print(BooleansModel(standard='False', strict=False)) +# BooleansModel standard=False strict=False +try: + BooleansModel(standard=[], strict=False) +except ValidationError as e: + print(str(e)) +""" +1 validation error +standard + value could not be parsed to a boolean (type=type_error.bool) +""" diff --git a/docs/examples/exotic.py b/docs/examples/exotic.py index 9c44397..834bc35 100644 --- a/docs/examples/exotic.py +++ b/docs/examples/exotic.py @@ -5,7 +5,7 @@ from pathlib import Path from uuid import UUID from pydantic import (DSN, UUID1, UUID3, UUID4, UUID5, BaseModel, DirectoryPath, EmailStr, FilePath, NameEmail, - NegativeFloat, NegativeInt, PositiveFloat, PositiveInt, PyObject, StrictBool, UrlStr, conbytes, condecimal, + NegativeFloat, NegativeInt, PositiveFloat, PositiveInt, PyObject, UrlStr, conbytes, condecimal, confloat, conint, conlist, constr, IPvAnyAddress, IPvAnyInterface, IPvAnyNetwork, SecretStr, SecretBytes) @@ -39,8 +39,6 @@ class Model(BaseModel): email_address: EmailStr = None email_and_name: NameEmail = None - is_really_a_bool: StrictBool = None - url: UrlStr = None password: SecretStr = None @@ -96,7 +94,6 @@ m = Model( short_list=[1, 2], email_address='Samuel Colvin ', email_and_name='Samuel Colvin ', - is_really_a_bool=True, url='http://example.com', password='password', password_bytes=b'password2', diff --git a/docs/index.rst b/docs/index.rst index 9a0ba19..7ffb584 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -580,12 +580,32 @@ Exotic Types (This script is complete, it should run "as is") -StrictBool -~~~~~~~~~~ +Booleans +~~~~~~~~ -Unlike normal ``bool`` fields, ``StrictBool`` can be used to required specifically ``True`` or ``False``, +.. warning:: + + The logic for parsing ``bool`` fields has changed as of version v1. + Prior to v1, ``bool`` parsing never failed, leading to some unexpected results. + The new logic is described below. + +A standard ``bool`` field will raise a ``ValidationError`` if the value is not one of the following: + +* A valid boolean (i.e., ``True`` or ``False``), +* The integers ``0`` or ``1``, +* a ``str`` which when converted to lower case is one of + ``'off', 'f', 'false', 'n', 'no', '1', 'on', 't', 'true', 'y', 'yes'`` +* a ``bytes`` which is valid (per the previous rule) when decoded to ``str`` + +For stricter behavior, ``StrictBool`` can be used to require specifically ``True`` or ``False``; nothing else is permitted. +Here is a script demonstrating some of these behaviors: + +.. literalinclude:: examples/booleans.py + +(This script is complete, it should run "as is") + Callable ~~~~~~~~ diff --git a/pydantic/errors.py b/pydantic/errors.py index 27a50d4..1aa0028 100644 --- a/pydantic/errors.py +++ b/pydantic/errors.py @@ -55,6 +55,10 @@ class WrongConstantError(PydanticValueError): return f'unexpected value; permitted: {permitted}' +class BoolError(PydanticTypeError): + msg_template = 'value could not be parsed to a boolean' + + class BytesError(PydanticTypeError): msg_template = 'byte type expected' diff --git a/pydantic/validators.py b/pydantic/validators.py index 378a532..671a1b1 100644 --- a/pydantic/validators.py +++ b/pydantic/validators.py @@ -91,7 +91,8 @@ def bytes_validator(v: Any) -> bytes: raise errors.BytesError() -BOOL_STRINGS = {'1', 'TRUE', 'ON', 'YES'} +BOOL_FALSE = {False, 0, '0', 'off', 'f', 'false', 'n', 'no'} +BOOL_TRUE = {True, 1, '1', 'on', 't', 'true', 'y', 'yes'} def bool_validator(v: Any) -> bool: @@ -100,8 +101,13 @@ def bool_validator(v: Any) -> bool: if isinstance(v, bytes): v = v.decode() if isinstance(v, str): - return v.upper() in BOOL_STRINGS - return bool(v) + v = v.lower() + with change_exception(errors.BoolError, TypeError): + if v in BOOL_TRUE: + return True + if v in BOOL_FALSE: + return False + raise errors.BoolError() def int_validator(v: Any) -> int: diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index dc91520..7c32476 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -187,8 +187,8 @@ def test_tuple_more(): simple_tuple: tuple = None tuple_of_different_types: Tuple[int, float, str, bool] = None - m = Model(simple_tuple=[1, 2, 3, 4], tuple_of_different_types=[1, 2, 3, 4]) - assert m.dict() == {'simple_tuple': (1, 2, 3, 4), 'tuple_of_different_types': (1, 2.0, '3', True)} + m = Model(simple_tuple=[1, 2, 3, 4], tuple_of_different_types=[4, 3, 2, 1]) + assert m.dict() == {'simple_tuple': (1, 2, 3, 4), 'tuple_of_different_types': (4, 3.0, '2', True)} def test_tuple_length_error(): diff --git a/tests/test_types.py b/tests/test_types.py index c330774..84938d3 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -316,22 +316,59 @@ class CheckModel(BaseModel): max_anystr_length = 10 +class BoolCastable: + def __bool__(self) -> bool: + return True + + @pytest.mark.parametrize( 'field,value,result', [ ('bool_check', True, True), - ('bool_check', False, False), - ('bool_check', None, False), - ('bool_check', '', False), ('bool_check', 1, True), - ('bool_check', 'TRUE', True), - ('bool_check', b'TRUE', True), - ('bool_check', 'true', True), - ('bool_check', '1', True), - ('bool_check', '2', False), - ('bool_check', 2, True), - ('bool_check', 'on', True), + ('bool_check', 'y', True), + ('bool_check', 'Y', True), ('bool_check', 'yes', True), + ('bool_check', 'Yes', True), + ('bool_check', 'YES', True), + ('bool_check', 'true', True), + ('bool_check', 'True', True), + ('bool_check', 'TRUE', True), + ('bool_check', 'on', True), + ('bool_check', 'On', True), + ('bool_check', 'ON', True), + ('bool_check', '1', True), + ('bool_check', 't', True), + ('bool_check', 'T', True), + ('bool_check', b'TRUE', True), + ('bool_check', False, False), + ('bool_check', 0, False), + ('bool_check', 'n', False), + ('bool_check', 'N', False), + ('bool_check', 'no', False), + ('bool_check', 'No', False), + ('bool_check', 'NO', False), + ('bool_check', 'false', False), + ('bool_check', 'False', False), + ('bool_check', 'FALSE', False), + ('bool_check', 'off', False), + ('bool_check', 'Off', False), + ('bool_check', 'OFF', False), + ('bool_check', '0', False), + ('bool_check', 'f', False), + ('bool_check', 'F', False), + ('bool_check', b'FALSE', False), + ('bool_check', None, ValidationError), + ('bool_check', '', ValidationError), + ('bool_check', [], ValidationError), + ('bool_check', {}, ValidationError), + ('bool_check', [1, 2, 3, 4], ValidationError), + ('bool_check', {1: 2, 3: 4}, ValidationError), + ('bool_check', b'2', ValidationError), + ('bool_check', '2', ValidationError), + ('bool_check', 2, ValidationError), + ('bool_check', b'\x81', ValidationError), + ('bool_check', BoolCastable(), ValidationError), ('str_check', 's', 's'), ('str_check', ' s ', 's'), ('str_check', b's', 's'), @@ -954,6 +991,17 @@ def test_strict_bool(): Model(v=b'1') +def test_bool_unhashable_fails(): + class Model(BaseModel): + v: bool + + with pytest.raises(ValidationError) as exc_info: + Model(v={}) + assert exc_info.value.errors() == [ + {'loc': ('v',), 'msg': 'value could not be parsed to a boolean', 'type': 'type_error.bool'} + ] + + def test_uuid_error(): class Model(BaseModel): v: UUID