Repository: sanic-org/sanic-ext
Pull request #156 (2022-12-06 09:32:04)

Constant injection
The purpose of this is to add an API for injecting constants into dependencies.

A simple example:

```python
@dataclass
class Foo:
    bar: int


app.ext.add_constant("bar", 999)
app.ext.add_dependency(Foo)


@app.get("/")
async def handler(request: Request, foo: Foo):
    return json({"foo": foo.bar})
```

Here, we have a constant `bar` that will be injected into the `Foo` object when instantiated.

These constants are added to `app.config`, and can also be accessed: `app.config.BAR`.

This opens up the idea that all config variables might want to be accessible as injected constants. For this, there is a convenience method that will set these up for you:

```python
app.ext.load_constants()
```

By default this method will load `app.config`, but you can also pass it your own mapping of key/value pairs. I have gone back and forth about whether the config should be loaded by default, so I am open to thoughts/suggestions.



Patch:
diff --git a/sanic_ext/bootstrap.py b/sanic_ext/bootstrap.py
index 6e775cc..48882d5 100644
--- a/sanic_ext/bootstrap.py
+++ b/sanic_ext/bootstrap.py
@@ -1,11 +1,13 @@
 from __future__ import annotations
 
 from types import SimpleNamespace
-from typing import Any, Callable, Dict, List, Optional, Type, Union
+from typing import Any, Callable, Dict, List, Mapping, Optional, Type, Union
 from warnings import warn
 
 from sanic import Sanic, __version__
+from sanic.config import DEFAULT_CONFIG
 from sanic.exceptions import SanicException
+from sanic.helpers import Default, _default
 from sanic.log import logger
 
 from sanic_ext.config import Config, add_fallback_config
@@ -13,7 +15,10 @@ from sanic_ext.extensions.base import Extension
 from sanic_ext.extensions.health.extension import HealthExtension
 from sanic_ext.extensions.http.extension import HTTPExtension
 from sanic_ext.extensions.injection.extension import InjectionExtension
-from sanic_ext.extensions.injection.registry import InjectionRegistry
+from sanic_ext.extensions.injection.registry import (
+    ConstantRegistry,
+    InjectionRegistry,
+)
 from sanic_ext.extensions.logging.extension import LoggingExtension
 from sanic_ext.extensions.openapi.builders import SpecificationBuilder
 from sanic_ext.extensions.openapi.extension import OpenAPIExtension
@@ -70,6 +75,7 @@ class Extend:
             )
 
         self._injection_registry: Optional[InjectionRegistry] = None
+        self._constant_registry: Optional[ConstantRegistry] = None
         self._openapi: Optional[SpecificationBuilder] = None
         self.app = app
         self.extensions: List[Extension] = []
@@ -136,6 +142,32 @@ class Extend:
             raise SanicException("Injection extension not enabled")
         self._injection_registry.register(type, constructor)
 
+    def add_constant(self, name: str, value: Any, overwrite: bool = False):
+        if not self._constant_registry:
+            raise ValueError("Cannot add constant. No registry created.")
+        self._constant_registry.register(name, value, overwrite)
+
+    def load_constants(
+        self,
+        constants: Optional[Mapping[str, Any]] = None,
+        overwrite: Union[Default, bool] = _default,
+    ):
+        if not constants:
+            constants = {
+                k: v
+                for k, v in self.app.config.items()
+                if k.isupper()
+                and k not in DEFAULT_CONFIG
+                and k not in self.config
+                and not k.startswith("_")
+            }
+            if isinstance(overwrite, Default):
+                overwrite = True
+        if isinstance(overwrite, Default):
+            overwrite = False
+        for name, value in constants.items():
+            self.add_constant(name, value, overwrite)
+
     def dependency(self, obj: Any, name: Optional[str] = None) -> None:
         if not name:
             name = camel_to_snake(obj.__class__.__name__)
diff --git a/sanic_ext/config.py b/sanic_ext/config.py
index 93775d2..8d78a11 100644
--- a/sanic_ext/config.py
+++ b/sanic_ext/config.py
@@ -36,6 +36,7 @@ class Config(SanicConfig):
         http_auto_options: bool = True,
         http_auto_trace: bool = False,
         injection_signal: Union[str, Event] = Event.HTTP_ROUTING_AFTER,
