From 78921da35353c9d875c01acde0dc2c6986810ab5 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 18 Oct 2019 14:32:30 +0100 Subject: [PATCH] better str and repr for ModelField (#912) * better str and repr for ModelField, fix #505 * better type display, fix tests * correct _type_display signature * fix for python3.6 differences * fix PyObjectStr * fix coverage --- changes/912-samuelcolvin.md | 1 + pydantic/fields.py | 25 +++++++++++++++++++++++-- pydantic/typing.py | 4 ++-- pydantic/utils.py | 10 ++++++++++ tests/test_edge_cases.py | 37 +++++++++++++++++++++++++++++++++++-- tests/test_errors.py | 7 +++---- tests/test_main.py | 4 ++-- tests/test_utils.py | 4 +--- 8 files changed, 77 insertions(+), 15 deletions(-) create mode 100644 changes/912-samuelcolvin.md diff --git a/changes/912-samuelcolvin.md b/changes/912-samuelcolvin.md new file mode 100644 index 0000000..bac10cd --- /dev/null +++ b/changes/912-samuelcolvin.md @@ -0,0 +1 @@ +Better `str`/`repr` logic for `ModelField` diff --git a/pydantic/fields.py b/pydantic/fields.py index 388655a..8432bba 100644 --- a/pydantic/fields.py +++ b/pydantic/fields.py @@ -24,7 +24,7 @@ from .error_wrappers import ErrorWrapper from .errors import NoneIsNotAllowedError from .types import Json, JsonWrapper from .typing import AnyType, Callable, ForwardRef, display_as_type, is_literal_type -from .utils import Representation, lenient_issubclass, sequence_like +from .utils import PyObjectStr, Representation, lenient_issubclass, sequence_like from .validators import constant_validator, dict_validator, find_validators, validate_json try: @@ -170,6 +170,13 @@ SHAPE_TUPLE = 5 SHAPE_TUPLE_ELLIPSIS = 6 SHAPE_SEQUENCE = 7 SHAPE_FROZENSET = 8 +SHAPE_NAME_LOOKUP = { + SHAPE_LIST: 'List[{}]', + SHAPE_SET: 'Set[{}]', + SHAPE_TUPLE_ELLIPSIS: 'Tuple[{}, ...]', + SHAPE_SEQUENCE: 'Sequence[{}]', + SHAPE_FROZENSET: 'FrozenSet[{}]', +} class ModelField(Representation): @@ -600,8 +607,22 @@ class ModelField(Representation): or hasattr(self.type_, '__pydantic_model__') # pydantic dataclass ) + def _type_display(self) -> PyObjectStr: + t = display_as_type(self.type_) + + if self.shape == SHAPE_MAPPING: + t = f'Mapping[{display_as_type(self.key_field.type_)}, {t}]' # type: ignore + elif self.shape == SHAPE_TUPLE: + t = 'Tuple[{}]'.format(', '.join(display_as_type(f.type_) for f in self.sub_fields)) # type: ignore + elif self.shape != SHAPE_SINGLETON: + t = SHAPE_NAME_LOOKUP[self.shape].format(t) + + if self.allow_none and (self.shape != SHAPE_SINGLETON or not self.sub_fields): + t = f'Optional[{t}]' + return PyObjectStr(t) + def __repr_args__(self) -> 'ReprArgs': - args = [('name', self.name), ('type', display_as_type(self.type_)), ('required', self.required)] + args = [('name', self.name), ('type', self._type_display()), ('required', self.required)] if not self.required: args.append(('default', self.default)) diff --git a/pydantic/typing.py b/pydantic/typing.py index e7e3c3d..5eca455 100644 --- a/pydantic/typing.py +++ b/pydantic/typing.py @@ -113,8 +113,8 @@ def display_as_type(v: AnyType) -> str: try: return v.__name__ except AttributeError: - # happens with unions - return str(v) + # happens with typing objects + return str(v).replace('typing.', '') def resolve_annotations(raw_annotations: Dict[str, AnyType], module_name: Optional[str]) -> Dict[str, AnyType]: diff --git a/pydantic/utils.py b/pydantic/utils.py index d5c06d6..d704b0c 100644 --- a/pydantic/utils.py +++ b/pydantic/utils.py @@ -118,6 +118,16 @@ def almost_equal_floats(value_1: float, value_2: float, *, delta: float = 1e-8) return abs(value_1 - value_2) <= delta +class PyObjectStr(str): + """ + String class where repr doesn't include quotes. Useful with Representation when you want to return a string + representation of something that valid (or pseudo-valid) python. + """ + + def __repr__(self) -> str: + return str(self) + + class Representation: """ Mixin to provide __str__, __repr__, and __pretty__ methods. See #884 for more details. diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index 16c9844..2313fe6 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -1,4 +1,5 @@ import re +import sys from decimal import Decimal from enum import Enum from typing import Any, Dict, List, Optional, Set, Tuple, Type, Union @@ -27,7 +28,7 @@ def test_str_bytes(): m = Model(v='s') assert m.v == 's' - assert repr(m.__fields__['v']) == "ModelField(name='v', type='typing.Union[str, bytes]', required=True)" + assert repr(m.__fields__['v']) == "ModelField(name='v', type=Union[str, bytes], required=True)" m = Model(v=b'b') assert m.v == 'b' @@ -325,7 +326,7 @@ def test_infer_alias(): assert Model(_a='different').a == 'different' assert repr(Model.__fields__['a']) == ( - "ModelField(name='a', type='str', required=False, default='foobar', alias='_a')" + "ModelField(name='a', type=str, required=False, default='foobar', alias='_a')" ) @@ -1119,3 +1120,35 @@ def test_alias_generator_parent(): assert Child.__fields__['y'].alias == 'y2' assert Child.__fields__['x'].alias == 'x2' + + +def test_field_str_shape(): + class Model(BaseModel): + a: List[int] + + assert repr(Model.__fields__['a']) == "ModelField(name='a', type=List[int], required=True)" + assert str(Model.__fields__['a']) == "name='a' type=List[int] required=True" + + +@pytest.mark.skipif(sys.version_info < (3, 7), reason='output slightly different for 3.6') +@pytest.mark.parametrize( + 'type_,expected', + [ + (int, 'int'), + (Optional[int], 'Optional[int]'), + (Union[None, int, str], 'Union[NoneType, int, str]'), + (Union[int, str, bytes], 'Union[int, str, bytes]'), + (List[int], 'List[int]'), + (Tuple[int, str, bytes], 'Tuple[int, str, bytes]'), + (Union[List[int], Set[bytes]], 'Union[List[int], Set[bytes]]'), + (List[Tuple[int, int]], 'List[Tuple[int, int]]'), + (Dict[int, str], 'Mapping[int, str]'), + (Tuple[int, ...], 'Tuple[int, ...]'), + (Optional[List[int]], 'Optional[List[int]]'), + ], +) +def test_field_type_display(type_, expected): + class Model(BaseModel): + a: type_ + + assert Model.__fields__['a']._type_display() == expected diff --git a/tests/test_errors.py b/tests/test_errors.py index 9796f16..7e42a59 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,3 +1,4 @@ +import sys from typing import Dict, List, Optional, Union from uuid import UUID, uuid4 @@ -63,6 +64,7 @@ def test_interval_validation_error(): ] +@pytest.mark.skipif(sys.version_info < (3, 7), reason='output slightly different for 3.6') def test_error_on_optional(): class Foobar(BaseModel): foo: Optional[str] = None @@ -74,10 +76,7 @@ def test_error_on_optional(): with pytest.raises(ValidationError) as exc_info: Foobar(foo='x') assert exc_info.value.errors() == [{'loc': ('foo',), 'msg': 'custom error', 'type': 'value_error'}] - assert repr(exc_info.value.raw_errors[0]) in ( - "ErrorWrapper(exc=ValueError('custom error'), loc=('foo',))", # python 3.7 - "ErrorWrapper(exc=ValueError('custom error',), loc=('foo',))", # python 3.6 - ), repr(exc_info.value.raw_errors[0]) + assert repr(exc_info.value.raw_errors[0]) == "ErrorWrapper(exc=ValueError('custom error'), loc=('foo',))" with pytest.raises(ValidationError) as exc_info: Foobar(foo=None) diff --git a/tests/test_main.py b/tests/test_main.py index 41ddd9a..e32bfc5 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -41,8 +41,8 @@ def test_ultra_simple_repr(): m = UltraSimpleModel(a=10.2) assert str(m) == 'a=10.2 b=10' assert repr(m) == 'UltraSimpleModel(a=10.2, b=10)' - assert repr(m.__fields__['a']) == "ModelField(name='a', type='float', required=True)" - assert repr(m.__fields__['b']) == "ModelField(name='b', type='int', required=False, default=10)" + assert repr(m.__fields__['a']) == "ModelField(name='a', type=float, required=True)" + assert repr(m.__fields__['b']) == "ModelField(name='b', type=int, required=False, default=10)" assert dict(m) == {'a': 10.2, 'b': 10} assert m.dict() == {'a': 10.2, 'b': 10} assert m.json() == '{"a": 10.2, "b": 10}' diff --git a/tests/test_utils.py b/tests/test_utils.py index 7c21b53..3eb552b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -32,9 +32,7 @@ def test_import_no_attr(): assert exc_info.value.args[0] == 'Module "os" does not define a "foobar" attribute' -@pytest.mark.parametrize( - 'value,expected', ((str, 'str'), ('string', 'str'), (Union[str, int], 'typing.Union[str, int]')) -) +@pytest.mark.parametrize('value,expected', ((str, 'str'), ('string', 'str'), (Union[str, int], 'Union[str, int]'))) def test_display_as_type(value, expected): assert display_as_type(value) == expected