Repository: pydantic/pydantic

Issue:
Field representation does not show generic types.
I tried to run this code on https://github.com/samuelcolvin/pydantic/commit/bff8a1789dfde2c38928cced6640887b53615aa3

![Untitled](https://user-images.githubusercontent.com/1862725/57166070-a728c380-6e01-11e9-9544-a57a4799a2b8.png)

As you can see field representation does not say `List[int]`

Thanks for an awesome package.


Discussion:
The problem here is that `field.type_` holds information and about the items in that field (eg. `int` here) and `field.shape` holds information about the "shape" of the data, eg. list, dict, set etc.

Basically `field.__str__` could use some work.

https://github.com/samuelcolvin/pydantic/blob/bff8a1789dfde2c38928cced6640887b53615aa3/pydantic/fields.py#L449-L459

But I don't think this is a big urgent problem.

Resolving patch:
diff --git a/pydantic/fields.py b/pydantic/fields.py
--- a/pydantic/fields.py
+++ b/pydantic/fields.py
@@ -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:
@@ -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):
@@ -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))
diff --git a/pydantic/typing.py b/pydantic/typing.py
--- 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
--- 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.


Test patch:
diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py
--- 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 @@ 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'
@@ -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')"
     )
 
 
@@ -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
diff --git a/tests/test_errors.py b/tests/test_errors.py
--- 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 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
@@ -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)
diff --git a/tests/test_main.py b/tests/test_main.py
--- 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
--- 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