+        injection_load_custom_constants: bool = False,
         logging: bool = False,
         logging_queue_max_size: int = 4096,
         oas: bool = True,
@@ -85,6 +86,7 @@ class Config(SanicConfig):
         self.HTTP_AUTO_OPTIONS = http_auto_options
         self.HTTP_AUTO_TRACE = http_auto_trace
         self.INJECTION_SIGNAL = injection_signal
+        self.INJECTION_LOAD_CUSTOM_CONSTANTS = injection_load_custom_constants
         self.LOGGING = logging
         self.LOGGING_QUEUE_MAX_SIZE = logging_queue_max_size
         self.OAS = oas
diff --git a/sanic_ext/extensions/injection/constructor.py b/sanic_ext/extensions/injection/constructor.py
index 4a8b12c..0a89bce 100644
--- a/sanic_ext/extensions/injection/constructor.py
+++ b/sanic_ext/extensions/injection/constructor.py
@@ -16,13 +16,14 @@ from typing import (
 )
 
 from sanic import Request
+from sanic.app import Sanic
 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
+    from .registry import ConstantRegistry, InjectionRegistry
 
 
 class Constructor:
@@ -34,6 +35,7 @@ class Constructor:
     ):
         self.func = func
         self.injections: Dict[str, Tuple[Type, Constructor]] = {}
+        self.constants: Dict[str, Any] = {}
         self.pass_kwargs: bool = False
         self.request_arg: Optional[str] = None
 
@@ -46,6 +48,7 @@ class Constructor:
     async def __call__(self, request, **kwargs):
         try:
             args = await gather_args(self.injections, request, **kwargs)
+            args.update(self.constants)
             if self.pass_kwargs:
                 args.update(kwargs)
 
@@ -66,13 +69,17 @@ class Constructor:
 
     def prepare(
         self,
+        app: Sanic,
         injection_registry: InjectionRegistry,
+        constant_registry: ConstantRegistry,
         allowed_types: Set[Type[object]],
     ) -> None:
         hints = self._get_hints()
         hints.pop("return", None)
         missing = []
         for param, annotation in hints.items():
+            if param in constant_registry:
+                self.constants[param] = getattr(app.config, param.upper())
             if annotation in allowed_types:
                 self.pass_kwargs = True
             if is_optional(annotation):
diff --git a/sanic_ext/extensions/injection/extension.py b/sanic_ext/extensions/injection/extension.py
index 46a27a7..2bcf474 100644
--- a/sanic_ext/extensions/injection/extension.py
+++ b/sanic_ext/extensions/injection/extension.py
@@ -1,15 +1,22 @@
 from ..base import Extension
 from .injector import add_injection
-from .registry import InjectionRegistry
+from .registry import ConstantRegistry, InjectionRegistry
 
 
 class InjectionExtension(Extension):
     name = "injection"
 
     def startup(self, bootstrap) -> None:
+        self.constant_registry = ConstantRegistry(self.app.config)
         self.registry = InjectionRegistry()
-        add_injection(self.app, self.registry)
+        add_injection(self.app, self.registry, self.constant_registry)
         bootstrap._injection_registry = self.registry
+        bootstrap._constant_registry = self.constant_registry
+        if self.config.INJECTION_LOAD_CUSTOM_CONSTANTS:
+            self.app.ext.load_constants()
 
     def label(self):
-        return f"{self.registry.length} added"
+        return (
+            f"{self.registry.length} dependencies; "
+            f"{self.constant_registry.length} constants"
+        )
diff --git a/sanic_ext/extensions/injection/injector.py b/sanic_ext/extensions/injection/injector.py
index a375671..1cc8112 100644
--- a/sanic_ext/extensions/injection/injector.py
+++ b/sanic_ext/extensions/injection/injector.py
@@ -6,15 +6,20 @@ from typing import Any, Callable, Dict, Optional, Tuple, Type, get_type_hints
 
 from sanic import Sanic
 from sanic.constants import HTTP_METHODS
