lagom icon indicating copy to clipboard operation
lagom copied to clipboard

Cannot get _is_coroutine on injectable when mock

Open rodfersou opened this issue 3 years ago • 11 comments

Error when inject async function:

/cache/asdf/installs/python/3.8.11/lib/python3.8/unittest/mock.py:1322:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
/cache/asdf/installs/python/3.8.11/lib/python3.8/unittest/mock.py:1304: in decoration_helper
    arg = exit_stack.enter_context(patching)
/cache/asdf/installs/python/3.8.11/lib/python3.8/unittest/mock.py:1477: in __enter__
    new = create_autospec(autospec, spec_set=spec_set,
/cache/asdf/installs/python/3.8.11/lib/python3.8/unittest/mock.py:2607: in create_autospec
    mock = Klass(parent=_parent, _new_parent=_parent, _new_name=_new_name,
/cache/asdf/installs/python/3.8.11/lib/python3.8/unittest/mock.py:2006: in __init__
    _safe_super(MagicMixin, self).__init__(*args, **kw)
/cache/asdf/installs/python/3.8.11/lib/python3.8/unittest/mock.py:444: in __init__
    self._mock_add_spec(spec, spec_set, _spec_as_instance, _eat_self)
/cache/asdf/installs/python/3.8.11/lib/python3.8/unittest/mock.py:499: in _mock_add_spec
    if asyncio.iscoroutinefunction(getattr(spec, attr, None)):
/cache/asdf/installs/python/3.8.11/lib/python3.8/asyncio/coroutines.py:167: in iscoroutinefunction
    getattr(func, '_is_coroutine', None) is _is_coroutine)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <lagom.markers.Injectable object at 0xffffb8796610>, item = '_is_coroutine'

    def __getattr__(self, item: str):
        """
        injectable should never have it's attributes referenced or a method call.
        This normally indicates that the default injectable value hasn't been
        handled by lagom - which is likely a function missing a bind decorator.
        """
        # Ignore dunder methods as it's likely some decorator magic and
        # it doesn't really help to raise an exception then.
        if item.startswith("__") and item.endswith("__"):
            return None
>       raise InjectableNotResolved(
            f"Cannot get {item} on injectable. Make sure the function was bound to a container instance"
        )
E       lagom.exceptions.InjectableNotResolved: Cannot get _is_coroutine on injectable. Make sure the function was bound to a container instance

.venv/lib/python3.8/site-packages/lagom/markers.py:46: InjectableNotResolved

rodfersou avatar Dec 14 '21 09:12 rodfersou

@rodfersou interesting. Have you got some sample code I could use to replicate this?

meadsteve avatar Dec 14 '21 09:12 meadsteve

it's ~~really breaking:~~

import asyncio
from abc import ABC

from lagom import bind_to_container, Container, dependency_definition, injectable
from lagom.environment import Env

## Assume we have a Thing with a slightly different class in dev
## or maybe just in a different deployment


class Thing(ABC):
    async def run(self):
        ...


class ProdVersionOfThing(Thing):
    def __init__(self, config):
        self.config = config

    def run(self):
        print(f"PROD: {self.config}")


class DevVersionOfThing(Thing):
    def run(self):
        print("DEV THING")


## Lagom provides an env class to represent a logical
## grouping of environment variables


class ThingEnvironment(Env):
    # This maps to an environment variable THING_CONN
    thing_conn: str


## We define a dependency_definition function for
## our Thing which fetches the env from the container
## automatically loading the env variable.
## Note: if the env variable is unset an error will be raised
container = Container()


@dependency_definition(container, singleton=True)
def _thing_loader(c: Container) -> Thing:
    connection_string = c[ThingEnvironment].thing_conn
    if connection_string.startswith("dev://"):
        return DevVersionOfThing()
    return ProdVersionOfThing(connection_string)


@bind_to_container(container)
async def main(thing: Thing = injectable):
    thing.run()


if __name__ == "__main__":
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())

rodfersou avatar Dec 14 '21 09:12 rodfersou

still working.. investigating if ~the problem is with pytest~

rodfersou avatar Dec 14 '21 09:12 rodfersou

looking at the traceback, the problem is related with unittest.mock and async in a function with injectable default value

rodfersou avatar Dec 14 '21 10:12 rodfersou

Ah this makes more sense then. I've not done much mocking of any entities that are decorated and have injectable arguments. It's not really a usecase I'd considered so it possibly has some unexpected behaviour like this. I'll need to spend some time investigating to give a clearer answer.

meadsteve avatar Dec 14 '21 11:12 meadsteve

understand, in our case we need to rely on mock simply because we are testing the API, and some calls happen before reach the entities that are injectable.

rodfersou avatar Dec 15 '21 07:12 rodfersou

One of the design goals of lagom was to avoid having mocking at this level so I'd be interested to see what your test cases look like. If you've got an example you could share that would be really helpful.

meadsteve avatar Dec 15 '21 08:12 meadsteve

in this specific test we are treating the system as a blackbox, so we do a GET or POST in one endpoint and get the results

by default we have some testing / production versions of the dependencies, and this work great

but for example we want to force the system to behave like the user don't have permission to do the action, so we want to change the dependency to one that raises an exception

Agree that would be possible to do this change directly in the container, but turned out that if I use this pattern:

@bind_to_container(container)
def main(thing: Thing = injectable):
    thing.run()

the container can't be overriten, it's like the implementation of the decorator is not allowing to access changes outside the function scope, that's why I need to be creative to fix using a mock:

from functools import partial
from unittest import mock

import my_module


@mock.patch(
    "my_module.my_injected_function",
    partial(my_module.my_injected_function, injected_parameter="Custom Value"),
)
def test_thing() -> None:
    response = get_json(
        f"/my/api/{some_test_id}"
    )
    assert ...

rodfersou avatar Dec 15 '21 08:12 rodfersou

Thanks. The way I would test a function like:

@bind_to_container(container)
def main(thing: Thing = injectable):
    thing.run()

would be

from unittest import mock

from my_module import main


def test_thing() -> None:
    mock_thing = mock(Thing)
    result = main(mock_thing)
    assert ...

Ideally with lagom functions bound to the container should become "pure" with respect to their input so you would not need to mock the function itself.

But in any case the injected marker and container decorator should behave better when mocked so I'll look into this a little more.

meadsteve avatar Dec 15 '21 09:12 meadsteve

Interestingly my straight-forward mocking of a bound function seems to work: 3bb77e241082d52c1c60e6669564f8336135ffe7 so I'm struggling to replicate this at the moment.

meadsteve avatar Dec 16 '21 08:12 meadsteve

@meadsteve like I described, it's not an unit test, it's an integration test, in this case you don't have direct access to the function that you are injecting dependency

rodfersou avatar Dec 29 '21 01:12 rodfersou