Skip to content

Conditional type-check based on callable call #2627

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 4 commits into from
Jan 11, 2017
Merged

Conditional type-check based on callable call #2627

merged 4 commits into from
Jan 11, 2017

Conversation

afrieder
Copy link
Contributor

@afrieder afrieder commented Jan 1, 2017

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

Copy link
Contributor

@ambv ambv left a comment

Choose a reason for hiding this comment

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

LGTM.

else:
return None, {}
else:
return {}, {}
Copy link
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
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.

@@ -0,0 +1,24 @@
from typing import builtinclass, Tuple, TypeVar, Generic, Union
Copy link
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
Copy link
Contributor

ambv commented Jan 1, 2017

👍

@afrieder
Copy link
Contributor Author

afrieder commented Jan 2, 2017

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

@rwbarton
Copy link
Contributor

rwbarton commented Jan 2, 2017

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
Copy link
Contributor Author

afrieder commented Jan 2, 2017

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

rwbarton commented Jan 2, 2017

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

rwbarton commented Jan 2, 2017

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

gvanrossum commented Jan 2, 2017 via email

@rwbarton
Copy link
Contributor

rwbarton commented Jan 2, 2017

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

gvanrossum commented Jan 2, 2017 via email

@afrieder
Copy link
Contributor Author

afrieder commented Jan 2, 2017

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

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

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

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

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

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

uncallables.append(item)

else:
if is_callable_type(item):
Copy link
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
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
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