-from sanic.signals import Event
 
 from sanic_ext.extensions.injection.constructor import gather_args
 
-from .registry import InjectionRegistry, SignatureRegistry
+from .registry import ConstantRegistry, InjectionRegistry, SignatureRegistry
 
 
-def add_injection(app: Sanic, injection_registry: InjectionRegistry) -> None:
-    signature_registry = _setup_signature_registry(app, injection_registry)
+def add_injection(
+    app: Sanic,
+    injection_registry: InjectionRegistry,
+    constant_registry: ConstantRegistry,
+) -> None:
+    signature_registry = _setup_signature_registry(
+        app, injection_registry, constant_registry
+    )
 
     @app.after_server_start
     async def finalize_injections(app: Sanic, _):
@@ -29,9 +34,9 @@ def add_injection(app: Sanic, injection_registry: InjectionRegistry) -> None:
                 hints = get_type_hints(converter)
                 if return_type := hints.get("return"):
                     router_types.add(return_type)
-        injection_registry.finalize(router_types)
+        injection_registry.finalize(app, constant_registry, router_types)
 
-    injection_signal: Event = app.ext.config.INJECTION_SIGNAL
+    injection_signal = app.ext.config.INJECTION_SIGNAL
 
     @app.signal(injection_signal)
     async def inject_kwargs(request, **_):
@@ -41,15 +46,19 @@ def add_injection(app: Sanic, injection_registry: InjectionRegistry) -> None:
             request.route.name,
             f"{request.route.name}_{request.method.lower()}",
         ):
-            injections = signature_registry.get(name)
-            if injections:
+            dependencies, constants = signature_registry.get(
+                name, (None, None)
+            )
+            if dependencies or constants:
                 break
 
-        if injections:
+        if dependencies:
             injected_args = await gather_args(
-                injections, request, **request.match_info
+                dependencies, request, **request.match_info
             )
             request.match_info.update(injected_args)
+        if constants:
+            request.match_info.update(constants)
 
 
 def _http_method_predicate(member):
@@ -59,6 +68,7 @@ def _http_method_predicate(member):
 def _setup_signature_registry(
     app: Sanic,
     injection_registry: InjectionRegistry,
+    constant_registry: ConstantRegistry,
 ) -> SignatureRegistry:
     registry = SignatureRegistry()
 
