Repository: samuelcolvin/pydantic (python)

Issue:
AttributeError: name with frozendict[..., ...] and subclass (maybe Mapping+GenericAlias issue?)
# Bug

Using a [`frozendict`](https://github.com/Marco-Sulla/python-frozendict) as a model field works normally/correctly but when subscripted (eg: `frozendict[str, int]`), pydantic errors in some weird ways:
- the output has a `dict` (similar to #2311 and others, except even converting to `frozendict` explicitly or in a `@validator` doesn't work)
- on model subclasses, the `field.outer_type_` seems to be botched and missing attributes (but works fine on the original defining class)

```py
# pip3 install frozendict
from frozendict import frozendict
from pydantic import BaseModel


class Base(BaseModel):
    a: frozendict
    b: frozendict[str, int]


class Sub(Base):
    pass


def inspect(m: BaseModel):
    print(f"\t{m.__name__}\n")
    print(m(a=frozendict(a=1), b=frozendict(b=1)))
    print(m.__fields__)
    print(m.__fields__["b"].outer_type_)


# This doesn't error, but the sub-typed variant is replaced with a dict and the Field is a Mapping
inspect(Base)
# This errors when inspecting `b` ModelField.outer_type_, seemingly trying to access an uninitialized .name
inspect(Sub)
```
outputs
```
        Base

a=frozendict({'a': 1}) b={'b': 1}
{'a': ModelField(name='a', type=frozendict, required=True), 'b': ModelField(name='b', type=Mapping[str, int], required=True)}
frozendict.core.frozendict[str, int]
        Sub

a=frozendict({'a': 1}) b={'b': 1}
{'a': ModelField(name='a', type=frozendict, required=True), 'b': ModelField(name='b', type=Mapping[str, int], required=True)}
Traceback (most recent call last):
  File "/tmp/pydantic_error.py", line 25, in <module>
    inspect(Sub)
  File "/tmp/pydantic_error.py", line 19, in inspect
    print(m.__fields__["b"].outer_type_)
  File "pydantic/utils.py", line 396, in pydantic.utils.Representation.__repr__
  File "pydantic/utils.py", line 375, in genexpr
  File "pydantic/utils.py", line 375, in genexpr
  File "pydantic/fields.py", line 951, in pydantic.fields.ModelField.__repr_args__
AttributeError: name
```

The `type(frozendict[str, int])` is a `GenericAlias` and otherwise seems to be normal:

```
>>> type(frozendict[str, str])
<class 'types.GenericAlias'>
>>> get_origin(frozendict[str, str])
<class 'frozendict.core.frozendict'>
>>> get_args(frozendict[str, str])
(<class 'str'>, <class 'str'>)
>>>
>>>
>>> type(dict[str, str])
<class 'types.GenericAlias'>
>>> get_origin(dict[str, str])
<class 'dict'>
>>> get_args(dict[str, str])
(<class 'str'>, <class 'str'>)
```

### Checks

* [x] I added a descriptive title to this issue
* [x] I have searched (google, github) for similar issues and couldn't find anything
* [x] I have read and followed [the docs](https://pydantic-docs.helpmanual.io/) and still think this is a bug

Output of `python -c "import pydantic.utils; print(pydantic.utils.version_info())"`:
```
             pydantic version: 1.8.2
            pydantic compiled: True
                 install path: /Users/jacobhayes/src/github.com/replicahq/artigraph/.wt/type-dispatch/.direnv/python-3.9.6/lib/python3.9/site-packages/pydantic
               python version: 3.9.6 (default, Jul 20 2021, 13:51:27)  [Clang 12.0.5 (clang-1205.0.22.9)]
                     platform: macOS-11.4-x86_64-i386-64bit
     optional deps. installed: ['typing-extensions']
```

Pull request description:
feat: avoid dict coercion for subclasses
<!-- 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
When using a subclass of `dict` as type of a field, the shape of the field was still considered `SHAPE_DICT`. When a value was validated, it was hence coerced to `dict` [here](https://github.com/samuelcolvin/pydantic/blob/master/pydantic/fields.py#L899-L904)
Now we set the shape to `SHAPE_MAPPING` in those cases and keep target class
<!-- Please give a short summary of the changes. -->

## Related issue number
closes #3122
<!-- Are there any issues opened that will be resolved by merging this change? -->

## 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)
* [x] My PR is ready to review, **please add a comment including the phrase "please review" to assign reviewers**


Patch:
diff --git a/changes/3122-PrettyWood.md b/changes/3122-PrettyWood.md
new file mode 100644
index 000000000..b9dc1c245
--- /dev/null
+++ b/changes/3122-PrettyWood.md
@@ -0,0 +1,1 @@
+avoid dict coercion when using dict subclasses as field type
\ No newline at end of file
diff --git a/pydantic/fields.py b/pydantic/fields.py
index 106414f38..8894158aa 100644
--- a/pydantic/fields.py
+++ b/pydantic/fields.py
@@ -554,13 +554,13 @@ class ModelField(Representation):
             if isinstance(self.type_, type) and isinstance(None, self.type_):
                 self.allow_none = True
             return
-        if origin is Annotated:
+        elif origin is Annotated:
             self.type_ = get_args(self.type_)[0]
             self._type_analysis()
             return
-        if origin is Callable:
+        elif origin is Callable:
             return
-        if is_union(origin):
+        elif is_union(origin):
             types_ = []
             for type_ in get_args(self.type_):
                 if type_ is NoneType:
@@ -580,8 +580,7 @@ class ModelField(Representation):
             else:
                 self.sub_fields = [self._create_sub_type(t, f'{self.name}_{display_as_type(t)}') for t in types_]
             return
-
-        if issubclass(origin, Tuple):  # type: ignore
+        elif issubclass(origin, Tuple):  # type: ignore
             # origin == Tuple without item type
             args = get_args(self.type_)
             if not args:  # plain tuple
@@ -599,8 +598,7 @@ class ModelField(Representation):
                 self.shape = SHAPE_TUPLE
                 self.sub_fields = [self._create_sub_type(t, f'{self.name}_{i}') for i, t in enumerate(args)]
             return
-
-        if issubclass(origin, List):
+        elif issubclass(origin, List):
             # Create self validators
             get_validators = getattr(self.type_, '__get_validators__', None)
             if get_validators:
@@ -636,6 +634,11 @@ class ModelField(Representation):
         elif issubclass(origin, Sequence):
             self.type_ = get_args(self.type_)[0]
             self.shape = SHAPE_SEQUENCE
+        # priority to most common mapping: dict
+        elif origin is dict or origin is Dict:
+            self.key_field = self._create_sub_type(get_args(self.type_)[0], 'key_' + self.name, for_keys=True)
+            self.type_ = get_args(self.type_)[1]
+            self.shape = SHAPE_DICT
         elif issubclass(origin, DefaultDict):
             self.key_field = self._create_sub_type(get_args(self.type_)[0], 'key_' + self.name, for_keys=True)
             self.type_ = get_args(self.type_)[1]
@@ -644,10 +647,6 @@ class ModelField(Representation):
             self.key_field = self._create_sub_type(get_args(self.type_)[0], 'key_' + self.name, for_keys=True)
             self.type_ = int
             self.shape = SHAPE_COUNTER
-        elif issubclass(origin, Dict):
-            self.key_field = self._create_sub_type(get_args(self.type_)[0], 'key_' + self.name, for_keys=True)
-            self.type_ = get_args(self.type_)[1]
-            self.shape = SHAPE_DICT
         elif issubclass(origin, Mapping):
             self.key_field = self._create_sub_type(get_args(self.type_)[0], 'key_' + self.name, for_keys=True)
             self.type_ = get_args(self.type_)[1]


Test patch:
diff --git a/tests/test_main.py b/tests/test_main.py
index 4a968104a..ceb925db4 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -2,7 +2,20 @@ import sys
 from collections import defaultdict
 from copy import deepcopy
 from enum import Enum
-from typing import Any, Callable, ClassVar, Counter, DefaultDict, Dict, List, Mapping, Optional, Type, get_type_hints
+from typing import (
+    Any,
+    Callable,
+    ClassVar,
+    Counter,
+    DefaultDict,
+    Dict,
+    List,
+    Mapping,
+    Optional,
+    Type,
+    TypeVar,
+    get_type_hints,
+)
 from uuid import UUID, uuid4
 
 import pytest
@@ -1976,6 +1989,27 @@ def test_typing_coercion_dict():
     assert repr(m) == "Model(x={'one': 1, 'two': 2})"
 
 
+@pytest.mark.skipif(sys.version_info < (3, 7), reason='generic classes need 3.7')
+def test_typing_non_coercion_of_dict_subclasses():
+    KT = TypeVar('KT')
+    VT = TypeVar('VT')
+
+    class MyDict(Dict[KT, VT]):
+        def __repr__(self):
+            return f'MyDict({super().__repr__()})'
+
+    class Model(BaseModel):
+        a: MyDict
+        b: MyDict[str, int]
+        c: Dict[str, int]
+        d: Mapping[str, int]
+
+    assert (
+        repr(Model(a=MyDict({'a': 1}), b=MyDict({'a': '1'}), c=MyDict({'a': '1'}), d=MyDict({'a': '1'})))
+        == "Model(a=MyDict({'a': 1}), b=MyDict({'a': 1}), c={'a': 1}, d=MyDict({'a': 1}))"
+    )
+
+
 def test_typing_coercion_defaultdict():
     class Model(BaseModel):
         x: DefaultDict[int, str]