Repository: sanic-org/sanic-ext
Pull request #128 (2022-09-15 19:09:45)

Add support for class constructor injections
Adds support for class constructors to by used for injection:

```python
class Alpha:
    ...


class Beta:
    def __init__(self, alpha: Alpha) -> None:
        self.alpha = alpha

app.ext.add_dependency(Alpha)
app.ext.add_dependency(Beta)
```


Patch:
diff --git a/sanic_ext/extensions/http/methods.py b/sanic_ext/extensions/http/methods.py
index 62071af..7c9700e 100644
--- a/sanic_ext/extensions/http/methods.py
+++ b/sanic_ext/extensions/http/methods.py
@@ -92,7 +92,7 @@ def add_auto_handlers(
                                 name=name,
                                 host=host,
                             )
-            app.router.finalize()
+            app.finalize()
 
         if auto_trace:
             app.router.reset()
@@ -104,7 +104,7 @@ def add_auto_handlers(
                         methods=["TRACE"],
                         strict_slashes=group.strict,
                     )
-            app.router.finalize()
+            app.finalize()
 
         if auto_options:
             app.router.reset()
@@ -138,4 +138,4 @@ def add_auto_handlers(
                             name=name,
                             host=host,
                         )
-            app.router.finalize()
+            app.finalize()
diff --git a/sanic_ext/extensions/injection/constructor.py b/sanic_ext/extensions/injection/constructor.py
index 8669bf0..4a8b12c 100644
--- a/sanic_ext/extensions/injection/constructor.py
+++ b/sanic_ext/extensions/injection/constructor.py
@@ -1,14 +1,17 @@
 from __future__ import annotations
 
-from inspect import iscoroutine
+from dataclasses import is_dataclass
+from inspect import isclass, iscoroutine
 from typing import (
     TYPE_CHECKING,
     Any,
     Callable,
     Dict,
+    Optional,
     Set,
     Tuple,
     Type,
+    get_args,
     get_type_hints,
 )
 
@@ -16,6 +19,7 @@ from sanic import Request
 from sanic.exceptions import ServerError
 
 from sanic_ext.exceptions import InitError
+from sanic_ext.utils.typing import is_attrs, is_optional, is_pydantic
 
 if TYPE_CHECKING:
     from .registry import InjectionRegistry
@@ -30,7 +34,8 @@ class Constructor:
     ):
         self.func = func
         self.injections: Dict[str, Tuple[Type, Constructor]] = {}
-        self.pass_kwargs = False
+        self.pass_kwargs: bool = False
+        self.request_arg: Optional[str] = None
 
     def __str__(self) -> str:
         return f"<{self.__class__.__name__}:{self.func.__name__}>"
@@ -43,7 +48,12 @@ class Constructor:
             args = await gather_args(self.injections, request, **kwargs)
             if self.pass_kwargs:
                 args.update(kwargs)
-            retval = self.func(request, **args)
+
+            if self.request_arg:
+                args[self.request_arg] = request
+
+            retval = self.func(**args)
+
             if iscoroutine(retval):
                 retval = await retval
             return retval
@@ -59,12 +69,19 @@ class Constructor:
         injection_registry: InjectionRegistry,
         allowed_types: Set[Type[object]],
     ) -> None:
-        hints = get_type_hints(self.func)
+        hints = self._get_hints()
         hints.pop("return", None)
         missing = []
         for param, annotation in hints.items():
             if annotation in allowed_types:
                 self.pass_kwargs = True
