Skip to content

Conditional type-check based on callable call - #2627

Merged
gvanrossum merged 4 commits into
python:masterfrom
afrieder:master
Jan 11, 2017
Merged

gvanrossum merged 4 commits into
python:masterfrom
afrieder:master

Conversation

@afrieder

@afrieder afrieder commented Jan 1, 2017 •

Copy link
Copy Markdown
Contributor

Fixes #1973

This is my first PR to mypy, so let me know if this is out of scope, poor style, wrong, if it needs more tests, etc, etc. I'm happy to learn.

The naïve thing is to make use of conditional_type_map but the way that works, it basically casts the expr to the proposed_type, so passing in a generic CallableType instance (ie AnyType *args, **kwargs, and return) results in a type map where expr is just a generic CallableType. This new function instead preserves the signature of expr and will make Unions as necessary (see testUnionMultipleReturnTypes, which would not raise an error with a generic CallableType).

@ambv ambv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM.

Comment thread mypy/checker.py
else:
return None, {}
else:
return {}, {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: This could be factored with less indentation by reversing the order of conditional checks. Also, avoid backslashes. I know you based off conditional_type_map() but given it's already copied and pasted, how about:

    if not current_type:
        return {}, {}
        
    if isinstance(current_type, CallableType):
        return {}, None

    if isinstance(current_type, UnionType):
        callables = [item for item in current_type.items
                     if isinstance(item, CallableType)]  # type: List[Type]
        non_callables = [item for item in current_type.items
                         if not isinstance(item, CallableType)]  # type: List[Type]
        return (
            {expr: UnionType.make_union(callables)},
            {expr: UnionType.make_union(non_callables)},
        )

    return None, {}

The two list comprehensions are a bit wasteful but I guess that's fine, we don't expect unions to be overly long.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reformatted. I also changed it to a for loop. I initially thought the comprehensions would be faster, but basic experiments show I'm wrong.

Comment thread test-data/unit/fixtures/callable.pyi Outdated
@@ -0,0 +1,24 @@
from typing import builtinclass, Tuple, TypeVar, Generic, Union

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@builtinclass is no longer a thing, see #599. Just remove this import, you're not using it anyway.

@ambv

ambv commented Jan 1, 2017

Copy link
Copy Markdown
Contributor

👍

@afrieder

afrieder commented Jan 2, 2017 •

Copy link
Copy Markdown
Contributor Author

I added more tests for chaining ands/ors with callable.
Otherwise this is good to go.

@rwbarton

rwbarton commented Jan 2, 2017

Copy link
Copy Markdown
Contributor

I have some doubts about this. Actually the issue is not with your code but the original feature request, but the issue only became clear when I read your implementation. In any case it seems better to comment here for now.

The issue is that there's actually almost no situation where we can correctly conclude that a type is not callable. This even applies to the original program which involved Union[str, Callable[[str], int]]. Technically a value of this type could be an instance of a subclass of str, and that subclass could define a __call__ method. In that case callable would return True at runtime, but the real call signature could be anything. Pretty much the only time we know for sure a value cannot be callable is if it is of a non-subclassable type, like bool. At a minimum, we should be treating type and other classes that are known to have __call__ methods, as well as classes inheriting from Any, as callable or potentially callable.

There's actually a similar issue with normal isinstance checks and generics: if I have a value of type Union[A, List[int]] and isinstance tells me it is a list then technically it could still be a subclass of both A and list and not actually be a list of ints. Mypy makes the simplifying assumption that there is no multiple inheritance in this situation. However there doesn't seem to be a reasonable analogous assumption to make for callable, since being callable is as easy as defining __call__, and doesn't require inheriting from a class.

@afrieder

afrieder commented Jan 2, 2017

Copy link
Copy Markdown
Contributor Author

That's completely true.
Even in the following short example:

class T(int):
    def __call__(self) -> int:
        return self


def f(i: int) -> int:
    if callable(i):
        print("Impossible!")
    return i

f(T(5))

What's considered impossible while type-checking is very clearly possible at run-time.
So I guess I should close this PR and someone else should close the issue?

@rwbarton

rwbarton commented Jan 2, 2017

Copy link
Copy Markdown
Contributor

Well there are some bits that we can carve out here. For instance if we have a Union[str, Callable[[str], bool]] and we test and it turns out to not be callable then we can reasonably conclude that it's in fact a str (and I think your code will already do this). I'm sure there is some obscure thing you can do at runtime to a Callable[[str], bool] to make callable return False on it, but I feel pretty confident that you'd have to do things that are well beyond mypy's comprehension.

I feel like these examples involving subclassing int and str with a __call__ method are also kind of unreasonable (though clearly much less so). It wouldn't be out of the question for mypy to treat callable(x) as always false when x is an int. But I'm not sure callable is important enough to do something complex about it.

@gvanrossum

Copy link
Copy Markdown
Member

I would defer to @JukkaL about this. That said, my own intuition is that the PR is valuable as-is (though I haven't reviewed it). While it's possible to subvert the types by e.g. subclassing str to add a __call__ method, I don't think that's a common scenario at all, while I believe that the scenario from the issue this is addressing (#1973) is common enough to want it to "just work" rather than forcing one to refactor the code just so it will type-check. (In fact I do think it's pretty similar to the "multiple inheritance" scenario that we're already ignoring in other cases.)

@rwbarton

rwbarton commented Jan 2, 2017

Copy link
Copy Markdown
Contributor

But at a minimum I would expect mypy to treat Any, Type[T], type, and types known to have a __call__ method as callable, rather than definitely not callable.

@gvanrossum

gvanrossum commented Jan 2, 2017 via email

Copy link
Copy Markdown
Member

@rwbarton

rwbarton commented Jan 2, 2017

Copy link
Copy Markdown
Contributor

(Though Any should be treated as "perhaps callable perhaps not" I suppose.)

Er yes, I changed what I was going to say by the time I got to the end of that sentence. :)

I only read the code, I didn't read the tests or test it myself; but currently it appears to treat anything that isn't a Callable or a Union as not callable.

@gvanrossum

gvanrossum commented Jan 2, 2017 via email

Copy link
Copy Markdown
Member

@afrieder

afrieder commented Jan 2, 2017 •

Copy link
Copy Markdown
Contributor Author

Yes, the PR as-is only supports Callable and Unions of Callables. I'll take a look at how CallExpr handles things and improve this.

Edit: It's been improved and should now cover all cases.

@gvanrossum

Copy link
Copy Markdown
Member

Status update: I discussed this with @JukkaL and he's in favor (though hasn't reviewed the PR in detail). I also ran tests on our internal codebases and found no problems. If I can wrap my head around the actual code I'll try to merge this ASAP so it will make it into the 0.4.7 release (planned for this Thursday).

Comment thread mypy/checker.py Outdated
return all(is_callable_type(item) for item in type.items)

if isinstance(type, TypeVarType):
return not is_callable_type(type.erase_to_union_or_bound())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Rereading through this, I have no idea why this is negated. I'll remove it and test to confirm this case is right.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You seem to have no tests involving TypeVar. Also none for Type. So please add some!

Comment thread mypy/checker.py Outdated
return all(is_callable_type(item) for item in type.items)

if isinstance(type, TypeVarType):
return not is_callable_type(type.erase_to_union_or_bound())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You seem to have no tests involving TypeVar. Also none for Type. So please add some!

Comment thread mypy/checker.py Outdated
def conditional_callable_type_map(expr: Expression,
current_type: Optional[Type],
) -> Tuple[TypeMap, TypeMap]:
"""Takes in an expression and the current type of the expression

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Add period? Perhaps complete the sentence?

Comment thread mypy/checker.py Outdated
"""Takes in an expression and the current type of the expression

Returns a 2-tuple: The first element is a map from the expression to
the restricted type if it must be callable. The second element is a

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

"must be" feels odd. Perhaps make the phrasing more similar to the docstring of conditional_type_map()?

Comment thread mypy/checker.py Outdated
uncallable_map = {expr: UnionType.make_union(uncallables)} if len(uncallables) else None
return callable_map, uncallable_map

if is_callable_type(current_type):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is also missing a case for TypeVar. E.g. this example doesn't work right even if I remove the not from line 2520 above:

T = TypeVar('T', bound=Union[int, Callable[[], int]])
def f(a: T) -> int:
    if callable(a):
        reveal_type(a)
        return a()
    else:
        reveal_type(a)
        return a

The output is

__tmp__.py:8: error: Revealed type is 'T`-1'
__tmp__.py:9: error: Incompatible return value type (got "T", expected "int")

Comment thread mypy/checker.py Outdated
uncallables.append(item)

else:
if is_callable_type(item):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

There's a problem here: nested unions (e.g. Union[int, Union[str, float]]) aren't necessarily flattened, so if the inner union contains a callable and a non-callable, that item is binned with the uncallables -- which is wrong. Try e.g.

U = Union[int, Union[str, Callable[[], int]]]
def f(a: U) -> int:
    if callable(a):
        reveal_type(a)
        return a()
    else:
        reveal_type(a)
        return a

vs.

U = Union[int, str, Callable[[], int]]

@afrieder

Copy link
Copy Markdown
Contributor Author

@gvanrossum I rewrote the underlying logic to handle the cases you brought up (and hopefully all other cases) and added various tests.

@gvanrossum

Copy link
Copy Markdown
Member

W00t! Thanks! Merging now...

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.

4 participants