Skip to content
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

Create TypedDict from positional dict #1046

Closed
layday opened this issue Sep 20, 2020 · 10 comments
Closed

Create TypedDict from positional dict #1046

layday opened this issue Sep 20, 2020 · 10 comments
Labels
as designed Not a bug, working as intended

Comments

@layday
Copy link

layday commented Sep 20, 2020

PEP 589 says: "The created TypedDict type object is not a real class object. Here are the only uses of the type a type checker is expected to allow: ... It can be used as a callable object with keyword arguments corresponding to the TypedDict items. Non-keyword arguments are not allowed." However, the typing implementation permits dicts and dict literals in addition to keyword arguments and it appears to have been that way since it was first added to mypy_extensions four years ago; I assume they simply omitted to mention it in the PEP and that by 'non-keyword arguments' they mean that it cannot be initialised like a NamedTuple or dataclass where the positional arguments correspond to keys or fields on the class.

@erictraut
Copy link
Collaborator

The typing implementation of TypedDict is:

    TypedDict: object

I think you're referring to a fallback definition that mypy uses in some cases, but Pyright never uses it.

# Internal mypy fallback type for all typed dicts (does not exist at runtime)
class _TypedDict(Mapping[str, object], metaclass=ABCMeta):
   ...

So I think the PEP is correct, and the _TypedDict type in typing.pyi is incorrect.

If I attempt to initialize a TypedDict class with a positional dict, I receive a runtime error.

from typing import TypedDict

class Movie(TypedDict):
    name: str
    year: int

a = Movie({name: "hi", year: 1923})
# NameError: name 'name' is not defined

Pyright correctly emits an error in this case.

@erictraut erictraut added the as designed Not a bug, working as intended label Sep 20, 2020
@layday
Copy link
Author

layday commented Sep 20, 2020

I believe it only name-errors because name is not quoted - Python doesn't have unquoted keys like JS - although the code I linked to earlier is in fact part of the metaclass constructor logic and TypedDict subclasses seem to accept anything dict does by unpacking the arguments (https://github.com/python/cpython/blob/3.8/Lib/typing.py#L1708-L1709). So, this works:

a = Movie({"name": "hi", "year": 1923})

Or even:

Movie([("name", "hi"), ("year", 1923)])

@erictraut
Copy link
Collaborator

Of course quotes are needed here. (Sorry, I clearly haven't had enough caffeine yet this morning. :) )

Yes, it appears that these are supported at runtime, but PEP 589 indicates that they are not allowed by a type checker. Pyright follows the PEP as it's written. Supporting those other forms adds no value and would represent a lot of extra work in a type checker, so I'm not surprised that the PEP is written as it is.

I don't see any benefit of providing support for these alternative forms when you can already do this:

a: Movie = {"name": "hi", "year": 1923}

and

a = Movie(name = "hi", year = 1923)

@layday
Copy link
Author

layday commented Sep 20, 2020

Well, the more general issue is that you cannot convert an existing dictionary to a TypedDict programmatically. You could unpack it but then the arguments aren't checked: Movie(**{"name": 0, "year": 1923}). If you generate, say, a collection of Movies in a comprehension, you have to annotate the resultant type and, even then, the type error is much less informative than if you had a single Movie:

# Expression of type "List[Dict[str, int]]" cannot be assigned to declared type "list[Movie]"
#   TypeVar "_T" is invariant
#     "Dict[str, int]" is incompatible with "Movie"
b: 'list[Movie]' = [{"name": 0, "year": 1923} for _ in range(1)]

@erictraut
Copy link
Collaborator

There's no type-safe way to convert an existing dictionary without manually unpacking it. There's no type information about the individual fields in an existing dictionary. If you could pass any dict object into the constructor of a TypedDict, there would be no guarantee that the resulting object would contain the keys and value types that are dictated by the TypedDict definition.

Your example will work if you provide the correct type for the name.

b: 'list[Movie]' = [{"name": "title", "year": 1923} for _ in range(1)]

@layday
Copy link
Author

layday commented Sep 20, 2020

If you could pass any dict object into the constructor of a TypedDict, there would be no guarantee that the resulting object would contain the keys and value types that are dictated by the TypedDict definition.

Well, isn't that the point of casting it to a TypedDict? What's different between

a: Movie = {"name": "hi", "year": 1923}

and

a = Movie(**{"name": "hi", "year": 1923})

that the latter should not be type checkable? I would actually expect this to fail as well but it doesn't:

class MovieClass:
    def __init__(self, name: str, year: int) -> None:
        ...

c = MovieClass(**{"name": 1, "year": 1923})

This does, however:

# Argument of type "Literal[1]" cannot be assigned to parameter "name" of type "str" in function "__init__"
#   "Literal[1]" is incompatible with "str"
c = MovieClass(*(1, 1923))

Although I am straying from TypedDicts.

@erictraut erictraut removed the as designed Not a bug, working as intended label Sep 22, 2020
@erictraut
Copy link
Collaborator

I've investigated this further, and I think Pyright is doing the right thing. Here's my thinking.

First, PEP 589 very clearly states that "It can be used as a callable object with keyword arguments corresponding to the TypedDict items. Non-keyword arguments are not allowed". Mypy breaks with the spec in allowing a positional argument to the constructor. Pyright is following the spec.

Second, where named parameters are allowed, the type checker should not emit errors if a dictionary unpack operator ("**") is used. For example:

def callee(/, name: str, year: int): ...

def caller1(**kwargs: Any):
    # This should not emit an error
    func(**kwargs)

def caller2(my_dict: Dict[str, Union[int, str]):
    # This should not emit an error
    func(**my_dict)

You asked why this doesn't work as you expect:

a = Movie(**{"name": "hi", "year": 1923})

Here is that the type of the expression {"name": "hi", "year": 1923} is inferred to be Dict[str, Union[int, str]].

@erictraut erictraut added the as designed Not a bug, working as intended label Sep 26, 2020
@layday
Copy link
Author

layday commented Sep 26, 2020

Should the / in callee have been an asterisk with func being callee? (A slash as the first parameter is a syntax error and Pyright should probably flag it as such.) If callee(**{ ... }) was type checked, Dict[str, Union[int, str]] would have been somewhat satisfactory; but right now you can unpack just about anything in callee: an incompatible dictionary or even a non-mapping. Plain dictionaries are ultimately ill-suited for representing keyword arguments but so are TypedDicts, I suppose.

@erictraut
Copy link
Collaborator

erictraut commented Sep 26, 2020

Ah yes, I meant:

def callee(*, name: str, year: int): ...

def caller1(**kwargs: Any):
    # This should not emit an error
    callee(**kwargs)

def caller2(my_dict: Dict[str, Union[int, str]]):
    # This should not emit an error
    callee(**my_dict)

@erictraut
Copy link
Collaborator

Good point about the syntax error. I created this issue: #1064

thejcannon added a commit to pantsbuild/pants that referenced this issue May 26, 2023
Found using `pyright`. Discussion here:
microsoft/pyright#1046

Essentially, `TypedDict` doesn't allow a single-object dictionary for
construction.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
as designed Not a bug, working as intended
Projects
None yet
Development

No branches or pull requests

2 participants