-
-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Support TypedDicts with missing keys (total=False) #3558
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Only the functional syntax is supported.
The Travis CI failure looks like a flake. |
There is no support for introspection of `total` yet.
Make TypedDict `total` introspectable.
I'm getting second thoughts about this feature. I think insisting on using get() everywhere will be pretty disruptive. I've found several use cases already (without really looking) where there's something like args = {'p1': int(), 'p2': str()}
if bool():
args['p3'] = int() and then later |
I agree that the current PR likely isn't flexible enough for a lot of user code. I like your idea, assuming I understood it correctly. The only change seems to be that type checking of This change would also make supporting |
Now |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For a later PR: I just came up with an idea for specifying whether individual fields are required or not -- use '-x'
in the dict. (But this doesn't work for the class-based syntax.)
else: | ||
return 'TypedDict({}, _fallback={})'.format(s, t.fallback.accept(self)) | ||
return 'TypedDict({}, _fallback={}{})'.format(s, t.fallback.accept(self), keys_str) | ||
return 'TypedDict({})'.format(s) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a bit disturbing that the repr of a TypedDict is so different from the actual syntax used to create one, but let's deal with that some other time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Created #3590 to track this.
f({'x': 1}) | ||
f({'y': ''}) | ||
f({'x': 1, 'y': ''}) | ||
f({'x': 1, 'z': ''}) # E: Expected TypedDict key 'x' but found keys ('x', 'z') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems inconsistent -- isn't {'x': 1, 'z': ''}
an instance of a subtype of D
? See testTypedDictSubtypingWithTotalFalse below.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I want to catch misspelled key names (or totally invalid items). If we didn't complain about z
, there would be a lot of potential for false negatives. TypedDicts are treated kind of like structs by mypy so we don't allow extra keys during creation. Structural subtyping can't produce new keys, so it's less of a problem.
T = TypeVar('T') | ||
def f(x: Callable[[T, T], None]) -> T: pass | ||
def g(x: XY, y: YZ) -> None: pass | ||
reveal_type(f(g)) # E: Revealed type is 'TypedDict(x=builtins.int, y=builtins.int, z=builtins.int, _fallback=typing.Mapping[builtins.str, builtins.int], _required_keys=[y, z])' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The repr of a TypedDict is rather large, with the fallback and required_keys...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's continue this in #3590. I think that it's best to discuss all the aspects of the representation at the same time.
b: B | ||
c: C | ||
reveal_type(j(a, b)) \ | ||
# E: Revealed type is 'TypedDict(_fallback=typing.Mapping[builtins.str, <nothing>])' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a little subtle that this is how a TypedDict with no keys is rendered.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another issue for #3590.
How about class Maybe2DPoint(TypedDict):
x: int
(y): int There will be some difficulties with runtime introspection, expressions that are not simple names are not stored, so that |
Very thin ice there, and the runtime introspection issue kills it for me.
:-(
|
OK, another two crazy ideas that probably have not been discussed yet: from mypy_extensions import TypedDict, optional
class Maybe2DPoint(TypedDict):
x: int
y: (int, optional) # The syntax is invalid without parentheses here
class Maybe2DPoint(TypedDict):
x: int
y: int; optional This is quite similar to @JukkaL Sorry for off-topic. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Sorry, hit SEND prematurely. Here's the restthe review. Honest.)
mypy/checkexpr.py
Outdated
callee_item_names = callee.items.keys() | ||
if not (callee.required_keys <= set(kwargs.keys()) <= set(callee.items.keys())): | ||
callee_item_names = [key for key in callee.items.keys() | ||
if key in callee.required_keys or key in kwargs.keys()] | ||
kwargs_item_names = kwargs.keys() | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This blank line irks me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed.
mypy/checkexpr.py
Outdated
if callee.items.keys() != kwargs.keys(): | ||
callee_item_names = callee.items.keys() | ||
if not (callee.required_keys <= set(kwargs.keys()) <= set(callee.items.keys())): | ||
callee_item_names = [key for key in callee.items.keys() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Naming these two variables expected_xxx and actual_xxx (matching the error message call below) would help in understanding what they mean.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A good idea -- done.
mypy/checkexpr.py
Outdated
rvalue_name='expression') | ||
if item_name in kwargs: | ||
item_value = kwargs[item_name] | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again why a blank line?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed.
mypy/meet.py
Outdated
@@ -266,7 +267,8 @@ def visit_typeddict_type(self, t: TypedDictType) -> Type: | |||
items = OrderedDict(item_list) | |||
mapping_value_type = join_type_list(list(items.values())) | |||
fallback = self.s.create_anonymous_fallback(value_type=mapping_value_type) | |||
return TypedDictType(items, fallback) | |||
required_keys = set(items.keys()) & (t.required_keys | self.s.required_keys) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again, the '&' with items.keys() shouldn't ever do anything right? Assuming s.required_keys is a subset of s.items etc.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are correct. Updated.
mypy/join.py
Outdated
]) | ||
mapping_value_type = join_type_list(list(items.values())) | ||
fallback = self.s.create_anonymous_fallback(value_type=mapping_value_type) | ||
return TypedDictType(items, fallback) | ||
required_keys = set(items.keys()) & t.required_keys & self.s.required_keys |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is the set(items.keys()) &
part needed? Is there ever a TypedDict whose required_keys is not a subset of its items?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We filter out earlier keys that exist in both t
and self.s
but have incompatible value types, so this is necessary, I think.
if (isinstance(value_type, TypedDictType) | ||
and isinstance(default_arg, DictExpr) | ||
and len(default_arg.items) == 0): | ||
# Caller has empty dict {} as default for typed dict. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we have to special-case this? Assuming the value type is some TypedDict, and the default passed to get() is some dict literal, isn't the natural union type resulting from the two an appropriate TypedDict? Even if it isn't, shouldn't we use the value type as a context for inferring the type of the dict literal? ISTM that d.get('x', {'y': 1})
ought to work too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We special this so that the context will be a non-total TypedDict in case the default is {}
. If we don't do that, {}
won't be accepted for total TypedDicts since it has missing keys (all keys are missing).
Any subset of keys should work but this will be harder to implement and likely not very common, so I think it's okay to postpone it until later. I can create an issue to track that.
mypy/subtypes.py
Outdated
if not is_equivalent(l, r, self.check_type_parameter): | ||
return False | ||
# Non-required key is not compatible with a required key since indexing |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm, I wonder if there's something to say for making at least one of the directions work here??? It's hard to keep things straight (see my notes on some of the tests).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Update the comment to be more specific. Here's my argument why required key shouldn't be compatible with a non-required key (assuming we had #3550 implemented):
A = TypedDict('A', {'x': int})
B = TypedDict('B', {'x': int}, total=False)
def f(b: B) -> None:
del b['x'] # Should be accepted (but is not until we implement #3550)
a: A = {'x': 0}
f(a) # Error: if we allow this, the next line can fail
a['x'] # Should not fail with KeyError
mypy/subtypes.py
Outdated
if not is_equivalent(l, r, self.check_type_parameter): | ||
return False | ||
# Non-required key is not compatible with a required key since indexing | ||
# may fail. Required key is not compatible with a non-required key | ||
# since the prior doesn't support 'del' but the latter supports it. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When I try to delete an item I always get "A" has no attribute "__delitem__"
, e.g.
A = TypedDict('A', {'x': int, 'y': str})
a: A = {'x': 0, 'y': ''}
del a['x']
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's not implemented yet (#3550). Updated comment to reflect that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM!
See python/mypy#3558 for context.
See python/mypy#3558 for context.
See python/mypy#3558 for context.
Support both the functional syntax and the class based syntax.
This will also require a change to the
mypy_extensions
stub in typeshed.Implements most of #2632, but binder support (
'key' in typed_dict
checks)will need another PR.