diff --git a/docs/en/docs/pluggables.md b/docs/en/docs/pluggables.md index 0b906c1d..949c469e 100644 --- a/docs/en/docs/pluggables.md +++ b/docs/en/docs/pluggables.md @@ -105,10 +105,18 @@ This way you don't need to use the [Pluggable](#pluggable) object in any way and simply just use the [Extension](#extension) class or even your own since you **are in control** of the extension. -```python hl_lines="25 42-43" +There are two variants how to do it: + +```python title="With extension class or Pluggable" {!> ../../../docs_src/pluggables/manual.py !} ``` +```python hl_lines="25 42-43" title="Self registering" +{!> ../../../docs_src/pluggables/manual_self_registering.py !} +``` + +You can use for the late registration the methods `add_extension` or `add_pluggable`. `add_pluggable` is an alias. + ### Standalone object But, what if I don't want to use the [Extension](#extension) object for my pluggable? Is this diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 7508dc17..3279c29b 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -11,6 +11,11 @@ hide: - Allow passing HTTP/WebSocket handlers directly to routes. They are automatically wrapped in Gateways- +### Changed + +- Pluggables can now receive plain Extensions and Extension classes. +- Alias add_pluggable with add_extension. + ## 3.4.4 ### Added diff --git a/docs_src/pluggables/manual.py b/docs_src/pluggables/manual.py index c22de6ac..27af4af9 100644 --- a/docs_src/pluggables/manual.py +++ b/docs_src/pluggables/manual.py @@ -20,10 +20,6 @@ def extend(self, **kwargs: "DictAny") -> None: # Do something here like print a log or whatever you need logger.success("Started the extension manually") - # Add the extension to the pluggables of Esmerald - # And make it accessible - self.app.add_pluggable("my-extension", self) - @get("/home") async def home(request: Request) -> JSONResponse: @@ -38,6 +34,6 @@ async def home(request: Request) -> JSONResponse: app = Esmerald(routes=[Gateway(handler=home)]) - -extension = MyExtension(app=app) -extension.extend() +app.add_pluggable("my-extension", MyExtension) +# or +# app.add_extension("my-extension", MyExtension) diff --git a/docs_src/pluggables/manual_self_registering.py b/docs_src/pluggables/manual_self_registering.py new file mode 100644 index 00000000..c22de6ac --- /dev/null +++ b/docs_src/pluggables/manual_self_registering.py @@ -0,0 +1,43 @@ +from typing import Optional + +from loguru import logger + +from esmerald import Esmerald, Extension, Gateway, JSONResponse, Request, get +from esmerald.types import DictAny + + +class MyExtension(Extension): + def __init__(self, app: Optional["Esmerald"] = None, **kwargs: "DictAny"): + super().__init__(app, **kwargs) + self.app = app + self.kwargs = kwargs + + def extend(self, **kwargs: "DictAny") -> None: + """ + Function that should always be implemented when extending + the Extension class or a `NotImplementedError` is raised. + """ + # Do something here like print a log or whatever you need + logger.success("Started the extension manually") + + # Add the extension to the pluggables of Esmerald + # And make it accessible + self.app.add_pluggable("my-extension", self) + + +@get("/home") +async def home(request: Request) -> JSONResponse: + """ + Returns a list of pluggables of the system. + + "pluggables": ["my-extension"] + """ + pluggables = list(request.app.pluggables) + + return JSONResponse({"pluggables": pluggables}) + + +app = Esmerald(routes=[Gateway(handler=home)]) + +extension = MyExtension(app=app) +extension.extend() diff --git a/docs_src/pluggables/standalone.py b/docs_src/pluggables/standalone.py index 5cdcb499..f59796b8 100644 --- a/docs_src/pluggables/standalone.py +++ b/docs_src/pluggables/standalone.py @@ -8,7 +8,6 @@ class Standalone: def __init__(self, app: Optional["Esmerald"] = None, **kwargs: "DictAny"): - super().__init__(app, **kwargs) self.app = app self.kwargs = kwargs diff --git a/esmerald/applications.py b/esmerald/applications.py index 5e259ee8..fa2b99e4 100644 --- a/esmerald/applications.py +++ b/esmerald/applications.py @@ -46,7 +46,7 @@ from esmerald.openapi.schemas.v3_1_0 import Contact, License, SecurityScheme from esmerald.openapi.schemas.v3_1_0.open_api import OpenAPI from esmerald.permissions.types import Permission -from esmerald.pluggables import Extension, Pluggable +from esmerald.pluggables import Extension, ExtensionDict, Pluggable from esmerald.protocols.template import TemplateEngineProtocol from esmerald.routing import gateways from esmerald.routing.apis import base @@ -1342,7 +1342,7 @@ async def home() -> Dict[str, str]: ), ] = None, pluggables: Annotated[ - Optional[Dict[str, Pluggable]], + Optional[Dict[str, Union[Extension, Pluggable, type[Extension]]]], Doc( """ A `list` of global pluggables from objects inheriting from @@ -1528,7 +1528,6 @@ def extend(self, config: PluggableConfig) -> None: self.redirect_slashes = self.load_settings_value( "redirect_slashes", redirect_slashes, is_boolean=True ) - self.pluggables = self.load_settings_value("pluggables", pluggables) # OpenAPI Related self.root_path_in_servers = self.load_settings_value( @@ -1578,9 +1577,12 @@ def extend(self, config: PluggableConfig) -> None: self.get_default_exception_handlers() self.user_middleware = self.build_user_middleware_stack() self.middleware_stack = self.build_middleware_stack() - self.pluggable_stack = self.build_pluggable_stack() self.template_engine = self.get_template_engine(self.template_config) + # load pluggables nearly last so everythings is initialized + self.pluggables = ExtensionDict( + self.load_settings_value("pluggables", pluggables), app=cast(Esmerald, self) + ) self._configure() def _register_application_encoders(self) -> None: @@ -1826,6 +1828,7 @@ def add_apiview( ```python from esmerald import Esmerald, APIView, Gateway, get + class View(APIView): path = "/" @@ -1833,6 +1836,7 @@ class View(APIView): async def hello(self) -> str: return "Hello, World!" + gateway = Gateway(handler=View) app = Esmerald() @@ -1955,10 +1959,12 @@ def add_route( ```python from esmerald import Esmerald, get + @get(status_code=status_code) async def hello() -> str: return "Hello, World!" + app = Esmerald() app.add_route(path="/hello", handler=hello) ``` @@ -2067,6 +2073,7 @@ def add_websocket_route( ```python from esmerald import Esmerald, websocket + @websocket() async def websocket_route(socket: WebSocket) -> None: await socket.accept() @@ -2076,6 +2083,7 @@ async def websocket_route(socket: WebSocket) -> None: await socket.send_json({"data": "esmerald"}) await socket.close() + app = Esmerald() app.add_websocket_route(path="/ws", handler=websocket_route) ``` @@ -2113,10 +2121,12 @@ def add_include( ```python from esmerald import get, Include + @get(status_code=status_code) async def hello(self) -> str: return "Hello, World!" + include = Include("/child", routes=[Gateway(handler=hello)]) app = Esmerald() @@ -2212,10 +2222,12 @@ def add_router( from esmerald import get from esmerald.routing.router import Router + @get(status_code=status_code) async def hello(self) -> str: return "Hello, World!" + router = Router(path="/aditional", routes=[Gateway(handler=hello)]) app = Esmerald() @@ -2415,37 +2427,9 @@ def build_middleware_stack(self) -> "ASGIApp": app = cls(app=app, *args, **kwargs) # noqa return app - def build_pluggable_stack(self) -> Optional["Esmerald"]: - """ - Validates the pluggable types passed and builds the stack - and triggers the plug - """ - if not self.pluggables: - return None - - pluggables = {} - - for name, extension in self.pluggables.items(): - if not isinstance(name, str): - raise ImproperlyConfigured("Pluggable names should be in string format.") - elif isinstance(extension, Pluggable): - pluggables[name] = extension - continue - elif not is_class_and_subclass(extension, Extension): - raise ImproperlyConfigured( - "An extension must subclass from esmerald.pluggables.Extension and added to " - "a Pluggable object" - ) - - app: "ASGIApp" = self - for name, pluggable in pluggables.items(): - for cls, options in [pluggable]: - ext: "Extension" = cls(app=app, **options) - ext.extend(**options) - self.pluggables[name] = cls - return cast("Esmerald", app) - - def add_pluggable(self, name: str, extension: "Extension") -> None: + def add_extension( + self, name: str, extension: Union[Extension, Pluggable, type[Extension]] + ) -> None: """ Adds a [Pluggable](https://esmerald.dev/pluggables/) directly to the active application router. @@ -2457,27 +2441,37 @@ def add_pluggable(self, name: str, extension: "Extension") -> None: from pydantic import BaseModel + class Config(BaseModel): name: Optional[str] + class CustomExtension(Extension): def __init__(self, app: Optional["Esmerald"] = None, **kwargs: DictAny): super().__init__(app, **kwargs) self.app = app def extend(self, config) -> None: - logger.success(f"Started standalone plugging with the name: {config.name}") + logger.success( + f"Started standalone plugging with the name: {config.name}" + ) + + # you can also autoadd the extension like this + # self.app.add_pluggable(config.name, self) - self.app.add_pluggable("manual", self) app = Esmerald(routes=[]) config = Config(name="manual") - extension = CustomExtension(app=app) - extension.extend(config=config) + pluggable = Pluggable(CustomExtension, config=config) + app.add_extension("manual", pluggable) + # or + # app.add_pluggable("manual", pluggable) ``` """ self.pluggables[name] = extension + add_pluggable = add_extension + @property def settings(self) -> Type["EsmeraldAPISettings"]: """ @@ -3470,7 +3464,7 @@ class ChildEsmerald(Esmerald): ```python from esmerald import Esmerald, ChildEsmerald, Include - app = Esmerald(routes=[Include('/child', app=ChildEsmerald(...))]) + app = Esmerald(routes=[Include("/child", app=ChildEsmerald(...))]) ``` """ diff --git a/esmerald/pluggables/__init__.py b/esmerald/pluggables/__init__.py index 0b50fbef..b2097466 100644 --- a/esmerald/pluggables/__init__.py +++ b/esmerald/pluggables/__init__.py @@ -1,3 +1,3 @@ -from .base import Extension, Pluggable +from .base import Extension, ExtensionDict, Pluggable -__all__ = ["Pluggable", "Extension"] +__all__ = ["Pluggable", "Extension", "ExtensionDict"] diff --git a/esmerald/pluggables/base.py b/esmerald/pluggables/base.py index 18a4e6d7..7397e00b 100644 --- a/esmerald/pluggables/base.py +++ b/esmerald/pluggables/base.py @@ -3,7 +3,9 @@ from typing_extensions import Annotated, Doc +from esmerald.exceptions import ImproperlyConfigured from esmerald.protocols.extension import ExtensionProtocol +from esmerald.utils.helpers import is_class_and_subclass if TYPE_CHECKING: # pragma: no cover from esmerald.applications import Esmerald @@ -35,7 +37,10 @@ class PluggableConfig(BaseModel): class MyExtension(Extension): def __init__( - self, app: Optional["Esmerald"] = None, config: PluggableConfig = None, **kwargs: "DictAny" + self, + app: Optional["Esmerald"] = None, + config: PluggableConfig = None, + **kwargs: "DictAny", ): super().__init__(app, **kwargs) self.app = app @@ -52,7 +57,7 @@ def extend(self, config: PluggableConfig) -> None: ``` """ - def __init__(self, cls: "Extension", **options: Any): + def __init__(self, cls: type["Extension"], **options: Any): self.cls = cls self.options = options @@ -67,35 +72,7 @@ def __repr__(self) -> str: # pragma: no cover return f"{name}({args})" -class BaseExtension(ABC, ExtensionProtocol): # pragma: no cover - """ - The base for any Esmerald plugglable. - """ - - def __init__( - self, - app: Annotated[ - Optional["Esmerald"], - Doc( - """ - An `Esmerald` application instance or subclasses of Esmerald. - """ - ), - ] = None, - **kwargs: Annotated[ - Any, - Doc("""Any additional kwargs needed."""), - ], - ): - super().__init__(app, **kwargs) - self.app = app - - @abstractmethod - def extend(self, **kwargs: "Any") -> None: - raise NotImplementedError("plug must be implemented by the subclasses.") - - -class Extension(BaseExtension): +class Extension(ABC, ExtensionProtocol): """ `Extension` object is the one being used to add the logic that will originate the `pluggable` in the application. @@ -127,3 +104,55 @@ def extend(self, **kwargs: "DictAny") -> None: # Do something here ``` """ + + def __init__( + self, + app: Annotated[ + Optional["Esmerald"], + Doc( + """ + An `Esmerald` application instance or subclasses of Esmerald. + """ + ), + ] = None, + **kwargs: Annotated[ + Any, + Doc("""Any additional kwargs needed."""), + ], + ): + super().__init__(app, **kwargs) + self.app = app + + @abstractmethod + def extend(self, **kwargs: "Any") -> None: + raise NotImplementedError("plug must be implemented by the subclasses.") + + +BaseExtension = Extension + + +class ExtensionDict(dict[str, Extension]): + def __init__(self, data: Any = None, *, app: "Esmerald"): + super().__init__(data) + self.app = app + for k, v in self.items(): + self[k] = v + + def __setitem__(self, name: Any, value: Any) -> None: + if not isinstance(name, str): + raise ImproperlyConfigured("Pluggable names should be in string format.") + elif isinstance(value, Pluggable): + cls, options = value + value = cls(app=self.app, **options) + value.extend(**options) + elif isinstance(value, ExtensionProtocol): + pass + elif is_class_and_subclass(value, Extension): + value = value(app=self.app) + value.extend() + else: + raise ImproperlyConfigured( + "An extension must subclass from esmerald.pluggables.Extension and added to " + "a Pluggable object" + ) + super().__setitem__(name, value) diff --git a/esmerald/testclient.py b/esmerald/testclient.py index 40f8c39e..cfe92e4c 100644 --- a/esmerald/testclient.py +++ b/esmerald/testclient.py @@ -46,7 +46,7 @@ ) from esmerald.interceptors.types import Interceptor from esmerald.permissions.types import Permission - from esmerald.pluggables import Pluggable + from esmerald.pluggables import Extension, Pluggable from esmerald.routing.gateways import WebhookGateway from esmerald.types import ( APIGateHandler, @@ -110,7 +110,7 @@ def create_client( backend: "Literal['asyncio', 'trio']" = "asyncio", backend_options: Optional[Dict[str, Any]] = None, interceptors: Optional[List["Interceptor"]] = None, - pluggables: Optional[Dict[str, "Pluggable"]] = None, + pluggables: Optional[Dict[str, Union["Extension", "Pluggable", type["Extension"]]]] = None, permissions: Optional[List["Permission"]] = None, dependencies: Optional["Dependencies"] = None, middleware: Optional[List["Middleware"]] = None, diff --git a/tests/pluggables/test_pluggables.py b/tests/pluggables/test_pluggables.py index dbd45f3c..612169ec 100644 --- a/tests/pluggables/test_pluggables.py +++ b/tests/pluggables/test_pluggables.py @@ -18,7 +18,7 @@ def __init__(self, app: "Esmerald"): self.app = app -def test_raises_improperly_configured_for_subsclass(test_client_factory): +def test_raises_improperly_configured_for_subclass(test_client_factory): with pytest.raises(ImproperlyConfigured) as raised: Esmerald(routes=[], pluggables={"test": MyNewPluggable}) @@ -122,6 +122,26 @@ def __init__(self, app: Optional["Esmerald"] = None, **kwargs: DictAny): super().__init__(app, **kwargs) self.app = app + def extend(self, config) -> None: + logger.success(f"Started plugging with the name: {config.name}") + + self.app.add_pluggable("manual", self) + + app = Esmerald(routes=[]) + config = Config(name="manual") + extension = CustomExtension(app=app) + extension.extend(config=config) + + assert "manual" in app.pluggables + assert isinstance(app.pluggables["manual"], Extension) + + +def test_add_standalone_extension(test_client_factory): + class CustomExtension: + def __init__(self, app: Optional["Esmerald"] = None, **kwargs: DictAny): + self.app = app + self.kwargs = kwargs + def extend(self, config) -> None: logger.success(f"Started standalone plugging with the name: {config.name}") @@ -132,5 +152,22 @@ def extend(self, config) -> None: extension = CustomExtension(app=app) extension.extend(config=config) + assert "manual" in app.pluggables + assert not isinstance(app.pluggables["manual"], Extension) + + +def test_add_pluggable(test_client_factory): + class CustomExtension(Extension): + def __init__(self, app: Optional["Esmerald"] = None, **kwargs: DictAny): + super().__init__(app, **kwargs) + self.app = app + + def extend(self, config) -> None: + logger.success(f"Started standalone plugging with the name: {config.name}") + + app = Esmerald(routes=[]) + config = Config(name="manual") + app.add_pluggable("manual", Pluggable(CustomExtension, config=config)) + assert "manual" in app.pluggables assert isinstance(app.pluggables["manual"], Extension)