@@ -93,16 +103,19 @@ def _setup_signature_registry(
                 except TypeError:
                     continue
 
-                injections: Dict[
+                dependencies: Dict[
                     str, Tuple[Type, Optional[Callable[..., Any]]]
-                ] = {
-                    param: (
-                        annotation,
-                        injection_registry[annotation],
-                    )
-                    for param, annotation in hints.items()
-                    if annotation in injection_registry
-                }
-                registry.register(name, injections)
+                ] = {}
+                constants: Dict[str, Any] = {}
+                for param, annotation in hints.items():
+                    if annotation in injection_registry:
+                        dependencies[param] = (
+                            annotation,
+                            injection_registry[annotation],
+                        )
+                    if param in constant_registry:
+                        constants[param] = app.config[param.upper()]
+
+                registry.register(name, dependencies, constants)
 
     return registry
diff --git a/sanic_ext/extensions/injection/registry.py b/sanic_ext/extensions/injection/registry.py
index 411dd13..a22642f 100644
--- a/sanic_ext/extensions/injection/registry.py
+++ b/sanic_ext/extensions/injection/registry.py
@@ -1,4 +1,9 @@
-from typing import Any, Callable, Dict, Optional, Tuple, Type
+from __future__ import annotations
+
+from typing import Any, Callable, Dict, Optional, Set, Tuple, Type
+
+from sanic.app import Sanic
+from sanic.config import Config
 
 from .constructor import Constructor
 
@@ -26,10 +31,14 @@ class InjectionRegistry:
         constructor = Constructor(constructor)
         self._registry[_type] = constructor
 
-    def finalize(self, allowed_types):
+    def finalize(
+        self, app: Sanic, constant_registry: ConstantRegistry, allowed_types
+    ):
         for constructor in self._registry.values():
             if isinstance(constructor, Constructor):
-                constructor.prepare(self, allowed_types)
+                constructor.prepare(
+                    app, self, constant_registry, allowed_types
+                )
 
     @property
     def length(self):
@@ -39,7 +48,11 @@ class InjectionRegistry:
 class SignatureRegistry:
     def __init__(self):
         self._registry: Dict[
-            str, Dict[str, Tuple[Type, Optional[Callable[..., Any]]]]
+            str,
+            Tuple[
+                Dict[str, Tuple[Type, Optional[Callable[..., Any]]]],
+                Dict[str, Any],
+            ],
         ] = {}
 
     def __getitem__(self, key):
@@ -54,6 +67,46 @@ class SignatureRegistry:
     def register(
         self,
         route_name: str,
-        injections: Dict[str, Tuple[Type, Optional[Callable[..., Any]]]],
+        dependencies: Dict[str, Tuple[Type, Optional[Callable[..., Any]]]],
+        constants: Optional[Dict[str, Any]] = None,
     ) -> None:
-        self._registry[route_name] = injections
+        self._registry[route_name] = (dependencies, constants or {})
+
+
+class ConstantRegistry:
+    def __init__(self, config: Config):
+        self._config = config
+        self._registry: Set[str] = set()
+
+    def __str__(self) -> str:
+        return str(self._registry)
+
+    def __iter__(self):
+        return iter(self._registry)
+
+    def __getitem__(self, key):
+        return self._registry[key]
+
+    def __contains__(self, other: Any):
+        return other in self._registry
+
+    def register(self, key: str, value: Any, overwrite: bool):
+        attribute = key.upper()
+        if attribute in self._config and not overwrite:
+            raise ValueError(
+                f"A value for {attribute} has already been assigned"
+            )
+        key = key.lower()
+        setattr(self._config, attribute, value)
+        return self._registry.add(key)
+
+    def get(self, key: str):
+        key = key.lower()
+        if key not in self._registry:
+            raise ValueError
+        attribute = key.upper()
+        return getattr(self._config, attribute)
+
+    @property
+    def length(self):
+        return len(self._registry)


Test patch:
diff --git a/tests/extensions/injection/test_constants.py b/tests/extensions/injection/test_constants.py
new file mode 100644
index 0000000..19c6126
--- /dev/null
+++ b/tests/extensions/injection/test_constants.py
@@ -0,0 +1,68 @@
+from dataclasses import dataclass
+
+import pytest
+from sanic import Sanic, json
+
+
+@dataclass
+class Foo:
+    bar: int
+
+
+def test_constant_in_registry(app: Sanic):
+    assert len(app.ext._constant_registry._registry) == 0
+    app.ext.add_constant("bar", 999)
+    assert len(app.ext._constant_registry._registry) == 1
+    assert "bar" in app.ext._constant_registry
+
+
+def test_constant_is_injected(app: Sanic):
+    app.ext.add_constant("bar", 999)
+
+    @app.get("/")
+    async def handler(_, bar: int):
+        return json({"bar": bar})
+
+    _, response = app.test_client.get("/")
+
+    assert response.json["bar"] == 999
+    assert app.config.BAR == 999
+
+
+def test_constant_is_injected_into_constructor(app: Sanic):
+    app.ext.add_constant("bar", 999)
+    app.ext.add_dependency(Foo)
+
+    @app.get("/")
+    async def handler(_, foo: Foo):
+        return json({"foo": foo.bar})
+
+    _, response = app.test_client.get("/")
+
+    assert response.json["foo"] == 999
+    assert app.config.BAR == 999
+
+
+def test_load_config(app: Sanic):
+    app.ext.load_constants()
+    assert len(app.ext._constant_registry._registry) == 0
+    app.ext.load_constants({**app.config}, overwrite=True)
+    assert len(app.ext._constant_registry._registry) == len(
+        [k for k in app.config.keys() if k.isupper()]
+    )
+    assert "request_buffer_size" in app.ext._constant_registry
+
+
+def test_load_custom(app: Sanic):
+    app.ext.load_constants({"foo": "bar"})
+    assert len(app.ext._constant_registry._registry) == 1
+    assert app.ext._constant_registry.get("foo") == "bar"
+
+
+def test_load_overwrite(app: Sanic):
+    app.ext.load_constants({"foo": "bar"})
+    with pytest.raises(
+        ValueError, match="A value for FOO has already been assigned"
+    ):
+        app.ext.load_constants({"foo": "bar"})
+    app.ext.load_constants({"foo": "bar"}, True)
diff --git a/tests/extensions/injection/test_injection_config.py b/tests/extensions/injection/test_injection_config.py
index 303ea17..526a6e9 100644
--- a/tests/extensions/injection/test_injection_config.py
+++ b/tests/extensions/injection/test_injection_config.py
@@ -35,6 +35,6 @@ def test_add_injection_uses_signal_config():
 
     app.signal = Mock(return_value=Mock())
     app.ext.config.INJECTION_SIGNAL = "random_string"
-    add_injection(app, Mock())
+    add_injection(app, Mock(), Mock())
 
     app.signal.assert_called_once_with("random_string")
diff --git a/tests/extensions/injection/test_injection_registry.py b/tests/extensions/injection/test_injection_registry.py
index 7511e80..3f067e0 100644
--- a/tests/extensions/injection/test_injection_registry.py
+++ b/tests/extensions/injection/test_injection_registry.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from unittest.mock import AsyncMock, call
+from unittest.mock import AsyncMock, Mock, call
 
 import pytest
 from sanic import Request
@@ -8,7 +8,10 @@ from sanic.exceptions import ServerError
 
 from sanic_ext.exceptions import InitError
 from sanic_ext.extensions.injection.constructor import Constructor, gather_args
-from sanic_ext.extensions.injection.registry import InjectionRegistry
+from sanic_ext.extensions.injection.registry import (
+    ConstantRegistry,
+    InjectionRegistry,
+)
 
 
 class Foo:
@@ -82,7 +85,7 @@ async def test_circular_refs():
         f"chain with {Chicken}."
     )
     with pytest.raises(InitError, match=message):
