diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 514ef86a..c75b113b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,7 +27,7 @@ repos: - id: ruff args: ["--fix", "--line-length=99"] - repo: https://github.com/psf/black - rev: 24.4.0 + rev: 24.4.1 hooks: - id: black args: ["--line-length=99"] diff --git a/docs/encoders.md b/docs/encoders.md new file mode 100644 index 00000000..e32750ef --- /dev/null +++ b/docs/encoders.md @@ -0,0 +1,171 @@ +# Encoders + +Esmerald being built on top of Lilya, brings another level of flexibility, the **encoders**. + +Pretty much like Lilya, an Encoder is what allows a specific type of object to be understood, +encoded and serialized by Esmerald without breaking the application. + +An example of default existing encoders in Esmerald would be the support for **Pydantic** and **MsgSpec**. + +!!! Warning + The encoders came to Esmerald after the version **3.1.2**. If you are using a version prior + to that, this won't be available. + +## Benefits of encoders + +The greatest benefit of supporting the encoders is that you don't need to rely on a specific framework +to support a specific library for you to use. + +With Esmerald `Encoder` you can design it yourself and simply add it to Esmerald to be used making it +**future proof** and extremely dynamic. + +## How to use it + +To take advantage of the Encoders **you must subclass the Encoder from Esmerald and implement three mandatory functions**. + +```python +from esmerald.encoders import Encoder +``` + +When subclassing the `Encoder`, the mandatory functions are: + +* [`is_type()`](#is_type) +* [`serialize()`](#serialize) +* [`encode()`](#encode) + +Esmerald extends the native functionality of Lilya regarding the encoders and adds some extra flavours to it. + +The reasoning behind it its because Esmerald internally manages signatures and data validations that are +unique to Esmerald. + +### is_type + +This function might sound confusing but it is in fact something simple. This function is used to check +if the object of type X is an instance or a subclass of that same type. + +!!! Danger + Here is where it is different from Lilya. With Lilya you can use the `__type__` as well but + **not in Esmerald. In Esmerald you must implement the `is_type` function. + +#### Example + +This is what currently Esmerald is doing for Pydantic and MsgSpec. + +```python +{!> ../docs_src/encoders/is_type.py !} +``` + +As you can see, this is how we check and verify if an object of type `BaseModel` and `Struct` are +properly validated by Esmerald. + +### serialize + +This function is what tells Esmerald how to serialize the given object type into a JSON readable +format. + +Quite simple and intuitive. + +#### Example + +```python +{!> ../docs_src/encoders/serialize.py !} +``` + +### encode + +Finally, this functionality is what converts a given piece of data (JSON usually) into an object +of the type of the Encoder. + +For example, a dictionary into Pydantic models or MsgSpec Structs. + +#### Example + +```python +{!> ../docs_src/encoders/encode.py !} +``` + +### The flexibility + +As you can see, there are many ways of you building your encoders. Esmerald internally already brings +two of them out of the box but you are free to build your own [custom encoder](#custom-encoders) and +apply your own logic and validations. + +You have 100% the power and control over any validator you would love to have in your Esmerald application. + +### Custom Encoders + +Well, this is where it becomes interesting. What if you actually want to build an Encoder that is not +currently supported by Esmerald natively, for example, the library `attrs`? + +It is in fact very simple as well, following the previous steps and explanations, it would look +like this: + +```python +{!> ../docs_src/encoders/custom.py !} +``` + +Do you see any differences compared to `Pydantic` and `MsgSpec`? + +Well, the `is_type` does not check for an `isinstance` or `is_class_and_subclass` and the reason +for that its because when using `attrs` there is not specific object of type X like we have in others, +in fact, the `attrs` uses decorators for it and by default provides a `has()` function that is used +to check the `attrs` object types, so we can simply use it. + +Every library has its own ways, object types and everything in between to check and +**this is the reason why the `is_type` exists, to make sure you have the control over the way the typing is checked**. + +Now imagine what you can do with any other library at your choice. + +### Register the Encoder + +Well, building the encoders is good fun but it does nothing to Esmerald unless you make it aware those +in fact exist and should be used. + +There are different ways of registering the encoders. + +* Via [settings](#via-settings) +* Via [instance](#via-instance) + +Esmerald also provides a function to register anywhere in your application but **it is not recommended** +to use it without understanding the ramifications, mostly if you have handlers relying on a given +object type that needs the encoder to be available before assembling the routing system. + +```python +from esmerald.encoders import register_esmerald_encoder +``` + +#### Via Settings + +Like everything in Esmerald, you can use the settings for basically everything in your application. + +Let us use the example of the [custom encoder](#custom-encoders) `AttrsEncoder`. + +```python +{!> ../docs_src/encoders/via_settings.py !} +``` + +#### Via Instance + +Classic approach and also available in any Esmerald or ChildEsmerald instance. + +```python +{!> ../docs_src/encoders/via_instance.py !} +``` + +#### Adding an encoder via app instance function + +This is also available in any Esmerald and ChildEsmerald application. If you would like to add +an encoder after instantiation you can do it but again, **it is not recommended** +to use it without understanding the ramifications, mostly if you have handlers relying on a given +object type that needs the encoder to be available before assembling the routing system. + +```python +{!> ../docs_src/encoders/via_func.py !} +``` + +### Notes + +Having this level of flexibility is great in any application and Esmerald makes it easy for you but +it is also important to understand that this level of control also comes with risks, meaning, when +you build an encoder, make sure you test all the cases possible and more importantly, you implement +**all the functions** mentioned above or else your application will break. diff --git a/docs_src/encoders/custom.py b/docs_src/encoders/custom.py new file mode 100644 index 00000000..e0403d8b --- /dev/null +++ b/docs_src/encoders/custom.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import Any + +from attrs import asdict, define, field, has + +from esmerald.encoders import Encoder + + +class AttrsEncoder(Encoder): + + def is_type(self, value: Any) -> bool: + return has(value) + + def serialize(self, obj: Any) -> Any: + return asdict(obj) + + def encode(self, annotation: Any, value: Any) -> Any: + return annotation(**value) + + +# The way an `attr` object is defined +@define +class AttrItem: + name: str = field() + age: int = field() + email: str diff --git a/docs_src/encoders/encode.py b/docs_src/encoders/encode.py new file mode 100644 index 00000000..4c22b9c2 --- /dev/null +++ b/docs_src/encoders/encode.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Any + +import msgspec +from msgspec import Struct +from pydantic import BaseModel + +from esmerald.encoders import Encoder +from lilya._utils import is_class_and_subclass + + +class MsgSpecEncoder(Encoder): + + def is_type(self, value: Any) -> bool: + return isinstance(value, Struct) or is_class_and_subclass(value, Struct) + + def serialize(self, obj: Any) -> Any: + return msgspec.json.decode(msgspec.json.encode(obj)) + + def encode(self, annotation: Any, value: Any) -> Any: + return msgspec.json.decode(msgspec.json.encode(value), type=annotation) + + +class PydanticEncoder(Encoder): + + def is_type(self, value: Any) -> bool: + return isinstance(value, BaseModel) or is_class_and_subclass(value, BaseModel) + + def serialize(self, obj: BaseModel) -> dict[str, Any]: + return obj.model_dump() + + def encode(self, annotation: Any, value: Any) -> Any: + if isinstance(value, BaseModel): + return value + return annotation(**value) diff --git a/docs_src/encoders/is_type.py b/docs_src/encoders/is_type.py new file mode 100644 index 00000000..f9d0a512 --- /dev/null +++ b/docs_src/encoders/is_type.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import Any + +from msgspec import Struct +from pydantic import BaseModel + +from esmerald.encoders import Encoder +from lilya._utils import is_class_and_subclass + + +class MsgSpecEncoder(Encoder): + + def is_type(self, value: Any) -> bool: + return isinstance(value, Struct) or is_class_and_subclass(value, Struct) + + +class PydanticEncoder(Encoder): + + def is_type(self, value: Any) -> bool: + return isinstance(value, BaseModel) or is_class_and_subclass(value, BaseModel) diff --git a/docs_src/encoders/serialize.py b/docs_src/encoders/serialize.py new file mode 100644 index 00000000..7a8ebc72 --- /dev/null +++ b/docs_src/encoders/serialize.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Any + +import msgspec +from msgspec import Struct +from pydantic import BaseModel + +from esmerald.encoders import Encoder +from lilya._utils import is_class_and_subclass + + +class MsgSpecEncoder(Encoder): + + def is_type(self, value: Any) -> bool: + return isinstance(value, Struct) or is_class_and_subclass(value, Struct) + + def serialize(self, obj: Any) -> Any: + return msgspec.json.decode(msgspec.json.encode(obj)) + + +class PydanticEncoder(Encoder): + + def is_type(self, value: Any) -> bool: + return isinstance(value, BaseModel) or is_class_and_subclass(value, BaseModel) + + def serialize(self, obj: BaseModel) -> dict[str, Any]: + return obj.model_dump() diff --git a/docs_src/encoders/via_func.py b/docs_src/encoders/via_func.py new file mode 100644 index 00000000..e286eae8 --- /dev/null +++ b/docs_src/encoders/via_func.py @@ -0,0 +1,8 @@ +from myapp.encoders import AttrsEncoder + +from esmerald import Esmerald + +app = Esmerald( + routes=[...], +) +app.register_encoder(AttrsEncoder) diff --git a/docs_src/encoders/via_instance.py b/docs_src/encoders/via_instance.py new file mode 100644 index 00000000..0a0db7ac --- /dev/null +++ b/docs_src/encoders/via_instance.py @@ -0,0 +1,8 @@ +from myapp.encoders import AttrsEncoder + +from esmerald import Esmerald + +app = Esmerald( + routes=[...], + encoders=[AttrsEncoder], +) diff --git a/docs_src/encoders/via_settings.py b/docs_src/encoders/via_settings.py new file mode 100644 index 00000000..571be09d --- /dev/null +++ b/docs_src/encoders/via_settings.py @@ -0,0 +1,12 @@ +from typing import List, Union + +from myapp.encoders import AttrsEncoder + +from esmerald import EsmeraldAPISettings +from esmerald.encoders import Encoder + + +class AppSettings(EsmeraldAPISettings): + @property + def encoders(self) -> Union[List[Encoder], None]: + return [AttrsEncoder] diff --git a/mkdocs.yml b/mkdocs.yml index 3488a852..0f176dbd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -107,6 +107,7 @@ nav: - Request: "requests.md" - Context: "context.md" - Responses: "responses.md" + - Encoders: "encoders.md" - MsgSpec: "msgspec.md" - Background Tasks: "background-tasks.md" - Lifespan Events: "lifespan-events.md" diff --git a/pyproject.toml b/pyproject.toml index 9a424d5c..e50f8930 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,7 +96,7 @@ test = [ "pytest>=7.1.3,<9.0.0", "pytest-cov>=4.1.0,<6.0.0", "pytest-asyncio>=0.20.0", - "mypy==1.9.0", + "mypy==1.10.0", "flake8>=5.0.4", "aiofiles>=0.8.0,<24", "a2wsgi>=1.9.0,<2", @@ -126,7 +126,7 @@ test = [ dev = [ "a2wsgi>=1.10.0,<2", "autoflake>=1.4.0", - "black==24.4.0", + "black==24.4.1", "ipdb", "pdbpp", "isort>=5.0.6,<6.0.0", @@ -180,7 +180,7 @@ disallow_any_generics = false implicit_reexport = false show_error_codes = true disallow_incomplete_defs = true -disable_error_code = "attr-defined,has-type,override" +disable_error_code = "attr-defined,has-type,override,safe-super" exclude = "esmerald/conf,esmerald/utils" warn_unused_ignores = true warn_redundant_casts = true