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

Type error when using contextlib.asynccontextmanager #193

Open
xueqiao001 opened this issue May 12, 2020 · 5 comments
Open

Type error when using contextlib.asynccontextmanager #193

xueqiao001 opened this issue May 12, 2020 · 5 comments
Labels
bug Something isn't working help wanted Extra attention is needed
Milestone

Comments

@xueqiao001
Copy link

I have 2 Python file foo.py and test_foo.py.
foo.py:

from contextlib import asynccontextmanager
from typing import AsyncGenerator
class Foo:
    async def bar()->int:
        print("Calling func bar")
        return 1
    async def __aenter__(self):
        return self
    async def __aexit__(self, exc_type, exc, tb):
        return

@asynccontextmanager
async def get_cm() -> AsyncGenerator[Foo, None]:
        yield Foo()

async def call_bar()->int:
    async with get_cm() as foo:
        return await foo.bar()

test_foo.py:

from testslide.dsl import context
from foo import call_bar,Foo
from testslide import StrictMock

module = "foo"

@context
def foo_test(context):
    context.memoize(
        "foo_mock", lambda self: StrictMock(template=Foo, default_context_manager=True)
    )

    @context.before
    async def mock_get_cm(self):
        self.mock_callable(module, "get_cm").to_return_value(
            self.foo_mock
        )
        self.mock_async_callable(self.foo_mock, "bar").to_return_value(3)
    @context.example
    async def test_bar(self):
        self.assertEqual(await call_bar(),3)

When I ran testslide test_foo.py. I hit this type error below.

Failures:

  1) foo test: test bar
    1) TypeError: type of return must be collections.abc.AsyncGenerator; got testslide.strict_mock.FooStrictMock instead: <StrictMock 0x10612BF10 template=foo.Foo testslide_test.py:10>
    Defined at /Users/qxue/testslide_test.py:15
      File ".pyenv/versions/3.8.2/lib/python3.8/asyncio/runners.py", line 43, in run
        return loop.run_until_complete(main)
      File ".pyenv/versions/3.8.2/lib/python3.8/asyncio/base_events.py", line 616, in run_until_complete
        return future.result()
      File "testslide_test.py", line 21, in test_bar
        self.assertEqual(await call_bar(),3)
      File "foo.py", line 20, in call_bar
        async with get_cm() as foo:
@fornellas
Copy link
Contributor

@xueqiao001 thanks for taking the time to report this.

When you declare:

@asynccontextmanager
async def get_cm() -> AsyncGenerator[Foo, None]:
        yield Foo()

You're annotating that the resulting function after the @asynccontextmanager decorator returns a type of AsyncGenerator[Foo, None]. This is not true:

In [3]: import foo                                                                                                            

In [4]: foo.get_cm()                                                                                                          
Out[4]: <contextlib._AsyncGeneratorContextManager at 0x7fd9488a4d30>

In [5]: type(foo.get_cm())                                                                                                    
Out[5]: contextlib._AsyncGeneratorContextManager

In [6]: type(foo.get_cm()).mro()                                                                                              
Out[6]: 
[contextlib._AsyncGeneratorContextManager,
 contextlib._GeneratorContextManagerBase,
 contextlib.AbstractAsyncContextManager,
 abc.ABC,
 object]

It is confusing, because this is legit:

async def get_cm() -> AsyncGenerator[Foo, None]:
        yield Foo()
In [17]: get_cm()                                                                                                             
Out[17]: <async_generator object get_cm at 0x7fd948a5ae50>

In [18]: type(get_cm())                                                                                                       
Out[18]: async_generator

In [19]: type(get_cm()).mro()                                                                                                 
Out[19]: [async_generator, object]

By changing get_cm to the correct typing annotation:

@asynccontextmanager
async def get_cm() -> AsyncContextManager:
        yield Foo()

This test goes green.

I hope this helped. If I missed something, please re-open this issue.

@fabriziocucci
Copy link

We may have to discuss whether keeping the existing behavior:

@mydecorator
def myfunc() -> TypeAfterDecorating:
...

or changing it to be aligned with statically typed languages (e.g. Java):

@mydecorator
def myfunc() -> TypeBeforeDecorating:
...

@fornellas
Copy link
Contributor

So, here's the deal. Python behaves differently depending on which decorator it is. For the case I commented back in May, I thought I had the general case. I was wrong...

Check this out:

In [1]: from contextlib import asynccontextmanager                                                                                                                                                                                     

In [2]: from typing import AsyncGenerator 
   ...:                                                                                                                                                                                                                                

In [3]: @asynccontextmanager 
   ...: async def get_cm() -> AsyncGenerator[int, None]: 
   ...:         yield 1 
   ...:                                                                                                                                                                                                                                

In [4]: get_cm.__annotations__                                                                                                                                                                                                         
Out[4]: {'return': typing.AsyncGenerator[int, NoneType]}

In [5]: def return_str(func): 
   ...:     def wrapper() -> str: 
   ...:         return str(func()) 
   ...:     return wrapper 
   ...:  
   ...:  
   ...: class Foo: 
   ...:     @return_str 
   ...:     def get_int_str(self) -> int: 
   ...:         return 1 
   ...:                                                                                                                                                                                                                                

In [6]: Foo.get_int_str.__annotations__                                                                                                                                                                                                
Out[6]: {'return': str}

In [7]: def return_str_untyped(func):  
   ...:     def wrapper():  
   ...:         return str(func())  
   ...:     return wrapper  
   ...:   
   ...:   
   ...: class Bar:  
   ...:     @return_str_untyped 
   ...:     def get_int_str(self) -> int:  
   ...:         return 1  
   ...:                                                                                                                                                                                                                                

In [8]: Bar.get_int_str.__annotations__                                                                                                                                                                                                
Out[8]: {}

So:

1 - For @asynccontextmanager decorated functions, the return type annotation is the one from the decorated function.
2 - For @some_random_decorator decorated functions, the return type annotation is the one from the decorator.

We'll have to investigate how to deal with this from TestSlide, @xueqiao001 sorry to guide towards the wrong direction back in May, tkz @fabriziocucci for reopening this.

@fornellas
Copy link
Contributor

fornellas commented Jul 7, 2020

So, @asynccontextmanager uses wraps under the hood, with its defaults, which copy __annotations__ from the decorated function, tho the resulting function, thus yielding the wrong type.

As far as I understand:

@asynccontextmanager 
async def get_cm() -> AsyncGenerator[int, None]: 
  yield 1 

get_cm does return an AsyncGenerator, but the resulting function after asynccontextmanager returns AsyncContextManager...

Doing what I suggested earlier:

@asynccontextmanager 
async def get_cm() -> AsyncContextManager: 
  yield 1 

is technically the wrong annotation for get_cm... but it does make asynccontextmanager result legit...

IMHO, it seems that asynccontextmanager is buggy, and is messing up with types there. From TestSlide's own perspective, I'm not sure how to deal with this, but it seems that an upstream bug for asynccontextmanager makes sense here.

Opinions?

PS: tkz @david-caro for finding the original Python bug https://bugs.python.org/issue8814 .

@fornellas fornellas added the bug Something isn't working label Jul 7, 2020
@fornellas
Copy link
Contributor

Python Upstream bug filed: https://bugs.python.org/issue41231.

@fornellas fornellas added the help wanted Extra attention is needed label Jul 8, 2020
@fornellas fornellas added this to the Type Checking milestone Jul 9, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working help wanted Extra attention is needed
Projects
None yet
Development

No branches or pull requests

3 participants