-        injections.finalize([])
+        injections.finalize(Mock(), ConstantRegistry({}), [])
 
 
 @pytest.mark.asyncio
@@ -95,15 +98,15 @@ async def test_finalize_allowed_types(raises, allowed):
         with pytest.raises(
             InitError, match=".Could not find the following dependencies."
         ):
-            injections.finalize(allowed)
+            injections.finalize(Mock(), ConstantRegistry({}), allowed)
     else:
-        injections.finalize(allowed)
+        injections.finalize(Mock(), ConstantRegistry({}), allowed)
 
 
 async def test_constructor():
     injections = InjectionRegistry()
     injections.register(Foo, Foo.create)
-    injections.finalize({int})
+    injections.finalize(Mock(), ConstantRegistry({}), {int})
     request = object()
     constructor = injections[Foo]
 
@@ -119,7 +122,7 @@ async def test_constructor_nested():
     injections = InjectionRegistry()
     injections.register(Foo, Foo.create)
     injections.register(Bar, Bar.create)
-    injections.finalize({int})
+    injections.finalize(Mock(), ConstantRegistry({}), {int})
     request = object()
     constructor_bar = injections[Bar]
 
@@ -135,7 +138,7 @@ async def test_constructor_nested():
 async def test_constructor_failure_kwargs():
     injections = InjectionRegistry()
     injections.register(Foo, Foo.create)
-    injections.finalize({int})
+    injections.finalize(Mock(), ConstantRegistry({}), {int})
     request = object()
     constructor = injections[Foo]
 
@@ -151,7 +154,7 @@ async def test_constructor_failure_nested():
     injections = InjectionRegistry()
     injections.register(Foo, Foo.create)
     injections.register(Bar, Bar.create)
-    injections.finalize({int})
+    injections.finalize(Mock(), ConstantRegistry({}), {int})
     request = object()
     constructor: Bar = injections[Bar]
 
@@ -166,7 +169,7 @@ async def test_constructor_failure_nested():
 async def test_constructor_with_inherited_request():
     injections = InjectionRegistry()
     injections.register(Baz, Baz.create)
-    injections.finalize([])
+    injections.finalize(Mock(), ConstantRegistry({}), [])
 
     request = object()
     constructor_baz = injections[Baz]