Skip to content

Commit

Permalink
Fix overlap between TypedDict and Mapping (#7164)
Browse files Browse the repository at this point in the history
Fixes #7036

This adds more "fine grained" logic for overlap between `TypedDict` and `Mapping` used by overload checks and `--strict-equality`. Basically the rules are:
* A `TypedDict` with some required keys is overlapping with `Mapping[str, <some type>]` if and only if every key type is overlapping with `<some type>`.
* A `TypedDict` with no required keys overlaps with `Mapping[str, <some type>]` if and only if at least one of key types overlaps with `<some type>`.
* Empty dictionaries are as usual in gray area, so we follow the same logic as in other places: an empty dictionary can't cause an overlap (i.e. `TypedDict` with no required keys doesn't overlap with `Mapping[str, <some type>]`), but is itself (i.e. `Mapping[<nothing>, <nothing>]`) overlapping with a `TypedDict` with no required keys.
  • Loading branch information
ilevkivskyi committed Jul 8, 2019
1 parent e818a96 commit 8782d63
Show file tree
Hide file tree
Showing 5 changed files with 261 additions and 1 deletion.
88 changes: 87 additions & 1 deletion mypy/meet.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from collections import OrderedDict
from typing import List, Optional, Tuple
from typing import List, Optional, Tuple, Callable

from mypy.join import (
is_similar_callables, combine_similar_callables, join_type_list, unpack_callback_protocol
Expand Down Expand Up @@ -204,6 +204,10 @@ def is_none_typevar_overlap(t1: Type, t2: Type) -> bool:

if isinstance(left, TypedDictType) and isinstance(right, TypedDictType):
return are_typed_dicts_overlapping(left, right, ignore_promotions=ignore_promotions)
elif typed_dict_mapping_pair(left, right):
# Overlaps between TypedDicts and Mappings require dedicated logic.
return typed_dict_mapping_overlap(left, right,
overlapping=_is_overlapping_types)
elif isinstance(left, TypedDictType):
left = left.fallback
elif isinstance(right, TypedDictType):
Expand Down Expand Up @@ -608,3 +612,85 @@ def meet_similar_callables(t: CallableType, s: CallableType) -> CallableType:
ret_type=meet_types(t.ret_type, s.ret_type),
fallback=fallback,
name=None)


def typed_dict_mapping_pair(left: Type, right: Type) -> bool:
"""Is this a pair where one type is a TypedDict and another one is an instance of Mapping?
This case requires a precise/principled consideration because there are two use cases
that push the boundary the opposite ways: we need to avoid spurious overlaps to avoid
false positives for overloads, but we also need to avoid spuriously non-overlapping types
to avoid false positives with --strict-equality.
"""
assert not isinstance(left, TypedDictType) or not isinstance(right, TypedDictType)

if isinstance(left, TypedDictType):
_, other = left, right
elif isinstance(right, TypedDictType):
_, other = right, left
else:
return False
return isinstance(other, Instance) and other.type.has_base('typing.Mapping')


def typed_dict_mapping_overlap(left: Type, right: Type,
overlapping: Callable[[Type, Type], bool]) -> bool:
"""Check if a TypedDict type is overlapping with a Mapping.
The basic logic here consists of two rules:
* A TypedDict with some required keys is overlapping with Mapping[str, <some type>]
if and only if every key type is overlapping with <some type>. For example:
- TypedDict(x=int, y=str) overlaps with Dict[str, Union[str, int]]
- TypedDict(x=int, y=str) doesn't overlap with Dict[str, int]
Note that any additional non-required keys can't change the above result.
* A TypedDict with no required keys overlaps with Mapping[str, <some type>] if and
only if at least one of key types overlaps with <some type>. For example:
- TypedDict(x=str, y=str, total=False) overlaps with Dict[str, str]
- TypedDict(x=str, y=str, total=False) doesn't overlap with Dict[str, int]
- TypedDict(x=int, y=str, total=False) overlaps with Dict[str, str]
As usual empty, dictionaries lie in a gray area. In general, List[str] and List[str]
are considered non-overlapping despite empty list belongs to both. However, List[int]
and List[<nothing>] are considered overlapping.
So here we follow the same logic: a TypedDict with no required keys is considered
non-overlapping with Mapping[str, <some type>], but is considered overlapping with
Mapping[<nothing>, <nothing>]. This way we avoid false positives for overloads, and also
avoid false positives for comparisons like SomeTypedDict == {} under --strict-equality.
"""
assert not isinstance(left, TypedDictType) or not isinstance(right, TypedDictType)

if isinstance(left, TypedDictType):
assert isinstance(right, Instance)
typed, other = left, right
else:
assert isinstance(left, Instance)
assert isinstance(right, TypedDictType)
typed, other = right, left

mapping = next(base for base in other.type.mro if base.fullname() == 'typing.Mapping')
other = map_instance_to_supertype(other, mapping)
key_type, value_type = other.args

# TODO: is there a cleaner way to get str_type here?
fallback = typed.as_anonymous().fallback
str_type = fallback.type.bases[0].args[0] # typing._TypedDict inherits Mapping[str, object]

# Special case: a TypedDict with no required keys overlaps with an empty dict.
if isinstance(key_type, UninhabitedType) and isinstance(value_type, UninhabitedType):
return not typed.required_keys

