Skip to content

Commit

Permalink
better str and repr for ModelField (#912)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
samuelcolvin committed Oct 18, 2019
1 parent ca1fa93 commit 78921da
Show file tree
Hide file tree
Showing 8 changed files with 77 additions and 15 deletions.
1 change: 1 addition & 0 deletions changes/912-samuelcolvin.md
@@ -0,0 +1 @@
Better `str`/`repr` logic for `ModelField`
25 changes: 23 additions & 2 deletions pydantic/fields.py
Expand Up @@ -24,7 +24,7 @@
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:
Expand Down Expand Up @@ -170,6 +170,13 @@ def Schema(default: Any, **kwargs: Any) -> Any:
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):
Expand Down Expand Up @@ -600,8 +607,22 @@ def is_complex(self) -> bool:
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))
Expand Down
4 changes: 2 additions & 2 deletions pydantic/typing.py
Expand Up @@ -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]:
Expand Down
10 changes: 10 additions & 0 deletions pydantic/utils.py
Expand Up @@ -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.
Expand Down
37 changes: 35 additions & 2 deletions 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
Expand Down Expand Up @@ -27,7 +28,7 @@ class Model(BaseModel):

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'
Expand Down Expand Up @@ -325,7 +326,7 @@ class Config:

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')"
)


Expand Down Expand Up @@ -1119,3 +1120,35 @@ def alias_generator(cls, f_name):

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
7 changes: 3 additions & 4 deletions tests/test_errors.py
@@ -1,3 +1,4 @@
import sys
from typing import Dict, List, Optional, Union
from uuid import UUID, uuid4

Expand Down Expand Up @@ -63,6 +64,7 @@ def check_action(cls, v):
]


@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
Expand All @@ -74,10 +76,7 @@ def check_foo(cls, v):
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)
Expand Down
4 changes: 2 additions & 2 deletions tests/test_main.py
Expand Up @@ -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}'
Expand Down
4 changes: 1 addition & 3 deletions tests/test_utils.py
Expand Up @@ -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

Expand Down

0 comments on commit 78921da

Please sign in to comment.