Repository: sanic-org/sanic-ext
Pull request #96 (2022-06-28 10:29:08)

Add API for preregistering Extensions
It can be somewhat difficult to add an extension if `app.extend` is not being explicitly called. This PR enables the API [as seen here](https://github.com/ahopkins/mayim) which makes it much easier for extensions to be added:

```python
app = Sanic(__name__)
Extend.register(
    MayimExtension(
        executors=[CityExecutor], dsn="postgres://..."
    )
)
```


Patch:
diff --git a/sanic_ext/__init__.py b/sanic_ext/__init__.py
index 3bccb27..71eee0e 100644
--- a/sanic_ext/__init__.py
+++ b/sanic_ext/__init__.py
@@ -2,6 +2,7 @@ from importlib.metadata import version
 
 from sanic_ext.bootstrap import Extend
 from sanic_ext.config import Config
+from sanic_ext.extensions.base import Extension
 from sanic_ext.extensions.http.cors import cors
 from sanic_ext.extensions.openapi import openapi
 from sanic_ext.extensions.templating.render import render
@@ -13,6 +14,7 @@ __version__ = version("sanic-ext")
 __all__ = [
     "Config",
     "Extend",
+    "Extension",
     "cors",
     "openapi",
     "render",
diff --git a/sanic_ext/bootstrap.py b/sanic_ext/bootstrap.py
index 253a830..0aa63cc 100644
--- a/sanic_ext/bootstrap.py
+++ b/sanic_ext/bootstrap.py
@@ -32,6 +32,8 @@ MIN_SUPPORT = (21, 3, 2)
 
 
 class Extend:
+    _pre_registry: List[Union[Type[Extension], Extension]] = []
+
     if TEMPLATING_ENABLED:
         environment: Environment
         templating: Templating
@@ -40,7 +42,7 @@ class Extend:
         self,
         app: Sanic,
         *,
-        extensions: Optional[List[Type[Extension]]] = None,
+        extensions: Optional[List[Union[Type[Extension], Extension]]] = None,
         built_in_extensions: bool = True,
         config: Optional[Union[Config, Dict[str, Any]]] = None,
         **kwargs,
@@ -79,6 +81,7 @@ class Extend:
         self.config = add_fallback_config(app, config, **kwargs)
 
         extensions = extensions or []
+        extensions.extend(Extend._pre_registry)
         if built_in_extensions:
             extensions.extend(
                 [
@@ -92,13 +95,13 @@ class Extend:
                 extensions.append(TemplatingExtension)
 
         started = set()
-        for extclass in extensions[::-1]:
-            if extclass in started:
+        for ext in extensions[::-1]:
+            if ext in started:
                 continue
-            extension = extclass(app, self.config)
+            extension = Extension.create(ext, app, self.config)
             extension._startup(self)
             self.extensions.append(extension)
-            started.add(extclass)
+            started.add(ext)
 
     def _display(self):
         init_logs = ["Sanic Extensions:"]
@@ -149,3 +152,11 @@ class Extend:
 
     def template(self, template_name: str, **kwargs):
         return self.templating.template(template_name, **kwargs)
+
+    @classmethod
+    def register(cls, extension: Union[Type[Extension], Extension]) -> None:
+        cls._pre_registry.append(extension)
+
+    @classmethod
+    def reset(cls) -> None:
+        cls._pre_registry.clear()
diff --git a/sanic_ext/extensions/base.py b/sanic_ext/extensions/base.py
index 27dcb27..fa03faf 100644
--- a/sanic_ext/extensions/base.py
+++ b/sanic_ext/extensions/base.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 from abc import ABC, abstractmethod
-from typing import Any, Dict, Type
+from typing import Any, Dict, Type, Union
 
 from sanic.app import Sanic
 from sanic.exceptions import SanicException
@@ -19,7 +19,10 @@ class NoDuplicateDict(dict):  # type: ignore
 
 class Extension(ABC):
     _name_registry: Dict[str, Type[Extension]] = NoDuplicateDict()
+    _started: bool
     name: str
+    app: Sanic
+    config: Config
 
     def __init_subclass__(cls):
         if not getattr(cls, "name", None) or not cls.name.isalpha():
@@ -33,11 +36,6 @@ class Extension(ABC):
 
         cls._name_registry[cls.name] = cls
 
-    def __init__(self, app: Sanic, config: Config) -> None:
-        self.app = app
-        self.config = config
-        self._started = False
-
     def _startup(self, bootstrap):
         if self._started:
             raise SanicException(
@@ -59,3 +57,22 @@ class Extension(ABC):
         if not label:
             return ""
         return f"[{label}]"
+
+    @classmethod
+    def create(
+        cls,
+        extension: Union[Type[Extension], Extension],
+        app: Sanic,
+        config: Config,
+    ) -> Extension:
+        instance = (
+            extension if isinstance(extension, Extension) else extension()
+        )
+        instance.app = app
+        instance.config = config
+        instance._started = False
+        return instance
+
+    @classmethod
+    def reset(cls) -> None:
+        cls._name_registry.clear()
diff --git a/sanic_ext/extensions/http/extension.py b/sanic_ext/extensions/http/extension.py
index b7b13b4..18fb2ca 100644
--- a/sanic_ext/extensions/http/extension.py
+++ b/sanic_ext/extensions/http/extension.py
@@ -7,15 +7,13 @@ from .methods import add_auto_handlers, add_http_methods
 class HTTPExtension(Extension):
     name = "http"
 
-    def __init__(self, *args) -> None:
-        super().__init__(*args)
+    def startup(self, _) -> None:
         self.all_methods: bool = self.config.HTTP_ALL_METHODS
         self.auto_head: bool = self.config.HTTP_AUTO_HEAD
         self.auto_options: bool = self.config.HTTP_AUTO_OPTIONS
         self.auto_trace: bool = self.config.HTTP_AUTO_TRACE
         self.cors: bool = self.config.CORS
 
-    def startup(self, _) -> None:
         if self.all_methods:
             add_http_methods(self.app, ["CONNECT", "TRACE"])
 


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
index 5dcda6d..f4a1f0e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,7 +3,7 @@ import re
 import pytest
 from sanic import Sanic
 
-from sanic_ext import Extend
+from sanic_ext import Extend, Extension
 from sanic_ext.extensions.http.extension import HTTPExtension
 from sanic_ext.extensions.injection.extension import InjectionExtension
 from sanic_ext.extensions.openapi.builders import (
@@ -20,6 +20,8 @@ def reset_globals():
     yield
     SpecificationBuilder.reset()
     OperationStore.reset()
+    Extend.reset()
+    Extension.reset()
 
 
 @pytest.fixture(autouse=True)
diff --git a/tests/extensions/__init__.py b/tests/extensions/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/extensions/http/__init__.py b/tests/extensions/http/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/extensions/injection/__init__.py b/tests/extensions/injection/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/extensions/openapi/__init__.py b/tests/extensions/openapi/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/extensions/openapi/test_body.py b/tests/extensions/openapi/test_body.py
index 8ccec02..a57d825 100644
--- a/tests/extensions/openapi/test_body.py
+++ b/tests/extensions/openapi/test_body.py
@@ -1,13 +1,12 @@
 from dataclasses import dataclass
-from typing import Callable
 
-import pytest
 from sanic import Request, Sanic
 from sanic.response import text
 
 from sanic_ext import openapi
 from sanic_ext.extensions.openapi.definitions import RequestBody
-from utils import get_spec
+
+from .utils import get_spec
 
 
 @dataclass
diff --git a/tests/extensions/openapi/test_decorators.py b/tests/extensions/openapi/test_decorators.py
index 0156a39..7ab81f4 100644
--- a/tests/extensions/openapi/test_decorators.py
+++ b/tests/extensions/openapi/test_decorators.py
@@ -7,7 +7,8 @@ import pytest
 from sanic.exceptions import SanicException
 
 from sanic_ext import openapi
-from utils import get_path, get_spec
+
+from .utils import get_path, get_spec
 
 
 class Choice(Enum):
diff --git a/tests/extensions/openapi/test_deprecated.py b/tests/extensions/openapi/test_deprecated.py
index 96901b6..469f249 100644
--- a/tests/extensions/openapi/test_deprecated.py
+++ b/tests/extensions/openapi/test_deprecated.py
@@ -2,7 +2,8 @@ from sanic import Request, Sanic
 from sanic.response import text
 
 from sanic_ext import openapi
-from utils import get_spec
+
+from .utils import get_spec
 
 
 def test_deprecated(app: Sanic):
diff --git a/tests/extensions/openapi/test_exclude.py b/tests/extensions/openapi/test_exclude.py
index 85a4b38..d964533 100644
--- a/tests/extensions/openapi/test_exclude.py
+++ b/tests/extensions/openapi/test_exclude.py
@@ -1,7 +1,8 @@
 from sanic import Blueprint, Request, Sanic, text
 
 from sanic_ext.extensions.openapi import openapi
-from utils import get_spec
+
+from .utils import get_spec
 
 
 def test_exclude_decorator(app: Sanic):
@@ -47,5 +48,5 @@ def test_exclude_bp(app: Sanic):
     paths = spec["paths"]
     assert len(paths) == 1
     assert "/op2" in paths
-    assert not "/op1" in paths
+    assert "/op1" not in paths
     assert paths["/op2"]["get"]["summary"] == "handler 2"
diff --git a/tests/extensions/openapi/test_external_docs.py b/tests/extensions/openapi/test_external_docs.py
index 0e42a4f..6b22467 100644
--- a/tests/extensions/openapi/test_external_docs.py
+++ b/tests/extensions/openapi/test_external_docs.py
@@ -3,7 +3,8 @@ from sanic.response import text
 
 from sanic_ext import openapi
 from sanic_ext.extensions.openapi.definitions import ExternalDocumentation
-from utils import get_spec
+
+from .utils import get_spec
 
 
 def test_external_docs(app: Sanic):
diff --git a/tests/extensions/openapi/test_parameter.py b/tests/extensions/openapi/test_parameter.py
index 7dc034a..68c61a9 100644
--- a/tests/extensions/openapi/test_parameter.py
+++ b/tests/extensions/openapi/test_parameter.py
@@ -2,7 +2,8 @@ from sanic import Request, Sanic, text
 
 from sanic_ext.extensions.openapi import openapi
 from sanic_ext.extensions.openapi.definitions import Parameter
-from utils import get_spec
+
+from .utils import get_spec
 
 
 def test_parameter(app: Sanic):
@@ -84,6 +85,6 @@ def test_parameter(app: Sanic):
         parameter = spec["paths"][f"/test{i}/{{val1}}"]["get"]["parameters"][0]
         assert parameter["name"] == NAME
         assert parameter["in"] == LOCATION
-        assert parameter["required"] == True
+        assert parameter["required"] is True
         assert parameter["schema"]["type"] == TYPE
         assert parameter["description"] == DESCRIPTION
diff --git a/tests/extensions/openapi/test_summary.py b/tests/extensions/openapi/test_summary.py
index d7788ab..14b3cc3 100644
--- a/tests/extensions/openapi/test_summary.py
+++ b/tests/extensions/openapi/test_summary.py
@@ -2,7 +2,8 @@ from sanic import Request, Sanic
 from sanic.response import text
 
 from sanic_ext import openapi
-from utils import get_spec
+
+from .utils import get_spec
 
 
 def test_summary(app: Sanic):
diff --git a/tests/extensions/openapi/test_tag.py b/tests/extensions/openapi/test_tag.py
index b8ff285..30cbab4 100644
--- a/tests/extensions/openapi/test_tag.py
+++ b/tests/extensions/openapi/test_tag.py
@@ -1,8 +1,8 @@
 from sanic import Request, Sanic, text
 
 from sanic_ext.extensions.openapi import openapi
-from sanic_ext.extensions.openapi.definitions import Parameter, Tag
-from utils import get_spec
+
+from .utils import get_spec
 
 
 def test_tag(app: Sanic):
diff --git a/tests/extensions/templating/templates/__init__.py b/tests/extensions/templating/templates/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/extensions/test_startup.py b/tests/extensions/test_startup.py
index be7e695..1972bfb 100644
--- a/tests/extensions/test_startup.py
+++ b/tests/extensions/test_startup.py
@@ -1,8 +1,10 @@
+from typing import Type, Union
 from unittest.mock import Mock
 
+import pytest
 from sanic import Sanic
 
-from sanic_ext.extensions.base import Extension
+from sanic_ext import Extend, Extension
 
 
 def test_multiple_extensions(bare_app: Sanic):
@@ -29,3 +31,32 @@ def test_multiple_extensions(bare_app: Sanic):
     bare_app.extend(extensions=[FooExtension, BarExtension, BarExtension])
 
     assert mock.call_count == 2
+
+
+@pytest.mark.parametrize("instance", (True, False))
+def test_multiple_extensions_pre_register(bare_app: Sanic, instance: bool):
+    mock = Mock()
+
+    class FooExtension(Extension):
+        name = "foo"
+
+        def startup(self, _) -> None:
+            mock()
+
+    class BarExtension(Extension):
+        name = "bar"
+
+        def startup(self, _) -> None:
+            mock()
+
+    foo: Union[Type[Extension], Extension] = FooExtension
+    bar: Union[Type[Extension], Extension] = BarExtension
+    if instance:
+        foo = foo()  # type: ignore
+        bar = bar()  # type: ignore
+
+    Extend.register(foo)
+    Extend.register(bar)
+    bare_app.extend()
+
+    assert mock.call_count == 2