if typed.required_keys:
if not overlapping(key_type, str_type):
return False
return all(overlapping(typed.items[k], value_type) for k in typed.required_keys)
else:
if not overlapping(key_type, str_type):
return False
non_required = set(typed.items.keys()) - typed.required_keys
return any(overlapping(typed.items[k], value_type) for k in non_required)
8 changes: 8 additions & 0 deletions test-data/unit/check-expressions.test
Original file line number Diff line number Diff line change
Expand Up @@ -2217,6 +2217,14 @@ o in exp
[builtins fixtures/list.pyi]
[typing fixtures/typing-full.pyi]

[case testEmptyListOverlap]
# mypy: strict-equality
from typing import List

x: List[int]
x == []
[builtins fixtures/isinstancelist.pyi]

[case testCustomEqDecoratedStrictEquality]
# flags: --strict-equality
from typing import TypeVar, Callable, Any
Expand Down
164 changes: 164 additions & 0 deletions test-data/unit/check-typeddict.test
Original file line number Diff line number Diff line change
Expand Up @@ -1673,3 +1673,167 @@ class A(TypedDict):
d: Union[A, None]
d.update({'x': 1})
[builtins fixtures/dict.pyi]

[case testTypedDictOverlapWithDict]
# mypy: strict-equality
from typing import TypedDict, Dict

class Config(TypedDict):
a: str
b: str

x: Dict[str, str]
y: Config

x == y
[builtins fixtures/dict.pyi]
[typing fixtures/typing-full.pyi]

[case testTypedDictOverlapWithDictNonOverlapping]
# mypy: strict-equality
from typing import TypedDict, Dict

class Config(TypedDict):
a: str
b: int

x: Dict[str, str]
y: Config

x == y # E: Non-overlapping equality check (left operand type: "Dict[str, str]", right operand type: "Config")
[builtins fixtures/dict.pyi]
[typing fixtures/typing-full.pyi]

[case testTypedDictOverlapWithDictNonTotal]
# mypy: strict-equality
from typing import TypedDict, Dict

class Config(TypedDict, total=False):
a: str
b: int

x: Dict[str, str]
y: Config

x == y
[builtins fixtures/dict.pyi]
[typing fixtures/typing-full.pyi]

[case testTypedDictOverlapWithDictNonTotalNonOverlapping]
# mypy: strict-equality
from typing import TypedDict, Dict

class Config(TypedDict, total=False):
a: int
b: int

x: Dict[str, str]
y: Config

x == y # E: Non-overlapping equality check (left operand type: "Dict[str, str]", right operand type: "Config")
[builtins fixtures/dict.pyi]
[typing fixtures/typing-full.pyi]

[case testTypedDictOverlapWithDictEmpty]
# mypy: strict-equality
from typing import TypedDict

class Config(TypedDict):
a: str
b: str

x: Config
x == {} # E: Non-overlapping equality check (left operand type: "Config", right operand type: "Dict[<nothing>, <nothing>]")
[builtins fixtures/dict.pyi]
[typing fixtures/typing-full.pyi]

[case testTypedDictOverlapWithDictNonTotalEmpty]
# mypy: strict-equality
from typing import TypedDict

class Config(TypedDict, total=False):
a: str
b: str

x: Config
x == {}
[builtins fixtures/dict.pyi]
[typing fixtures/typing-full.pyi]

[case testTypedDictOverlapWithDictNonStrKey]
# mypy: strict-equality
from typing import TypedDict, Dict, Union

class Config(TypedDict):
a: str
b: str

x: Config
y: Dict[Union[str, int], str]
x == y
[builtins fixtures/dict.pyi]
[typing fixtures/typing-full.pyi]

[case testTypedDictOverlapWithDictOverload]
from typing import overload, TypedDict, Dict

class Map(TypedDict):
x: int
y: str

@overload
def func(x: Map) -> int: ...
@overload
def func(x: Dict[str, str]) -> str: ...
def func(x):
pass
[builtins fixtures/dict.pyi]
[typing fixtures/typing-full.pyi]

[case testTypedDictOverlapWithDictOverloadBad]
from typing import overload, TypedDict, Dict

class Map(TypedDict, total=False):
x: int
y: str

@overload
def func(x: Map) -> int: ... # E: Overloaded function signatures 1 and 2 overlap with incompatible return types
@overload
def func(x: Dict[str, str]) -> str: ...
def func(x):
pass
[builtins fixtures/dict.pyi]
[typing fixtures/typing-full.pyi]

[case testTypedDictOverlapWithDictOverloadMappingBad]
from typing import overload, TypedDict, Mapping

class Map(TypedDict, total=False):
x: int
y: str

@overload
def func(x: Map) -> int: ... # E: Overloaded function signatures 1 and 2 overlap with incompatible return types
@overload
def func(x: Mapping[str, str]) -> str: ...
def func(x):
pass
[builtins fixtures/dict.pyi]
[typing fixtures/typing-full.pyi]

[case testTypedDictOverlapWithDictOverloadNonStrKey]
from typing import overload, TypedDict, Dict

class Map(TypedDict):
x: str
y: str

@overload
def func(x: Map) -> int: ...
@overload
def func(x: Dict[int, str]) -> str: ...
def func(x):
pass
[builtins fixtures/dict.pyi]
[typing fixtures/typing-full.pyi]
1 change: 1 addition & 0 deletions test-data/unit/fixtures/dict.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ VT = TypeVar('VT')

class object:
def __init__(self) -> None: pass
def __eq__(self, other: object) -> bool: pass

class type: pass

Expand Down
1 change: 1 addition & 0 deletions test-data/unit/fixtures/isinstancelist.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ from typing import (

class object:
def __init__(self) -> None: pass
def __eq__(self, other: object) -> bool: pass

class type:
def __init__(self, x) -> None: pass
Expand Down

0 comments on commit 8782d63

Please sign in to comment.