Skip to content

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

Merged
merged 20 commits into from
Jun 23, 2017
Merged

Conversation

JukkaL
Copy link
Collaborator

@JukkaL JukkaL commented Jun 16, 2017

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.

@JukkaL
Copy link
Collaborator Author

JukkaL commented Jun 16, 2017

The Travis CI failure looks like a flake.

@gvanrossum
Copy link
Member

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 args['p1'] and args['p2'] are used freely. While I don't want to have to implement the full "specify which keys are optional" functionality, I think the "total=False" flag would be more useful if it allowed args['p1'] (and even args['p3']) while preserving the other behavior of TypedDict (e.g. complaining if the key is not a constant or not one of the known keys, and inferring the right type for the values for known keys).

@JukkaL
Copy link
Collaborator Author

JukkaL commented Jun 21, 2017

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 td['key'] would remain unchanged for non-total typed dicts. This would imply that, unlike with total typed dicts, non-total typed dicts could generate KeyError exceptions. Otherwise they could be type safe, I think.

This change would also make supporting 'key' in td checks unnecessary, which would save some effort.

@JukkaL
Copy link
Collaborator Author

JukkaL commented Jun 21, 2017

Now td['key'] is supported for non-total TypedDicts.

Copy link
Member

@gvanrossum gvanrossum left a 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)
Copy link
Member

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.

Copy link
Collaborator Author

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')
Copy link
Member

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.

Copy link
Collaborator Author

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])'
Copy link
Member

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...

Copy link
Collaborator Author

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>])'
Copy link
Member

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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another issue for #3590.

@ilevkivskyi
Copy link
Member

@gvanrossum

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.)

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 Maybe2DPoint.__annotations__ will be just {'x': int}. But the above example actually works and is supported by the typed_ast parser, i.e., it will detect and mark assignment nodes that have parentheses.

@gvanrossum
Copy link
Member

gvanrossum commented Jun 21, 2017 via email

@ilevkivskyi
Copy link
Member

ilevkivskyi commented Jun 21, 2017

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 Optional but still it could be different enough due to being lowercase and appearing after the type, so that it reads not like the type is optional but the whole key is optional.
(Both look a bit ugly however, and the introspection problem still appears with the second one.)

@JukkaL Sorry for off-topic.

Copy link
Member

@gvanrossum gvanrossum left a 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.)

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()

Copy link
Member

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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

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()
Copy link
Member

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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A good idea -- done.

rvalue_name='expression')
if item_name in kwargs:
item_value = kwargs[item_name]

Copy link
Member

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?

Copy link
Collaborator Author

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)
Copy link
Member

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.

Copy link
Collaborator Author

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
Copy link
Member

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?

Copy link
Collaborator Author

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.
Copy link
Member

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.

Copy link
Collaborator Author

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
Copy link
Member

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).

Copy link
Collaborator Author

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.
Copy link
Member

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']

Copy link
Collaborator Author

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.

@gvanrossum gvanrossum mentioned this pull request Jun 21, 2017
Copy link
Member

@gvanrossum gvanrossum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@ilevkivskyi ilevkivskyi merged commit f0e8288 into master Jun 23, 2017
@ilevkivskyi ilevkivskyi deleted the typeddict-total branch June 23, 2017 06:41
JukkaL added a commit to python/typeshed that referenced this pull request Jun 29, 2017
JukkaL added a commit to JukkaL/typeshed that referenced this pull request Jun 29, 2017
JelleZijlstra pushed a commit to python/typeshed that referenced this pull request Jun 29, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants