Repository: ikalnytskyi/picobox

Issue:
Add coroutine functions support to `pass_` decorator
Currently the `picobox.pass_()` decorator can be successfully applied to coroutine functions, and such a coroutine function can be successfully used. For instance, the following code works as expected:

```python
@picobox.pass_("database_session")
async def get_movies_by_name(name: str, database_conn: DatabaseSession) -> list[Movie]:
    return await database_conn.query(...)

# ...

await get_movies_by_name("The Batman")
```

The `get_movies_by_name` coroutine function, however, lose its coroutine function marker, which may lead to undesired consequences when used with frameworks or libraries that support both sync and async interface, and use `inspect.iscoroutinefunction()` to properly dispatch its execution (e.g. `Starlette`).

```python
>>> inspect.iscoroutinefunction(get_movies_by_name)
False
```

Discussion:

Patch:
diff --git a/docs/index.rst b/docs/index.rst
index eff5e2b..12c0e27 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -358,6 +358,15 @@ Release Notes
     backward incompatible changes will be released along with bumping major
     version component.
 
+4.1.0
+`````
+
+(unreleased)
+
+* Fix a bug when a coroutine function wrapped with ``@picobox.pass_()``
+  lost its coroutine function marker, i.e. ``inspect.iscoroutinefunction()``
+  returned ``False``.
+
 4.0.0
 `````
 
diff --git a/pyproject.toml b/pyproject.toml
index d3c2d2d..1fdcd19 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,7 +35,7 @@ Bugs = "https://github.com/ikalnytskyi/picobox/issues"
 source = "vcs"
 
 [tool.hatch.envs.test]
-dependencies = ["pytest", "flask"]
+dependencies = ["pytest", "pytest-asyncio", "flask"]
 scripts.run = "python -m pytest --strict-markers {args:-vv}"
 
 [tool.hatch.envs.lint]
diff --git a/src/picobox/_box.py b/src/picobox/_box.py
index 3c5aaca..77bed5a 100644
--- a/src/picobox/_box.py
+++ b/src/picobox/_box.py
@@ -187,7 +187,7 @@ class Box:
                 return fn
 
             @functools.wraps(fn)
-            def wrapper(*args, **kwargs):
+            def fn_with_dependencies(*args, **kwargs):
                 signature = inspect.signature(fn)
                 arguments = signature.bind_partial(*args, **kwargs)
 
@@ -203,6 +203,14 @@ class Box:
                         kwargs[as_] = self.get(key)
                 return fn(*args, **kwargs)
 
+            if inspect.iscoroutinefunction(fn):
+
+                @functools.wraps(fn)
+                async def wrapper(*args, **kwargs):
+                    return await fn_with_dependencies(*args, **kwargs)
+            else:
+                wrapper = fn_with_dependencies
+
             wrapper.__dependencies__ = [(key, as_)]
             return wrapper
 


Test patch:
diff --git a/tests/test_box.py b/tests/test_box.py
index 9b7cfb4..f37cb4b 100644
--- a/tests/test_box.py
+++ b/tests/test_box.py
@@ -386,6 +386,27 @@ def test_box_pass_method(args, kwargs, rv, boxclass):
     assert Foo(*args, **kwargs).x == rv
 
 
+@pytest.mark.asyncio()
+@pytest.mark.parametrize(
+    ("args", "kwargs", "rv"),
+    [
+        ((1,), {}, 1),
+        ((), {"x": 1}, 1),
+        ((), {}, 42),
+    ],
+)
+async def test_box_pass_coroutine(args, kwargs, rv, boxclass):
+    testbox = boxclass()
+    testbox.put("x", 42)
+
+    @testbox.pass_("x")
+    async def co(x):
+        return x
+
+    assert inspect.iscoroutinefunction(co)
+    assert await co(*args, **kwargs) == rv
+
+
 @pytest.mark.parametrize(
     ("args", "kwargs", "rv"),
     [
@@ -490,6 +511,28 @@ def test_box_pass_optimization_complex(boxclass, request):
     assert len(fn()) == 3
 
 
+@pytest.mark.asyncio()
+async def test_box_pass_optimization_async(boxclass, request):
+    testbox = boxclass()
+    testbox.put("a", 1)
+    testbox.put("b", 1)
+    testbox.put("d", 1)
+
+    @testbox.pass_("a")
+    @testbox.pass_("b")
+    @testbox.pass_("d", as_="c")
+    async def fn(a, b, c):
+        backtrace = list(
+            itertools.dropwhile(
+                lambda frame: frame[2] != request.function.__name__,
+                traceback.extract_stack(),
+            )
+        )
+        return backtrace[1:-1]
+
+    assert len(await fn()) == 1
+
+
 def test_chainbox_put_changes_box():
     testbox = picobox.Box()
     testchainbox = picobox.ChainBox(testbox)
diff --git a/tests/test_stack.py b/tests/test_stack.py
index a6be870..3570268 100644
--- a/tests/test_stack.py
+++ b/tests/test_stack.py
@@ -1,5 +1,6 @@
 """Test picobox's stack interface."""
 
+import inspect
 import itertools
 import sys
 import traceback
@@ -449,6 +450,28 @@ def test_box_pass_method(boxclass, teststack, args, kwargs, rv):
         assert Foo(*args, **kwargs).x == rv
 
 
+@pytest.mark.asyncio()
+@pytest.mark.parametrize(
+    ("args", "kwargs", "rv"),
+    [
+        ((1,), {}, 1),
+        ((), {"x": 1}, 1),
+        ((), {}, 42),
+    ],
+)
+async def test_box_pass_coroutine(boxclass, teststack, args, kwargs, rv):
+    testbox = boxclass()
+    testbox.put("x", 42)
+
+    @teststack.pass_("x")
+    async def co(x):
+        return x
+
+    with teststack.push(testbox):
+        assert inspect.iscoroutinefunction(co)
+        assert await co(*args, **kwargs) == rv
+
+
 @pytest.mark.parametrize(
     ("args", "kwargs", "rv"),
     [
@@ -567,6 +590,29 @@ def test_box_pass_optimization_complex(boxclass, teststack, request):
         assert len(fn()) == 3
 
 
+@pytest.mark.asyncio()
+async def test_box_pass_optimization_async(boxclass, teststack, request):
+    testbox = boxclass()
+    testbox.put("a", 1)
+    testbox.put("b", 1)
+    testbox.put("d", 1)
+
+    @teststack.pass_("a")
+    @teststack.pass_("b")
+    @teststack.pass_("d", as_="c")
+    async def fn(a, b, c):
+        backtrace = list(
+            itertools.dropwhile(
+                lambda frame: frame[2] != request.function.__name__,
+                traceback.extract_stack(),
+            )
+        )
+        return backtrace[1:-1]
+
+    with teststack.push(testbox):
+        assert len(await fn()) == 1
+
+
 def test_chainbox_put_changes_box(teststack):
     testbox = picobox.Box()
     testchainbox = picobox.ChainBox(testbox)