diff --git a/README.md b/README.md index 8b3f574b..61793765 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ use it. * **Exception Handlers**: Apply exception handlers on any desired level. * **Permissions**: Apply specific rules and permissions on each API. * **Interceptors**: Intercept requests and add logic before reaching the endpoint. -* **Pluggables**: Create plugins for Esmerald and hook them into any application and/or +* **Extensions**: Create plugins for Esmerald and hook them into any application and/or distribute them. * **DAO and AsyncDAO**: Avoid database calls directly from the APIs. Use business objects instead. * **ORM Support**: Native support for [Saffier][saffier_orm] and [Edgy][edgy_orm]. diff --git a/docs/en/docs/pluggables.md b/docs/en/docs/extensions.md similarity index 81% rename from docs/en/docs/pluggables.md rename to docs/en/docs/extensions.md index 0b906c1d..56d59464 100644 --- a/docs/en/docs/pluggables.md +++ b/docs/en/docs/extensions.md @@ -1,6 +1,6 @@ -# Pluggables +# Extensions -What are pluggables in an Esmerald context? A separate and individual piece of software that +What are extensions in an Esmerald context? A separate and individual piece of software that can be hooked into **any** Esmerald application and perform specific actions individually without breaking the ecosystem. @@ -20,17 +20,15 @@ wouldn't make too much sense right? Also, how could we create this pattern, like Flask, to have an `init_app` and allow the application to do the rest for you? Well, Esmerald now does that via its internal protocols and interfaces. -In Esmerald world, this is called [**pluggable**](#pluggable). - !!! Note - Pluggables only exist on an [application level](./application/levels.md#application-levels). + Extensions only exist on an [application level](./application/levels.md#application-levels). ## Pluggable -This object is one of a kind and does **a lot of magic** for you when creating a pluggble for +This object is one of a kind and does **a lot of magic** for you when creating an extension for your application or even for distribution. -A **pluggable** is an object that receives an [Extension](#extension) class with parameters +A **Pluggable** is an object that receives an [Extension](#extension) class with parameters and hooks them into your Esmerald application and executes the [extend](#extend) method when starting the system. @@ -42,10 +40,6 @@ It is this simple but is it the only way to add a pluggable into the system? **S More details about this in [hooking a pluggable into the application](#hooking-pluggables). -!!! Danger - If another object but the [Extension](#extension) is provided to the Pluggable, it will - raise an `ImproperlyConfigured`. Pluggables are **always expecting an Extension to be provided**. - ## Extension This is the main class that should be extended when creating a pluggable for Esmerald. @@ -72,14 +66,14 @@ It is the entry-point for your extension. The extend by default expects `kwargs` to be provided but you can pass your own default parameters as well as there are many ways of creating and [hooking a pluggable] -## Hooking pluggables +## Hooking pluggables and extensions As mentioned before, there are different ways of hooking a pluggable into your Esmerald application. ### The automated and default way When using the default and automated way, Esmerald expects the pluggable to be passed into a dict -`pluggables` upon instantiation of an Esmerald application with `key-pair` value entries and where +`extensions` upon instantiation of an Esmerald application with `key-pair` value entries and where the `key` is the name for your pluggable and the `value` is an instance [Pluggable](#pluggable) holding your [Extension](#extension) object. @@ -93,7 +87,15 @@ parameter if needed {!> ../../../docs_src/pluggables/pluggable.py !} ``` -You can access all the pluggables of your application via `app.pluggables` at any given time. +You can access all the extensions of your application via `app.extensions` at any given time. + +#### Reordering + +Sometimes there are dependencies between extensions. One requires another. +You can reorder the extending order by using the method `ensure_extension(name)` of `app.extensions`. +It will fail if the extension doesn't exist, so only call it in extend. + +{!> ../../../docs_src/pluggables/reorder.py !} ### The manual and independent way @@ -105,33 +107,40 @@ 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`. +It will automatically initialize and call extend for you when passing a class or **Pluggable**, +**but not when passing an instance**. + ### Standalone object But, what if I don't want to use the [Extension](#extension) object for my pluggable? Is this possible? +´ +Yes, it must only implement the ExtensionProtocol. -Short answer, yes, but this comes with limitations: - -* You **cannot** hook the class within a [Pluggable](#pluggable) and use the automated way. -* You **will always need** to start it manually. - -```python hl_lines="9 25 42-43" +```python hl_lines="9 25" {!> ../../../docs_src/pluggables/standalone.py !} ``` ## Important notes -As you can see, **pluggables** in Esmerald can be a powerful tool that isolates common +As you can see, **extensions** in Esmerald can be a powerful tool that isolates common functionality from the main Esmerald application and can be used to leverage the creation of plugins to be used across your applications and/or to create opensource packages for any need. ## ChildEsmerald and pluggables -A [Pluggable](#pluggable) **is not the same** as a [ChildEsmerald](./routing/router.md#child-esmerald-application). +An [Extension](#extension) **is not the same** as a [ChildEsmerald](./routing/router.md#child-esmerald-application). These are two completely independent pieces of functionality with completely different purposes, be careful when considering one and the other. diff --git a/docs/en/docs/extras/path-params.md b/docs/en/docs/extras/path-params.md index ba661c68..6a885eaa 100644 --- a/docs/en/docs/extras/path-params.md +++ b/docs/en/docs/extras/path-params.md @@ -109,4 +109,4 @@ And since `something` is not declared in the Enum type, you will get an error si ``` -[lilya]: https//lilya.dev +[lilya]: https://lilya.dev diff --git a/docs/en/docs/index.md b/docs/en/docs/index.md index 2c5a8d34..62d61fc0 100644 --- a/docs/en/docs/index.md +++ b/docs/en/docs/index.md @@ -156,7 +156,7 @@ example. * **Pluggables**: Create plugins for Esmerald and hook them into any application and/or distribute them. * **DAO and AsyncDAO**: Avoid database calls directly from the APIs. Use business objects instead. -* **ORM Support**: Native support for [Saffier][saffier_orm] and [Edgy][_orm]. +* **ORM Support**: Native support for [Saffier][saffier_orm] and [Edgy][edgy_orm]. * **ODM Support**: Native support for [Mongoz][mongoz_odm]. * **APIView**: Class Based endpoints for your beloved OOP design. * **JSON serialization/deserialization**: Both UJSON and ORJSON support. diff --git a/docs/en/docs/references/esmerald.md b/docs/en/docs/references/esmerald.md index fce570bd..21ecd823 100644 --- a/docs/en/docs/references/esmerald.md +++ b/docs/en/docs/references/esmerald.md @@ -21,7 +21,7 @@ from esmerald import Esmerald - add_include - add_child_esmerald - add_router - - add_pluggable + - add_extension - register_encoder ::: esmerald.ChildEsmerald diff --git a/docs/en/docs/references/extensions.md b/docs/en/docs/references/extensions.md new file mode 100644 index 00000000..3fa8a10d --- /dev/null +++ b/docs/en/docs/references/extensions.md @@ -0,0 +1,6 @@ +# **`Extension`** class + +This is the reference for the main object `Extension`. It is optionally wrapped by +a [Pluggable](./pluggables.md). + +::: esmerald.Extension diff --git a/docs/en/docs/references/pluggables.md b/docs/en/docs/references/pluggables.md index 84f96a26..49ad005e 100644 --- a/docs/en/docs/references/pluggables.md +++ b/docs/en/docs/references/pluggables.md @@ -1,8 +1,6 @@ # **`Pluggable`** class -This is the reference for the main object `Pluggable` that contains all the parameters, -attributes and functions. +This is the reference for the wrapper object `Pluggable` that contains all the parameters, +attributes and functions. It wraps an [Extension](./extensions.md). ::: esmerald.Pluggable - -::: esmerald.Extension diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 9111829f..0e6d0c12 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -5,6 +5,20 @@ hide: # Release Notes +## Unreleased + +### Added + +- Allow passing HTTP/WebSocket handlers directly to routes. They are automatically wrapped in Gateways- + +### Changed + +- Pluggables can now receive plain Extensions and Extension classes. +- Rename of Pluggables to Extensions: + - Breaking: The `pluggables` attribute and parameter are now renamed to `extensions`. The old name is still available but deprecated. + - Breaking: The `add_pluggable` method is now renamed to `add_extension`. The old name is still available but deprecated. + - The documentation will refer now to extensions with `Pluggable` as a setup wrapper. + ## 3.4.4 ### Added @@ -1023,7 +1037,7 @@ across main application and children. ### Added -- Esmerald [Pluggables](./pluggables.md) [#60](https://github.com/dymmond/esmerald/pull/60). +- Esmerald Pluggables [#60](https://github.com/dymmond/esmerald/pull/60). This is the feature for the esmerald ecosystem that allows you to create plugins and extensions for any application as well as distribute them as installable packages. diff --git a/docs/en/docs/routing/routes.md b/docs/en/docs/routing/routes.md index 5638e0b2..76e795a5 100644 --- a/docs/en/docs/routing/routes.md +++ b/docs/en/docs/routing/routes.md @@ -16,6 +16,8 @@ different APIs and systems, so Esmerald created its own. A Gateway is an extension of the Route, really, but adds its own logic and handling capabilities, as well as its own validations, without compromising the core. +It is automatically added when just passing an HTTP/Websocket handler to routes. + ### Gateway and application In simple terms, a Gateway is not a direct route but instead is a "wrapper" of a [handler](./handlers.md) diff --git a/docs/en/mkdocs.yml b/docs/en/mkdocs.yml index ac2bffd5..d5dbbcc1 100644 --- a/docs/en/mkdocs.yml +++ b/docs/en/mkdocs.yml @@ -116,7 +116,7 @@ nav: - dependencies.md - exceptions.md - exception-handlers.md - - pluggables.md + - extensions.md - password-hashers.md - requests.md - context.md @@ -199,6 +199,7 @@ nav: - references/permissions.md - references/middleware/baseauth.md - references/middleware/middlewares.md + - references/extensions.md - references/pluggables.md - references/exceptions.md - references/request.md @@ -238,5 +239,7 @@ extra: alternate: - link: / name: en - English + - link: /ru/ + name: ru - русский язык hooks: - ../../scripts/hooks.py diff --git a/docs_src/pluggables/child_esmerald.py b/docs_src/pluggables/child_esmerald.py index f3ff968d..191f7553 100644 --- a/docs_src/pluggables/child_esmerald.py +++ b/docs_src/pluggables/child_esmerald.py @@ -30,4 +30,4 @@ def extend(self, **kwargs: "DictAny") -> None: logger.success("Added the ChildEsmerald via pluggable.") -app = Esmerald(routes=[], pluggables={"child-esmerald": Pluggable(ChildEsmeraldPluggable)}) +app = Esmerald(routes=[], extensions={"child-esmerald": Pluggable(ChildEsmeraldPluggable)}) diff --git a/docs_src/pluggables/manual.py b/docs_src/pluggables/manual.py index c22de6ac..c3487c56 100644 --- a/docs_src/pluggables/manual.py +++ b/docs_src/pluggables/manual.py @@ -20,24 +20,18 @@ 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: """ - Returns a list of pluggables of the system. + Returns a list of extensions of the system. - "pluggables": ["my-extension"] + "extensions": ["my-extension"] """ - pluggables = list(request.app.pluggables) + extensions = list(request.app.extensions) - return JSONResponse({"pluggables": pluggables}) + return JSONResponse({"extensions": extensions}) app = Esmerald(routes=[Gateway(handler=home)]) - -extension = MyExtension(app=app) -extension.extend() +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..940fbc29 --- /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 extensions of Esmerald + # And make it accessible + self.app.add_extension("my-extension", self) + + +@get("/home") +async def home(request: Request) -> JSONResponse: + """ + Returns a list of extensions of the system. + + "extensions": ["my-extension"] + """ + extensions = list(request.app.extensions) + + return JSONResponse({"extensions": extensions}) + + +app = Esmerald(routes=[Gateway(handler=home)]) + +extension = MyExtension(app=app) +extension.extend() diff --git a/docs_src/pluggables/pluggable.py b/docs_src/pluggables/pluggable.py index cca19522..3070e357 100644 --- a/docs_src/pluggables/pluggable.py +++ b/docs_src/pluggables/pluggable.py @@ -26,4 +26,4 @@ def extend(self, config: PluggableConfig) -> None: pluggable = Pluggable(MyExtension, config=my_config) -app = Esmerald(routes=[], pluggables={"my-extension": pluggable}) +app = Esmerald(routes=[], extensions={"my-extension": pluggable}) diff --git a/docs_src/pluggables/reorder.py b/docs_src/pluggables/reorder.py new file mode 100644 index 00000000..20258d53 --- /dev/null +++ b/docs_src/pluggables/reorder.py @@ -0,0 +1,21 @@ +from typing import Optional + +from loguru import logger +from pydantic import BaseModel + +from esmerald import Esmerald, Extension +from esmerald.types import DictAny + + +class MyExtension1(Extension): + def extend(self) -> None: + self.app.extensions.ensure_extension("extension2") + logger.success(f"Extension 1") + + +class MyExtension2(Extension): + def extend(self) -> None: + logger.success(f"Extension 2") + + +app = Esmerald(routes=[], extensions={"extension1": MyExtension1, "extension2": MyExtension2}) diff --git a/docs_src/pluggables/settings.py b/docs_src/pluggables/settings.py index 77d9fa72..b585571c 100644 --- a/docs_src/pluggables/settings.py +++ b/docs_src/pluggables/settings.py @@ -15,19 +15,13 @@ class PluggableConfig(BaseModel): class MyExtension(Extension): - def __init__( - self, app: Optional["Esmerald"] = None, config: PluggableConfig = None, **kwargs: "DictAny" - ): - super().__init__(app, **kwargs) - self.app = app - def extend(self, config: PluggableConfig) -> None: logger.success(f"Successfully passed a config {config.name}") class AppSettings(EsmeraldAPISettings): @property - def pluggables(self) -> Dict[str, "Pluggable"]: + def extensions(self) -> Dict[str, Union["Extension", "Pluggable", type["Extension"]]]: return {"my-extension": Pluggable(MyExtension, config=my_config)} diff --git a/docs_src/pluggables/settings_module.py b/docs_src/pluggables/settings_module.py index 16778513..2e1bb9a2 100644 --- a/docs_src/pluggables/settings_module.py +++ b/docs_src/pluggables/settings_module.py @@ -27,7 +27,7 @@ def extend(self, config: PluggableConfig) -> None: class AppSettings(EsmeraldAPISettings): @property - def pluggables(self) -> Dict[str, "Pluggable"]: + def extensions(self) -> Dict[str, Union["Extension", "Pluggable", type["Extension"]]]: return {"my-extension": Pluggable(MyExtension, config=my_config)} diff --git a/docs_src/pluggables/standalone.py b/docs_src/pluggables/standalone.py index 5cdcb499..f3ea23fa 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 @@ -20,24 +19,21 @@ 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 + # Add the extension to the extensions of Esmerald # And make it accessible - self.app.add_pluggable("standalone", self) + self.app.add_extension("standalone", self) @get("/home") async def home(request: Request) -> JSONResponse: """ - Returns a list of pluggables of the system. + Returns a list of extensions of the system. - "pluggables": ["standalone"] + "extensions": ["standalone"] """ - pluggables = list(request.app.pluggables) + extensions = list(request.app.extensions) - return JSONResponse({"pluggables": pluggables}) + return JSONResponse({"extensions": extensions}) -app = Esmerald(routes=[Gateway(handler=home)]) - -extension = Standalone(app=app) -extension.extend() +app = Esmerald(routes=[Gateway(handler=home)], extensions=[Standalone]) diff --git a/docs_src/routing/handlers/get.py b/docs_src/routing/handlers/get.py index a7d7b31a..dd4d4952 100644 --- a/docs_src/routing/handlers/get.py +++ b/docs_src/routing/handlers/get.py @@ -19,7 +19,8 @@ def another_read(name: str) -> str: app = Esmerald( routes=[ Gateway(handler=example), - Gateway(handler=another), + # you can the handlers also directly (they are automatically converted to Gateways) + another, Gateway(path="/last/{name:str}", handler=another_read), ] ) diff --git a/docs_src/routing/handlers/options.py b/docs_src/routing/handlers/options.py index 4ab50317..7ef907a2 100644 --- a/docs_src/routing/handlers/options.py +++ b/docs_src/routing/handlers/options.py @@ -19,7 +19,7 @@ def another_read(name: str) -> str: app = Esmerald( routes=[ Gateway(handler=example), - Gateway(handler=another), + another, Gateway(path="/last/{name:str}", handler=another_read), ] ) diff --git a/docs_src/routing/handlers/route_example1.py b/docs_src/routing/handlers/route_example1.py index ffdcc466..d967b20e 100644 --- a/docs_src/routing/handlers/route_example1.py +++ b/docs_src/routing/handlers/route_example1.py @@ -14,6 +14,6 @@ async def multiple_methods_function(request: Request) -> JSONResponse: app = Esmerald( routes=[ - Gateway(handler=multiple_methods_function), + multiple_methods_function, ] ) diff --git a/esmerald/applications.py b/esmerald/applications.py index 5e259ee8..5fa98dc9 100644 --- a/esmerald/applications.py +++ b/esmerald/applications.py @@ -1,3 +1,4 @@ +import warnings from datetime import timezone as dtimezone from functools import cached_property from typing import ( @@ -46,7 +47,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 @@ -118,7 +119,7 @@ class Application(Lilya): "openapi_schema", "parent", "permissions", - "pluggables", + "extensions", "redirect_slashes", "response_class", "response_cookies", @@ -1341,11 +1342,60 @@ async def home() -> Dict[str, str]: """ ), ] = None, + extensions: Annotated[ + Optional[Dict[str, Union[Extension, Pluggable, type[Extension]]]], + Doc( + """ + A `list` of global extensions from objects inheriting from + `esmerald.interceptors.interceptor.EsmeraldInterceptor`. + + Read more about how to implement the [Plugables](https://esmerald.dev/pluggables/) in Esmerald and to leverage them. + + **Example** + + ```python + from typing import Optional + + from loguru import logger + from pydantic import BaseModel + + from esmerald import Esmerald, Extension, Pluggable + from esmerald.types import DictAny + + + class PluggableConfig(BaseModel): + name: str + + + class MyExtension(Extension): + def __init__( + self, app: Optional["Esmerald"] = None, config: PluggableConfig = None, **kwargs: "DictAny" + ): + super().__init__(app, **kwargs) + self.app = app + + def extend(self, config: PluggableConfig) -> None: + logger.success(f"Successfully passed a config {config.name}") + + + my_config = PluggableConfig(name="my extension") + pluggable = Pluggable(MyExtension, config=my_config) + + + app = Esmerald( + routes=[], extensions={"my-extension": pluggable} + ) + ``` + """ + ), + ] = 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 + THIS PARAMETER IS DEPRECATED USE extensions INSTEAD + + A `list` of global extensions from objects inheriting from `esmerald.interceptors.interceptor.EsmeraldInterceptor`. Read more about how to implement the [Plugables](https://esmerald.dev/pluggables/) in Esmerald and to leverage them. @@ -1382,7 +1432,7 @@ def extend(self, config: PluggableConfig) -> None: app = Esmerald( - routes=[], pluggables={"my-extension": pluggable} + routes=[], extensions={"my-extension": pluggable} ) ``` """ @@ -1528,7 +1578,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,11 +1627,32 @@ 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 extensions nearly last so everythings is initialized + _extensions: Any = self.load_settings_value("extensions", extensions) + if not _extensions: + _extensions = self.load_settings_value("pluggables", pluggables) + if _extensions: + warnings.warn( + "The `pluggables` parameter/setting is deprecated use `extensions` instead", + DeprecationWarning, + stacklevel=2, + ) + + self.extensions = ExtensionDict(_extensions, app=cast(Esmerald, self)) + self.extensions.extend() self._configure() + @property + def pluggables(self) -> ExtensionDict: + warnings.warn( + "The `pluggables` attribute is deprecated use `extensions` instead", + DeprecationWarning, + stacklevel=2, + ) + return self.extensions + def _register_application_encoders(self) -> None: """ Registers the default Esmerald encoders. @@ -1826,6 +1896,7 @@ def add_apiview( ```python from esmerald import Esmerald, APIView, Gateway, get + class View(APIView): path = "/" @@ -1833,6 +1904,7 @@ class View(APIView): async def hello(self) -> str: return "Hello, World!" + gateway = Gateway(handler=View) app = Esmerald() @@ -1955,10 +2027,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 +2141,7 @@ def add_websocket_route( ```python from esmerald import Esmerald, websocket + @websocket() async def websocket_route(socket: WebSocket) -> None: await socket.accept() @@ -2076,6 +2151,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 +2189,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 +2290,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 +2495,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,26 +2509,44 @@ 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_extension(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_extension("manual", pluggable) ``` """ - self.pluggables[name] = extension + self.extensions[name] = extension + + def add_pluggable( + self, name: str, extension: Union[Extension, Pluggable, type[Extension]] + ) -> None: + warnings.warn( + "The `add_pluggable` method is deprecated use `add_extension` instead", + DeprecationWarning, + stacklevel=2, + ) + self.add_extension(name, extension) @property def settings(self) -> Type["EsmeraldAPISettings"]: @@ -3470,7 +3540,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/conf/global_settings.py b/esmerald/conf/global_settings.py index 61365e7f..925430a6 100644 --- a/esmerald/conf/global_settings.py +++ b/esmerald/conf/global_settings.py @@ -15,7 +15,7 @@ from esmerald.interceptors.types import Interceptor from esmerald.openapi.schemas.v3_1_0 import Contact, License, SecurityScheme from esmerald.permissions.types import Permission -from esmerald.pluggables import Pluggable +from esmerald.pluggables import Extension, Pluggable from esmerald.routing import gateways from esmerald.types import ( APIGateHandler, @@ -1320,15 +1320,15 @@ def lifespan(self) -> Optional["Lifespan"]: return None @property - def pluggables(self) -> Dict[str, "Pluggable"]: + def extensions(self) -> dict[str, Union["Extension", "Pluggable", type["Extension"]]]: """ - A `list` of global pluggables from objects inheriting from + A `list` of global extensions from objects inheriting from `esmerald.interceptors.interceptor.EsmeraldInterceptor`. Read more about how to implement the [Plugables](https://esmerald.dev/pluggables/) in Esmerald and to leverage them. Returns: - Mapping of pluggables + Mapping of extensions Defaults: {} @@ -1350,30 +1350,30 @@ class PluggableConfig(BaseModel): class MyExtension(Extension): - def __init__( - self, app: Optional["Esmerald"] = None, config: PluggableConfig = None, **kwargs: "DictAny" - ): - super().__init__(app, **kwargs) - self.app = app - - def extend(self, config: PluggableConfig) -> None: + def extend(self, config: PluggableConfig = None) -> None: logger.success(f"Successfully passed a config {config.name}") - class AppSettings(EsmeraldAPISettings): @property - def pluggables(self) -> Dict[str, "Pluggable"]: + def extensions(self) -> dict[str, Union["Extension", "Pluggable", type["Extension"]]]: my_config = PluggableConfig(name="my extension") - pluggable = Pluggable(MyExtension, config=my_config) return { - "my-extension": pluggable + "my-extension": Pluggable(MyExtension, config=my_config), + "my-extension": MyExtension, } ``` """ return {} + @property + def pluggables(self) -> Dict[str, Union["Extension", "Pluggable", type["Extension"]]]: + """ + Deprecated + """ + return {} + @property def encoders(self) -> Union[List[Encoder], None]: """ 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..00e2c96c 100644 --- a/esmerald/pluggables/base.py +++ b/esmerald/pluggables/base.py @@ -1,8 +1,10 @@ from abc import ABC, abstractmethod +from inspect import isclass from typing import TYPE_CHECKING, Any, Iterator, Optional from typing_extensions import Annotated, Doc +from esmerald.exceptions import ImproperlyConfigured from esmerald.protocols.extension import ExtensionProtocol if TYPE_CHECKING: # pragma: no cover @@ -11,9 +13,7 @@ class Pluggable: """ - The `Pluggable` is used to create an `Esmerald` pluggable from an `Extension`. - When Esmerald receives pluggables, it hooks them into the system and allows - the access via anywhere in the application. + The `Pluggable` is a wrapper around an Extension to initialize it lazily. Read more about the [Pluggables](https://esmerald.dev/pluggables/) and learn how to use them. @@ -35,10 +35,12 @@ 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 def extend(self, config: PluggableConfig) -> None: logger.success(f"Successfully passed a config {config.name}") @@ -48,11 +50,11 @@ def extend(self, config: PluggableConfig) -> None: pluggable = Pluggable(MyExtension, config=my_config) - app = Esmerald(routes=[], pluggables={"my-extension": pluggable}) + app = Esmerald(routes=[], extensions={"my-extension": pluggable}) ``` """ - def __init__(self, cls: "Extension", **options: Any): + def __init__(self, cls: type["ExtensionProtocol"], **options: Any): self.cls = cls self.options = options @@ -67,35 +69,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. @@ -115,8 +89,7 @@ class Extension(BaseExtension): class MyExtension(Extension): def __init__(self, app: Optional["Esmerald"] = None, **kwargs: "DictAny"): - super().__init__(app, **kwargs) - self.app = app + super().__init__(app) self.kwargs = kwargs def extend(self, **kwargs: "DictAny") -> None: @@ -127,3 +100,96 @@ 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__() + self.app = app + + @abstractmethod + def extend(self, **kwargs: Any) -> None: + raise NotImplementedError("Extension 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 + self.delayed_extend: Optional[dict[str, dict[str, Any]]] = {} + for k, v in self.items(): + self[k] = v + + def extend(self) -> None: + while self.delayed_extend: + key, val = self.delayed_extend.popitem() + self[key].extend(**val) + self.delayed_extend = None + + def ensure_extension(self, name: str) -> None: + """ + For reordering extension initialization + + class MyExtension2(Extension): + + def extend(self, **kwargs: "DictAny") -> None: + ''' + Function that should always be implemented when extending + the Extension class or a `NotImplementedError` is raised. + ''' + self.app.extensions.ensure_extension("foo") + # Do something here + ``` + + """ + if name not in self: + raise ValueError(f"Extension does not exist: {name}") + delayed = self.delayed_extend + if delayed is None: + return + val = delayed.pop(name, None) + if val is not None: + self[name].extend(**val) + + def __setitem__(self, name: Any, value: Any) -> None: + if not isinstance(name, str): + raise ImproperlyConfigured("Extension names should be in string format.") + elif isinstance(value, Pluggable): + cls, options = value + value = cls(app=self.app, **options) + if self.delayed_extend is None: + value.extend(**options) + else: + self.delayed_extend[name] = options + elif not isclass(value) and isinstance(value, ExtensionProtocol): + if self.delayed_extend is not None: + raise ImproperlyConfigured( + "Cannot pass an initialized extension in extensions parameter." + ) + elif isclass(value) and issubclass(value, ExtensionProtocol): + value = value(app=self.app) + if self.delayed_extend is None: + value.extend() + else: + self.delayed_extend[name] = {} + else: + raise ImproperlyConfigured( + "An extension must subclass from Extension, implement the ExtensionProtocol " + "as instance or being wrapped in a Pluggable." + ) + super().__setitem__(name, value) diff --git a/esmerald/protocols/extension.py b/esmerald/protocols/extension.py index 3e7604b9..2910f42b 100644 --- a/esmerald/protocols/extension.py +++ b/esmerald/protocols/extension.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional +from typing import Any, Optional from lilya.types import ASGIApp from typing_extensions import Protocol, runtime_checkable @@ -6,6 +6,6 @@ @runtime_checkable class ExtensionProtocol(Protocol): # pragma: no cover - def __init__(self, app: Optional["ASGIApp"] = None, **kwargs: Dict[Any, Any]): ... + def __init__(self, app: Optional["ASGIApp"] = None, **kwargs: Any): ... def extend(self, **kwargs: Any) -> None: ... diff --git a/esmerald/routing/router.py b/esmerald/routing/router.py index add13ebd..7d54e9bc 100644 --- a/esmerald/routing/router.py +++ b/esmerald/routing/router.py @@ -270,7 +270,7 @@ async def create_user(data: User) -> None: ), ] = None, routes: Annotated[ - Optional[Sequence[Union[APIGateHandler, Include]]], + Optional[Sequence[Union[APIGateHandler, Include, HTTPHandler, WebSocketHandler]]], Doc( """ A `list` of esmerald routes. Those routes may vary and those can @@ -485,7 +485,12 @@ async def another(request: Request) -> str: "/" ), "A path must not end with '/', as the routes will start with '/'" + new_routes: list[Any] = [] for route in routes or []: + if isinstance(route, HTTPHandler): + route = Gateway(handler=route) + elif isinstance(route, WebSocketHandler): + route = WebSocketGateway(handler=route) if not isinstance( route, ( @@ -493,14 +498,14 @@ async def another(request: Request) -> str: Gateway, WebSocketGateway, LilyaBasePath, - LilyaBasePath, Host, Router, ), - ) or isinstance(route, WebhookGateway): + ) or isinstance(route, WebhookGateway): # type: ignore raise ImproperlyConfigured( f"The route {route} must be of type Gateway, WebSocketGateway or Include" ) + new_routes.append(route) assert lifespan is None or ( on_startup is None and on_shutdown is None @@ -508,7 +513,7 @@ async def another(request: Request) -> str: super().__init__( redirect_slashes=redirect_slashes, - routes=routes, + routes=new_routes, default=default, lifespan=lifespan, on_shutdown=on_shutdown, @@ -522,7 +527,7 @@ async def another(request: Request) -> str: self.exception_handlers = exception_handlers or {} self.interceptors: Sequence[Interceptor] = interceptors or [] self.permissions: Sequence[Permission] = permissions or [] # type: ignore - self.routes: Any = routes or [] + self.routes: Any = new_routes self.middleware = middleware or [] self.tags = tags or [] self.name = name @@ -534,7 +539,7 @@ async def another(request: Request) -> str: self.routing = copy(self.routes) for route in self.routing or []: - self.validate_root_route_parent(route) + self.validate_root_route_parent(route) # type: ignore for route in self.routes or []: self.create_signature_models(route) @@ -700,6 +705,7 @@ def add_apiview( ```python from esmerald import Router, APIView, Gateway, get + class View(APIView): path = "/" @@ -707,6 +713,7 @@ class View(APIView): async def hello(self) -> str: return "Hello, World!" + gateway = Gateway(handler=View) app = Router() @@ -829,10 +836,12 @@ def add_route( ```python from esmerald import get + @get(status_code=status_code) async def hello(self) -> str: return "Hello, World!" + app = Esmerald() app.add_route(path="/hello", handler=hello) ``` @@ -937,6 +946,7 @@ def add_websocket_route( ```python from esmerald import websocket + @websocket() async def websocket_route(socket: WebSocket) -> None: await socket.accept() @@ -946,6 +956,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) ``` @@ -2553,7 +2564,7 @@ async def home(request: Request) -> dict: ), ] = None, routes: Annotated[ - Optional[Sequence[Union[APIGateHandler, Include]]], + Optional[Sequence[Union[APIGateHandler, Include, HTTPHandler, WebSocketHandler]]], Doc( """ A global `list` of esmerald routes. Those routes may vary and those can @@ -2844,7 +2855,7 @@ def resolve_app_parent(self, app: Optional[Any]) -> Optional[Any]: return app def resolve_route_path_handler( - self, routes: Sequence[Union[APIGateHandler, Include]] + self, routes: Sequence[Union[APIGateHandler, Include, HTTPHandler, WebSocketHandler]] ) -> List[Union[Gateway, WebSocketGateway, Include]]: """ Make sure the paths are properly configured from the handler handler. @@ -2896,6 +2907,11 @@ def resolve_route_path_handler( routing: List[Union[Gateway, WebSocketGateway, Include]] = [] for route in routes: # pragma: no cover + if isinstance(route, HTTPHandler): + route = Gateway(handler=route) + elif isinstance(route, WebSocketHandler): + route = WebSocketGateway(handler=route) + if not isinstance(route, (Include, Gateway, WebSocketGateway)): raise ImproperlyConfigured("The route must be of type Gateway or Include") diff --git a/esmerald/testclient.py b/esmerald/testclient.py index 40f8c39e..437d7bb1 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,8 @@ 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, + extensions: Optional[Dict[str, Union["Extension", "Pluggable", type["Extension"]]]] = None, permissions: Optional[List["Permission"]] = None, dependencies: Optional["Dependencies"] = None, middleware: Optional[List["Middleware"]] = None, @@ -178,6 +179,7 @@ def create_client( tags=tags, webhooks=webhooks, pluggables=pluggables, + extensions=extensions, encoders=encoders, ), base_url=base_url, diff --git a/tests/handlers/test_to_response_data.py b/tests/handlers/test_to_response_data.py index 1618f866..1e67e398 100644 --- a/tests/handlers/test_to_response_data.py +++ b/tests/handlers/test_to_response_data.py @@ -93,13 +93,17 @@ async def test_function(data: Individual) -> Individual: person_instance = IndividualFactory.build() test_function.signature_model = SignatureFactory( - test_function.fn, set() # type:ignore[arg-type] + test_function.fn, + set(), # type:ignore[arg-type] ).create_signature() - response = await test_function.to_response( - data=test_function.fn(data=person_instance), app=None # type: ignore - ) - assert loads(response.body) == person_instance.model_dump() + # we need the encoders + with create_client([]): + response = await test_function.to_response( + data=test_function.fn(data=person_instance), + app=None, # type: ignore + ) + assert loads(response.body) == person_instance.model_dump() @pytest.mark.asyncio() @@ -111,13 +115,17 @@ async def test_function(data: Individual) -> Individual: person_instance = IndividualFactory.build() test_function.signature_model = SignatureFactory( - test_function.fn, set() # type:ignore[arg-type] + test_function.fn, + set(), # type:ignore[arg-type] ).create_signature() - response = await test_function.to_response( - data=test_function.fn(data=person_instance), app=None # type: ignore - ) - assert loads(response.body) == person_instance.model_dump() + # we need the encoders + with create_client([]): + response = await test_function.to_response( + data=test_function.fn(data=person_instance), + app=None, # type: ignore + ) + assert loads(response.body) == person_instance.model_dump() @pytest.mark.asyncio() @@ -127,7 +135,7 @@ def test_function() -> Response: return Response(status_code=HTTP_200_OK, media_type=MediaType.TEXT, content="ok") with create_client(test_function) as client: - route: HTTPHandler = client.app.routes[0] # type: ignore + route: HTTPHandler = client.app.routes[0].handler # type: ignore response = await route.to_response(data=route.fn(), app=None) # type: ignore assert isinstance(response, Response) @@ -155,7 +163,7 @@ def test_function() -> LilyaResponse: return expected_response with create_client(test_function) as client: - route = client.app.routes[0] # type: ignore + route = client.app.routes[0].handler # type: ignore response = await route.to_response(data=route.fn(), app=None) # type: ignore assert isinstance(response, LilyaResponse) assert response is expected_response @@ -183,7 +191,7 @@ def test_function() -> Redirect: ) with create_client(test_function) as client: - route = client.app.routes[0] + route = client.app.routes[0].handler response = await route.to_response(data=route.fn(), app=None) # type: ignore assert isinstance(response, RedirectResponse) assert response.headers["location"] == "/somewhere-else" @@ -246,7 +254,7 @@ def test_function() -> File: ) with create_client(test_function) as client: - route = client.app.routes[0] # type: ignore + route = client.app.routes[0].handler # type: ignore response = await route.to_response(data=route.fn(), app=None) # type: ignore assert isinstance(response, FileResponse) @@ -301,7 +309,7 @@ def test_function() -> Stream: ) with create_client(test_function) as client: - route = client.app.routes[0] # type: ignore + route = client.app.routes[0].handler # type: ignore response = await route.to_response(data=route.fn(), app=None) # type: ignore assert isinstance(response, StreamingResponse) assert response.headers["local-header"] == "123" @@ -339,7 +347,7 @@ def test_function() -> Template: ) with create_client(test_function) as client: - route = client.app.routes[0] # type: ignore + route = client.app.routes[0].handler # type: ignore response = await route.to_response(data=route.fn(), app=None) # type: ignore assert isinstance(response, TemplateResponse) diff --git a/tests/pluggables/test_child_esmerald_via_pluggable.py b/tests/pluggables/test_child_esmerald_via_pluggable.py index 77c8997d..8e684814 100644 --- a/tests/pluggables/test_child_esmerald_via_pluggable.py +++ b/tests/pluggables/test_child_esmerald_via_pluggable.py @@ -19,13 +19,13 @@ @get("/") async def home(request: Request) -> JSONResponse: """ - Returns a list of pluggables of the system. + Returns a list of extensions of the system. - "pluggables": ["standalone"] + "extensions": ["standalone"] """ - pluggables = list(request.app.pluggables) + extensions = list(request.app.extensions) - return JSONResponse({"pluggables": pluggables}) + return JSONResponse({"extensions": extensions}) class ChildEsmeraldPluggable(Extension): @@ -48,7 +48,7 @@ def extend(self, **kwargs: "DictAny") -> None: def test_can_add_child_esmerald_via_pluggable(): - app = Esmerald(routes=[], pluggables={"child-esmerald": Pluggable(ChildEsmeraldPluggable)}) + app = Esmerald(routes=[], extensions={"child-esmerald": Pluggable(ChildEsmeraldPluggable)}) client = EsmeraldTestClient(app=app) diff --git a/tests/pluggables/test_extensions.py b/tests/pluggables/test_extensions.py new file mode 100644 index 00000000..7f873552 --- /dev/null +++ b/tests/pluggables/test_extensions.py @@ -0,0 +1,193 @@ +from typing import Optional + +import pytest +from loguru import logger +from pydantic import BaseModel + +from esmerald import Esmerald, Extension, Pluggable +from esmerald.exceptions import ImproperlyConfigured +from esmerald.types import DictAny + + +class MyNewPluggable: ... + + +class PluggableNoPlug(Extension): # pragma: no cover + def __init__(self, app: "Esmerald"): + super().__init__(app) + self.app = app + + +def test_raises_improperly_configured_for_subclass(test_client_factory): + with pytest.raises(ImproperlyConfigured) as raised: + Esmerald(routes=[], extensions={"test": MyNewPluggable}) + + assert raised.value.detail == ( + "An extension must subclass from Extension, implement the ExtensionProtocol " + "as instance or being wrapped in a Pluggable." + ) + + +def test_raises_improperly_configured_for_key_of_pluggables(test_client_factory): + with pytest.raises(ImproperlyConfigured) as raised: + Esmerald(routes=[], extensions={1: MyNewPluggable}) + + assert raised.value.detail == "Extension names should be in string format." + + +def test_raises_error_for_missing_extend(test_client_factory): + with pytest.raises(Exception): # noqa + Esmerald( + routes=[], + extensions={"test": Pluggable(PluggableNoPlug)}, + ) + + +class Config(BaseModel): + name: Optional[str] + + +class MyExtension(Extension): + def __init__(self, app: "Esmerald", config: Config): + super().__init__(app) + self.config = config + + def extend(self, config: Config) -> None: + logger.info(f"Started extension with config name {config.name}") + + +def test_generates_pluggable(): + app = Esmerald( + routes=[], extensions={"test": Pluggable(MyExtension, config=Config(name="my pluggable"))} + ) + + assert "test" in app.extensions + + +def test_generates_many_pluggables(): + container = [] + + class ReorderedExtension(Extension): + def __init__(self, app: "Esmerald"): + super().__init__(app) + + def extend(self) -> None: + container.append("works") + + class NonExtension: + def __init__(self, app: "Esmerald"): + super().__init__() + self.app = app + + def extend(self) -> None: + pass + + class LoggingExtension(Extension): + def __init__(self, app: "Esmerald", name): + super().__init__(app) + self.name = name + + def extend(self, name) -> None: + self.app.extensions.ensure_extension("base") + assert container == ["works"] + logger.info(f"Started logging extension with name {name}") + + class DatabaseExtension(Extension): + def __init__(self, app: "Esmerald", database): + super().__init__(app) + self.database = database + + def extend(self, database) -> None: + with pytest.raises(ValueError): + self.app.extensions.ensure_extension("non-existing") + logger.info(f"Started extension with database {database}") + + app = Esmerald( + routes=[], + extensions={ + "test": Pluggable(MyExtension, config=Config(name="my pluggable")), + "logging": Pluggable(LoggingExtension, name="my logging"), + "database": Pluggable(DatabaseExtension, database="my db"), + "base": ReorderedExtension, + "non-extension": NonExtension, + }, + ) + + assert len(app.extensions.keys()) == 5 + + +def test_start_extension_directly(test_client_factory): + class CustomExtension(Extension): + def __init__(self, app: Optional["Esmerald"] = None, **kwargs: DictAny): + super().__init__(app, **kwargs) + + def extend(self, **kwargs) -> None: + app = kwargs.get("app") + config = kwargs.get("config") + logger.success(f"Started standalone plugging with the name: {config.name}") + app.extensions["custom"] = self + + app = Esmerald(routes=[]) + config = Config(name="standalone") + extension = CustomExtension() + extension.extend(app=app, config=config) + + assert "custom" in app.extensions + assert isinstance(app.extensions["custom"], Extension) + + +def test_add_extension_manual(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 plugging with the name: {config.name}") + + self.app.add_extension("manual", self) + + app = Esmerald(routes=[]) + config = Config(name="manual") + extension = CustomExtension(app=app) + extension.extend(config=config) + + assert "manual" in app.extensions + assert isinstance(app.extensions["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}") + + self.app.add_extension("manual", self) + + app = Esmerald(routes=[]) + config = Config(name="manual") + extension = CustomExtension(app=app) + extension.extend(config=config) + + assert "manual" in app.extensions + assert not isinstance(app.extensions["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_extension("manual", Pluggable(CustomExtension, config=config)) + + assert "manual" in app.extensions + assert isinstance(app.extensions["manual"], Extension) diff --git a/tests/pluggables/test_pluggables.py b/tests/pluggables/test_pluggables.py index dbd45f3c..70134706 100644 --- a/tests/pluggables/test_pluggables.py +++ b/tests/pluggables/test_pluggables.py @@ -18,29 +18,32 @@ def __init__(self, app: "Esmerald"): self.app = app -def test_raises_improperly_configured_for_subsclass(test_client_factory): - with pytest.raises(ImproperlyConfigured) as raised: - Esmerald(routes=[], pluggables={"test": MyNewPluggable}) - - assert ( - raised.value.detail - == "An extension must subclass from esmerald.pluggables.Extension and added to a Pluggable object" +def test_raises_improperly_configured_for_subclass(test_client_factory): + with pytest.warns(DeprecationWarning): + with pytest.raises(ImproperlyConfigured) as raised: + Esmerald(routes=[], pluggables={"test": MyNewPluggable}) + + assert raised.value.detail == ( + "An extension must subclass from Extension, implement the ExtensionProtocol " + "as instance or being wrapped in a Pluggable." ) def test_raises_improperly_configured_for_key_of_pluggables(test_client_factory): - with pytest.raises(ImproperlyConfigured) as raised: - Esmerald(routes=[], pluggables={1: MyNewPluggable}) + with pytest.warns(DeprecationWarning): + with pytest.raises(ImproperlyConfigured) as raised: + Esmerald(routes=[], pluggables={1: MyNewPluggable}) - assert raised.value.detail == "Pluggable names should be in string format." + assert raised.value.detail == "Extension names should be in string format." def test_raises_error_for_missing_extend(test_client_factory): - with pytest.raises(Exception): # noqa - Esmerald( - routes=[], - pluggables={"test": Pluggable(PluggableNoPlug)}, - ) + with pytest.warns(DeprecationWarning): + with pytest.raises(Exception): # noqa + Esmerald( + routes=[], + pluggables={"test": Pluggable(PluggableNoPlug)}, + ) class Config(BaseModel): @@ -50,7 +53,6 @@ class Config(BaseModel): class MyExtension(Extension): def __init__(self, app: "Esmerald", config: Config): super().__init__(app) - self.app = app self.config = config def extend(self, config: Config) -> None: @@ -58,11 +60,14 @@ def extend(self, config: Config) -> None: def test_generates_pluggable(): - app = Esmerald( - routes=[], pluggables={"test": Pluggable(MyExtension, config=Config(name="my pluggable"))} - ) + with pytest.warns(DeprecationWarning): + app = Esmerald( + routes=[], + pluggables={"test": Pluggable(MyExtension, config=Config(name="my pluggable"))}, + ) - assert "test" in app.pluggables + with pytest.warns(DeprecationWarning): + assert "test" in app.pluggables def test_generates_many_pluggables(): @@ -86,51 +91,69 @@ def extend(self, database) -> None: app = Esmerald( routes=[], - pluggables={ + extensions={ "test": Pluggable(MyExtension, config=Config(name="my pluggable")), "logging": Pluggable(LoggingExtension, name="my logging"), "database": Pluggable(DatabaseExtension, database="my db"), }, ) + with pytest.warns(DeprecationWarning): + assert len(app.pluggables.keys()) == 3 - assert len(app.pluggables.keys()) == 3 - -def test_start_extension_directly(test_client_factory): +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, **kwargs) -> None: - app = kwargs.get("app") - config = kwargs.get("config") - logger.success(f"Started standalone plugging with the name: {config.name}") - app.pluggables["custom"] = self + def extend(self, config) -> None: + logger.success(f"Started plugging with the name: {config.name}") + + self.app.add_extension("manual", self) app = Esmerald(routes=[]) - config = Config(name="standalone") - extension = CustomExtension() - extension.extend(app=app, config=config) + config = Config(name="manual") + extension = CustomExtension(app=app) + extension.extend(config=config) - assert "custom" in app.pluggables - assert isinstance(app.pluggables["custom"], Extension) + assert "manual" in app.extensions + assert isinstance(app.extensions["manual"], Extension) -def test_add_extension_(test_client_factory): - class CustomExtension(Extension): +def test_add_standalone_extension(test_client_factory): + class CustomExtension: def __init__(self, app: Optional["Esmerald"] = None, **kwargs: DictAny): - super().__init__(app, **kwargs) self.app = app + self.kwargs = kwargs def extend(self, config) -> None: logger.success(f"Started standalone plugging with the name: {config.name}") - self.app.add_pluggable("manual", self) + self.app.add_extension("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) + assert "manual" in app.extensions + assert not isinstance(app.extensions["manual"], Extension) + + +def test_add_pluggable_manual(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") + with pytest.warns(DeprecationWarning): + app.add_pluggable("manual", Pluggable(CustomExtension, config=config)) + + assert "manual" in app.extensions + assert isinstance(app.extensions["manual"], Extension) diff --git a/tests/routing/test_routing.py b/tests/routing/test_routing.py index 3216b98d..d4074f5e 100644 --- a/tests/routing/test_routing.py +++ b/tests/routing/test_routing.py @@ -26,6 +26,11 @@ async def deny_access(request: Request) -> JSONResponse: """ """ +@get(path="/decorated") +async def decorated(request: Request) -> JSONResponse: + return JSONResponse("Hello, world") + + @get(path="/", permissions=[AllowAny]) async def allow_access(request: Request) -> JSONResponse: return JSONResponse("Hello, world") @@ -240,6 +245,7 @@ async def websocket_endpoint_include(self, socket: WebSocket, param: str, name: ), Gateway("/func", handler=func_homepage), Gateway("/func", handler=contact), + decorated, Gateway("/int/{param:int}", handler=int_convertor, name="int-convertor"), Gateway("/float/{param:float}", handler=float_convertor, name="float-convertor"), Gateway("/path/{param:path}", handler=path_convertor, name="path-convertor"),