+            if is_optional(annotation):
+                annotation = get_args(annotation)[0]
+            if not isclass(annotation):
+                missing.append((param, annotation))
+                continue
+            if issubclass(annotation, Request):
+                self.request_arg = param
             if (
                 annotation not in self.EXEMPT_ANNOTATIONS
                 and not issubclass(annotation, self.EXEMPT_ANNOTATIONS)
@@ -73,6 +90,7 @@ class Constructor:
                 dependency = injection_registry.get(annotation)
                 if not dependency:
                     missing.append((param, annotation))
+                    continue
                 self.injections[param] = (annotation, dependency)
 
         if missing:
@@ -123,6 +141,18 @@ class Constructor:
         current.remove(dependency)
         checked.add(dependency)
 
+    def _get_hints(self):
+        if (
+            not isclass(self.func)
+            or is_dataclass(self.func)
+            or is_attrs(self.func)
+            or is_pydantic(self.func)
+        ):
+            return get_type_hints(self.func)
+        elif isclass(self.func):
+            return get_type_hints(self.func.__init__)
+        raise InitError(f"Cannot get type hints for {self.func}")
+
 
 async def gather_args(injections, request, **kwargs) -> Dict[str, Any]:
     return {
diff --git a/sanic_ext/extensions/injection/registry.py b/sanic_ext/extensions/injection/registry.py
index 2858be1..411dd13 100644
--- a/sanic_ext/extensions/injection/registry.py
+++ b/sanic_ext/extensions/injection/registry.py
@@ -22,8 +22,8 @@ class InjectionRegistry:
     def register(
         self, _type: Type, constructor: Optional[Callable[..., Any]]
     ) -> None:
-        if constructor:
-            constructor = Constructor(constructor)
+        constructor = constructor or _type
+        constructor = Constructor(constructor)
         self._registry[_type] = constructor
 
     def finalize(self, allowed_types):
diff --git a/sanic_ext/extras/validation/check.py b/sanic_ext/extras/validation/check.py
index 42dc7bc..d9a72ba 100644
--- a/sanic_ext/extras/validation/check.py
+++ b/sanic_ext/extras/validation/check.py
@@ -7,13 +7,6 @@ from sanic_ext.utils.typing import UnionType, is_generic, is_optional
 
 MISSING: Tuple[Any, ...] = (_HAS_DEFAULT_FACTORY,)
 
-try:
-    from pydantic import BaseModel
-
-    PYDANTIC = True
-except ImportError:
-    PYDANTIC = False
-
 try:
     import attrs  # noqa
 
@@ -244,13 +237,3 @@ def _check_dict(value, allowed, hint, schema, allow_multiple, allow_coerce):
         except (ValueError, TypeError):
             ...
     raise ValueError(f"Value '{value}' must be a {hint}")
-
-
-def is_pydantic(model):
-    return PYDANTIC and (
-        issubclass(model, BaseModel) or hasattr(model, "__pydantic_model__")
-    )
-
-
-def is_attrs(model):
-    return ATTRS and (hasattr(model, "__attrs_attrs__"))
diff --git a/sanic_ext/extras/validation/schema.py b/sanic_ext/extras/validation/schema.py
index 0d5460e..1d66c25 100644
--- a/sanic_ext/extras/validation/schema.py
+++ b/sanic_ext/extras/validation/schema.py
@@ -13,9 +13,9 @@ from typing import (
     get_type_hints,
 )
 
-from sanic_ext.utils.typing import is_generic
+from sanic_ext.utils.typing import is_attrs, is_generic
 
-from .check import Hint, is_attrs
+from .check import Hint
 
 try:
     UnionType = types.UnionType  # type: ignore
diff --git a/sanic_ext/extras/validation/setup.py b/sanic_ext/extras/validation/setup.py
index 18fd915..6bdf2d3 100644
--- a/sanic_ext/extras/validation/setup.py
+++ b/sanic_ext/extras/validation/setup.py
@@ -4,8 +4,8 @@ from inspect import isawaitable, isclass
 from sanic.log import logger
 
 from sanic_ext.exceptions import ValidationError
+from sanic_ext.utils.typing import is_pydantic
 
-from .check import is_pydantic
 from .schema import make_schema
 from .validators import (
     _validate_annotations,
diff --git a/sanic_ext/utils/typing.py b/sanic_ext/utils/typing.py
index 4865a25..c34ab05 100644
--- a/sanic_ext/utils/typing.py
+++ b/sanic_ext/utils/typing.py
@@ -6,6 +6,20 @@ try:
 except AttributeError:
     UnionType = type("UnionType", (), {})
 
+try:
+    from pydantic import BaseModel
+
+    PYDANTIC = True
+except ImportError:
+    PYDANTIC = False
+
+try:
+    import attrs  # noqa
+
+    ATTRS = True
+except ImportError:
+    ATTRS = False
+
 
 def is_generic(item):
     return (
@@ -20,3 +34,13 @@ def is_optional(item):
         args = typing.get_args(item)
         return len(args) == 2 and type(None) in args
     return False
+
+
+def is_pydantic(model):
+    return PYDANTIC and (
+        issubclass(model, BaseModel) or hasattr(model, "__pydantic_model__")
+    )
+
+
+def is_attrs(model):
+    return ATTRS and (hasattr(model, "__attrs_attrs__"))
diff --git a/setup.cfg b/setup.cfg
index 3b75a8d..4f5ca27 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = sanic-ext
-version = 22.6.3
+version = 22.9.0
 url = http://github.com/sanic-org/sanic-ext/
 license = MIT
 author = Sanic Community
@@ -28,7 +28,7 @@ sanic_ext =
 
 [options.extras_require]
 test =
-    sanic_testing>=0.8
+    sanic_testing==22.9.0b1
     coverage
     pytest
     pytest-cov


Test patch:
diff --git a/tests/extensions/http/test_methods.py b/tests/extensions/http/test_methods.py
index 5635f86..faf27d0 100644
--- a/tests/extensions/http/test_methods.py
+++ b/tests/extensions/http/test_methods.py
@@ -18,6 +18,8 @@ def test_trace_and_connect_available(app: Sanic):
 
 
 def test_auto_head(app: Sanic, get_docs):
+    app.config.TOUCHUP = False
+
     @app.get("/foo")
     async def foo_handler(_):
         return text("...")
diff --git a/tests/extensions/injection/test_add_dependency.py b/tests/extensions/injection/test_add_dependency.py
index 8885cda..c0488ef 100644
--- a/tests/extensions/injection/test_add_dependency.py
+++ b/tests/extensions/injection/test_add_dependency.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 import asyncio
 from dataclasses import dataclass
 from itertools import count
+from typing import Optional
 
 import pytest
 from sanic import Request, json, text
@@ -34,7 +35,7 @@ class Person:
     age: int
 
     @classmethod
-    async def create(cls, request, person_id: int) -> Person:
+    async def create(cls, person_id: int) -> Person:
         return cls(person_id=PersonID(person_id), name="noname", age=111)
 
 
@@ -97,6 +98,35 @@ class E:
         return cls(c, d)
 
 
+class Alpha:
+    ...
+
+
+class Beta:
+    def __init__(self, alpha: Alpha) -> None:
+        self.alpha = alpha
+
+
+class Gamma:
+    def __init__(self, beta: Beta, request: Optional[Request] = None) -> None:
+        self.beta = beta
+        self.request = request
+
+
+class Delta:
+    def __init__(self, gamma: Gamma, request: Request) -> None:
+        self.gamma = gamma
+        self.request = request
+
+
+def make_gamma_with_request(beta: Beta, request: Request):
+    return Gamma(beta, request)
+
+
+def make_gamma_without_request(beta: Beta):
+    return Gamma(beta)
+
+
 def test_injection_not_allowed_when_ext_disabled(bare_app):
     ext = Extend(bare_app, built_in_extensions=False)
 
@@ -280,3 +310,88 @@ def test_injection_of_awaitable_variable_in_call(app):
     assert response.body == b"george"
     assert isinstance(request.ctx.name, AsyncName)
     assert request.ctx.name.name == "george"
+
+
+def test_injection_class_constructors(app):
+    app.ext.add_dependency(Alpha)
+    app.ext.add_dependency(Beta)
+
+    @app.get("/")
+    def handler(request: Request, beta: Beta):
+        return json({"is_beta": isinstance(beta, Beta)})
+
+    _, response = app.test_client.get("/")
+    assert response.json == {"is_beta": True}
+
+
+def test_injection_class_constructors_with_optional_request(app):
+    app.ext.add_dependency(Alpha)
+    app.ext.add_dependency(Beta)
+    app.ext.add_dependency(Gamma)
+
+    @app.get("/")
+    def handler(request: Request, gamma: Gamma):
+        return json(
+            {
+                "is_gamma": isinstance(gamma, Gamma),
+                "has_request": bool(gamma.request),
+            }
+        )
+
+    _, response = app.test_client.get("/")
+    assert response.json == {"is_gamma": True, "has_request": True}
+
+
+def test_injection_class_constructors_with_request(app):
+    app.ext.add_dependency(Alpha)
+    app.ext.add_dependency(Beta)
+    app.ext.add_dependency(Gamma)
+    app.ext.add_dependency(Delta)
+
+    @app.get("/")
+    def handler(request: Request, delta: Delta):
+        return json(
+            {
+                "is_delta": isinstance(delta, Delta),
+                "has_request": bool(delta.request),
+            }
+        )
+
+    _, response = app.test_client.get("/")
+    assert response.json == {"is_delta": True, "has_request": True}
+
+
+def test_injection_class_constructors_with_func_and_request(app):
+    app.ext.add_dependency(Alpha)
+    app.ext.add_dependency(Beta)
+    app.ext.add_dependency(Gamma, make_gamma_with_request)
+
+    @app.get("/")
+    def handler(request: Request, gamma: Gamma):
+        return json(
+            {
+                "is_gamma": isinstance(gamma, Gamma),
+                "has_request": bool(gamma.request),
+            }
+        )
+
+    _, response = app.test_client.get("/")
+    assert response.json == {"is_gamma": True, "has_request": True}
+
+
+def test_injection_class_constructors_with_func_and_no_request(app):
+    app.ext.add_dependency(Alpha)
+    app.ext.add_dependency(Beta)
+    app.ext.add_dependency(Gamma, make_gamma_without_request)
+
+    @app.get("/")
+    def handler(request: Request, gamma: Gamma):
+        return json(
+            {
+                "is_gamma": isinstance(gamma, Gamma),
+                "has_request": bool(gamma.request),
+            }
+        )
+
+    _, response = app.test_client.get("/")
+    assert response.json == {"is_gamma": True, "has_request": False}