From 9f86e1d46eece44c5c2ad3d31a5c8152185ed778 Mon Sep 17 00:00:00 2001 From: collerek Date: Sun, 6 Dec 2020 19:45:09 +0100 Subject: [PATCH] bump version, more tests, update docs --- docs/releases.md | 6 +- docs/signals.md | 249 ++++++++++++++++++++++ docs_src/signals/__init__.py | 0 docs_src/signals/docs002.py | 22 ++ mkdocs.yml | 1 + ormar/__init__.py | 26 ++- ormar/decorators/__init__.py | 14 ++ ormar/decorators/signals.py | 42 ++-- tests/test_excluding_fields_in_fastapi.py | 18 +- tests/test_signals.py | 85 +++++++- 10 files changed, 419 insertions(+), 44 deletions(-) create mode 100644 docs/signals.md create mode 100644 docs_src/signals/__init__.py create mode 100644 docs_src/signals/docs002.py diff --git a/docs/releases.md b/docs/releases.md index bd8f3db..1ab8e39 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -2,9 +2,11 @@ * **Breaking:** QuerySet `bulk_update` method now raises `ModelPersistenceError` for unsaved models passed instead of `QueryDefinitionError` * **Breaking:** Model initialization with unknown field name now raises `ModelError` instead of `KeyError` -* -* Add py.typed and modify setup.py for mypy support +* Added **Signals**, with pre-defined list signals and decorators: `post_delete`, `post_save`, `post_update`, `pre_delete`, +`pre_save`, `pre_update` +* Add `py.typed` and modify `setup.py` for mypy support * Performance optimization +* Updated docs # 0.6.2 diff --git a/docs/signals.md b/docs/signals.md new file mode 100644 index 0000000..14286ca --- /dev/null +++ b/docs/signals.md @@ -0,0 +1,249 @@ +# Signals + +Signals are a mechanism to fire your piece of code (function / method) whenever given type of event happens in `ormar`. + +To achieve this you need to register your receiver for a given type of signal for selected model(s). + +## Defining receivers + +Given a sample model like following: + +```Python +import databases +import sqlalchemy + +import ormar + +database = databases.Database("sqlite:///db.sqlite") +metadata = sqlalchemy.MetaData() + + +class Album(ormar.Model): + class Meta: + tablename = "albums" + metadata = metadata + database = database + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100) + is_best_seller: bool = ormar.Boolean(default=False) + play_count: int = ormar.Integer(default=0) +``` + +You can for example define a trigger that will set `album.is_best_seller` status if it will be played more than 50 times. + +Import `pre_update` decorator, for list of currently available decorators/ signals check below. + +```Python hl_lines="1" +--8<-- "../docs_src/signals/docs002.py" +``` + +Define your function. + +Note that each receiver function: + +* has to be **callable** +* has to accept first **`sender`** argument that receives the class of sending object +* has to accept **`**kwargs`** argument as the parameters send in each `ormar.Signal` can change at any time so your function has to serve them. +* has to be **`async`** cause callbacks are gathered and awaited. + +`pre_update` currently sends only one argument apart from `sender` and it's `instance` one. + +Note how `pre_update` decorator accepts a `senders` argument that can be a single model or a list of models, +for which you want to run the signal receiver. + +Currently there is no way to set signal for all models at once without explicitly passing them all into registration of receiver. + +```Python hl_lines="4-7" +--8<-- "../docs_src/signals/docs002.py" +``` + +!!!note + Note that receivers are defined on a class level -> so even if you connect/disconnect function through instance + it will run/ stop running for all operations on that `ormar.Model` class. + +Note that our newly created function has instance and class of the instance so you can easily run database +queries inside your receivers if you want to. + +```Python hl_lines="15-22" +--8<-- "../docs_src/signals/docs002.py" +``` + +You can define same receiver for multiple models at once by passing a list of models to signal decorator. + +```python +# define a dummy debug function +@pre_update([Album, Track]) +async def before_update(sender, instance, **kwargs): + print(f"{sender.get_name()}: {instance.json()}: {kwargs}") +``` + +Of course you can also create multiple functions for the same signal and model. Each of them will run at each signal. + +```python +@pre_update(Album) +async def before_update(sender, instance, **kwargs): + print(f"{sender.get_name()}: {instance.json()}: {kwargs}") + +@pre_update(Album) +async def before_update2(sender, instance, **kwargs): + print(f'About to update {sender.get_name()} with pk: {instance.pk}') +``` + +Note that `ormar` decorators are the syntactic sugar, you can directly connect your function or method for given signal for +given model. Connect accept only one parameter - your `receiver` function / method. + +```python hl_lines="11 13 16" +class AlbumAuditor: + def __init__(self): + self.event_type = "ALBUM_INSTANCE" + + async def before_save(self, sender, instance, **kwargs): + await AuditLog( + event_type=f"{self.event_type}_SAVE", event_log=instance.json() + ).save() + +auditor = AlbumAuditor() +pre_save(Album)(auditor.before_save) +# call above has same result like the one below +Album.Meta.signals.pre_save.connect(auditor.before_save) +# signals are also exposed on instance +album = Album(name='Miami') +album.signals.pre_save.connect(auditor.before_save) +``` + +!!!warning + Note that signals keep the reference to your receiver (not a `weakref`) so keep that in mind to avoid circular references. + +## Disconnecting the receivers + +To disconnect the receiver and stop it for running for given model you need to disconnect it. + +```python hl_lines="7 10" + +@pre_update(Album) +async def before_update(sender, instance, **kwargs): + if instance.play_count > 50 and not instance.is_best_seller: + instance.is_best_seller = True + +# disconnect given function from signal for given Model +Album.Meta.signals.pre_save.disconnect(before_save) +# signals are also exposed on instance +album = Album(name='Miami') +album.signals.pre_save.disconnect(before_save) +``` + + +## Available signals + +!!!warning + Note that signals are **not** send for: + + * bulk operations (`QuerySet.bulk_create` and `QuerySet.bulk_update`) as they are designed for speed. + + * queyset table level operations (`QuerySet.update` and `QuerySet.delete`) as they run on the underlying tables + (more lak raw sql update/delete operations) and do not have specific instance. + +### pre_save + +`pre_save(sender: Type["Model"], instance: "Model")` + +Send for `Model.save()` and `Model.objects.create()` methods. + +`sender` is a `ormar.Model` class and `instance` is the model to be saved. + +### post_save + +`post_save(sender: Type["Model"], instance: "Model")` + +Send for `Model.save()` and `Model.objects.create()` methods. + +`sender` is a `ormar.Model` class and `instance` is the model that was saved. + +### pre_update + +`pre_update(sender: Type["Model"], instance: "Model")` + +Send for `Model.update()` method. + +`sender` is a `ormar.Model` class and `instance` is the model to be updated. + +### post_update + +`post_update(sender: Type["Model"], instance: "Model")` + +Send for `Model.update()` method. + +`sender` is a `ormar.Model` class and `instance` is the model that was updated. + +### pre_delete + +`pre_delete(sender: Type["Model"], instance: "Model")` + +Send for `Model.save()` and `Model.objects.create()` methods. + +`sender` is a `ormar.Model` class and `instance` is the model to be deleted. + +### post_delete + +`post_delete(sender: Type["Model"], instance: "Model")` + +Send for `Model.update()` method. + +`sender` is a `ormar.Model` class and `instance` is the model that was deleted. + +## Defining your own signals + +Note that you can create your own signals although you will have to send them manually in your code or subclass `ormar.Model` +and trigger your signals there. + +Creating new signal is super easy. Following example will set a new signal with name your_custom_signal. + +```python hl_lines="21" +import databases +import sqlalchemy + +import ormar + +database = databases.Database("sqlite:///db.sqlite") +metadata = sqlalchemy.MetaData() + + +class Album(ormar.Model): + class Meta: + tablename = "albums" + metadata = metadata + database = database + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100) + is_best_seller: bool = ormar.Boolean(default=False) + play_count: int = ormar.Integer(default=0) + +Album.Meta.signals.your_custom_signal = ormar.Signal() +Album.Meta.signals.your_custom_signal.connect(your_receiver_name) +``` + +Actually under the hood signal is a `SignalEmitter` instance that keeps a dictionary of know signals, and allows you +to access them as attributes. When you try to access a signal that does not exist `SignalEmitter` will create one for you. + +So example above can be simplified to. The `Signal` will be created for you. + +``` +Album.Meta.signals.your_custom_signal.connect(your_receiver_name) +``` + +Now to trigger this signal you need to call send method of the Signal. + +```python +await Album.Meta.signals.your_custom_signal.send(sender=Album) +``` + +Note that sender is the only required parameter and it should be ormar Model class. + +Additional parameters have to be passed as keyword arguments. + +```python +await Album.Meta.signals.your_custom_signal.send(sender=Album, my_param=True) +``` + diff --git a/docs_src/signals/__init__.py b/docs_src/signals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/signals/docs002.py b/docs_src/signals/docs002.py new file mode 100644 index 0000000..5b1a15f --- /dev/null +++ b/docs_src/signals/docs002.py @@ -0,0 +1,22 @@ +from ormar import pre_update + + +@pre_update(Album) +async def before_update(sender, instance, **kwargs): + if instance.play_count > 50 and not instance.is_best_seller: + instance.is_best_seller = True + + +# here album.play_count ans is_best_seller get default values +album = await Album.objects.create(name="Venice") +assert not album.is_best_seller +assert album.play_count == 0 + +album.play_count = 30 +# here a trigger is called but play_count is too low +await album.update() +assert not album.is_best_seller + +album.play_count = 60 +await album.update() +assert album.is_best_seller diff --git a/mkdocs.yml b/mkdocs.yml index ccaae7c..60b42b2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,6 +7,7 @@ nav: - Fields: fields.md - Relations: relations.md - Queries: queries.md + - Signals: signals.md - Use with Fastapi: fastapi.md - Use with mypy: mypy.md - PyCharm plugin: plugin.md diff --git a/ormar/__init__.py b/ormar/__init__.py index 036e372..5891f71 100644 --- a/ormar/__init__.py +++ b/ormar/__init__.py @@ -1,6 +1,18 @@ -from ormar.decorators import property_field -from ormar.exceptions import ModelDefinitionError, MultipleMatches, NoMatch +from ormar.decorators import ( + post_delete, + post_save, + post_update, + pre_delete, + pre_save, + pre_update, + property_field, +) from ormar.protocols import QuerySetProtocol, RelationProtocol # noqa: I100 +from ormar.exceptions import ( # noqa: I100 + ModelDefinitionError, + MultipleMatches, + NoMatch, +) from ormar.fields import ( # noqa: I100 BigInteger, Boolean, @@ -22,6 +34,7 @@ from ormar.models import Model from ormar.models.metaclass import ModelMeta from ormar.queryset import QuerySet from ormar.relations import RelationType +from ormar.signals import Signal class UndefinedType: # pragma no cover @@ -31,7 +44,7 @@ class UndefinedType: # pragma no cover Undefined = UndefinedType() -__version__ = "0.6.2" +__version__ = "0.7.0" __all__ = [ "Integer", "BigInteger", @@ -59,4 +72,11 @@ __all__ = [ "RelationProtocol", "ModelMeta", "property_field", + "post_delete", + "post_save", + "post_update", + "pre_delete", + "pre_save", + "pre_update", + "Signal", ] diff --git a/ormar/decorators/__init__.py b/ormar/decorators/__init__.py index 7dfbe5e..395e3e2 100644 --- a/ormar/decorators/__init__.py +++ b/ormar/decorators/__init__.py @@ -1,5 +1,19 @@ from ormar.decorators.property_field import property_field +from ormar.decorators.signals import ( + post_delete, + post_save, + post_update, + pre_delete, + pre_save, + pre_update, +) __all__ = [ "property_field", + "post_delete", + "post_save", + "post_update", + "pre_delete", + "pre_save", + "pre_update", ] diff --git a/ormar/decorators/signals.py b/ormar/decorators/signals.py index d149f97..0505e1a 100644 --- a/ormar/decorators/signals.py +++ b/ormar/decorators/signals.py @@ -1,11 +1,11 @@ -from typing import Any, Callable, List, TYPE_CHECKING, Type, Union +from typing import Callable, List, TYPE_CHECKING, Type, Union if TYPE_CHECKING: # pragma: no cover from ormar import Model def receiver( - signal: str, senders: Union[Type["Model"], List[Type["Model"]]], **kwargs: Any + signal: str, senders: Union[Type["Model"], List[Type["Model"]]] ) -> Callable: def _decorator(func: Callable) -> Callable: if not isinstance(senders, list): @@ -14,43 +14,31 @@ def receiver( _senders = senders for sender in _senders: signals = getattr(sender.Meta.signals, signal) - signals.connect(func, **kwargs) + signals.connect(func) return func return _decorator -def post_save( - senders: Union[Type["Model"], List[Type["Model"]]], **kwargs: Any -) -> Callable: - return receiver(signal="post_save", senders=senders, **kwargs) +def post_save(senders: Union[Type["Model"], List[Type["Model"]]],) -> Callable: + return receiver(signal="post_save", senders=senders) -def post_update( - senders: Union[Type["Model"], List[Type["Model"]]], **kwargs: Any -) -> Callable: - return receiver(signal="post_update", senders=senders, **kwargs) +def post_update(senders: Union[Type["Model"], List[Type["Model"]]],) -> Callable: + return receiver(signal="post_update", senders=senders) -def post_delete( - senders: Union[Type["Model"], List[Type["Model"]]], **kwargs: Any -) -> Callable: - return receiver(signal="post_delete", senders=senders, **kwargs) +def post_delete(senders: Union[Type["Model"], List[Type["Model"]]],) -> Callable: + return receiver(signal="post_delete", senders=senders) -def pre_save( - senders: Union[Type["Model"], List[Type["Model"]]], **kwargs: Any -) -> Callable: - return receiver(signal="pre_save", senders=senders, **kwargs) +def pre_save(senders: Union[Type["Model"], List[Type["Model"]]],) -> Callable: + return receiver(signal="pre_save", senders=senders) -def pre_update( - senders: Union[Type["Model"], List[Type["Model"]]], **kwargs: Any -) -> Callable: - return receiver(signal="pre_update", senders=senders, **kwargs) +def pre_update(senders: Union[Type["Model"], List[Type["Model"]]]) -> Callable: + return receiver(signal="pre_update", senders=senders) -def pre_delete( - senders: Union[Type["Model"], List[Type["Model"]]], **kwargs: Any -) -> Callable: - return receiver(signal="pre_delete", senders=senders, **kwargs) +def pre_delete(senders: Union[Type["Model"], List[Type["Model"]]]) -> Callable: + return receiver(signal="pre_delete", senders=senders) diff --git a/tests/test_excluding_fields_in_fastapi.py b/tests/test_excluding_fields_in_fastapi.py index 3e3349b..6568d9b 100644 --- a/tests/test_excluding_fields_in_fastapi.py +++ b/tests/test_excluding_fields_in_fastapi.py @@ -10,7 +10,7 @@ from fastapi import FastAPI from starlette.testclient import TestClient import ormar -from ormar import property_field +from ormar import post_save, property_field from tests.settings import DATABASE_URL app = FastAPI() @@ -65,8 +65,6 @@ class RandomModel(ormar.Model): metadata = metadata database = database - include_props_in_dict = True - id: int = ormar.Integer(primary_key=True) password: str = ormar.String(max_length=255, default=gen_pass) first_name: str = ormar.String(max_length=255, default="John") @@ -251,7 +249,6 @@ def test_adding_fields_in_endpoints(): ] assert response.json().get("full_name") == "John Test" - RandomModel.Meta.include_props_in_fields = False user3 = {"last_name": "Test"} response = client.post("/random/", json=user3) assert list(response.json().keys()) == [ @@ -268,7 +265,6 @@ def test_adding_fields_in_endpoints(): def test_adding_fields_in_endpoints2(): client = TestClient(app) with client as client: - RandomModel.Meta.include_props_in_dict = True user3 = {"last_name": "Test"} response = client.post("/random2/", json=user3) assert list(response.json().keys()) == [ @@ -283,9 +279,15 @@ def test_adding_fields_in_endpoints2(): def test_excluding_property_field_in_endpoints2(): + + dummy_registry = {} + + @post_save(RandomModel) + async def after_save(sender, instance, **kwargs): + dummy_registry[instance.pk] = instance.dict() + client = TestClient(app) with client as client: - RandomModel.Meta.include_props_in_dict = True user3 = {"last_name": "Test"} response = client.post("/random3/", json=user3) assert list(response.json().keys()) == [ @@ -296,3 +298,7 @@ def test_excluding_property_field_in_endpoints2(): "created_date", ] assert response.json().get("full_name") is None + assert len(dummy_registry) == 1 + check_dict = dummy_registry.get(response.json().get("id")) + check_dict.pop("full_name") + assert response.json().get("password") == check_dict.get("password") diff --git a/tests/test_signals.py b/tests/test_signals.py index bc72706..72eac77 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -6,7 +6,7 @@ import pytest import sqlalchemy import ormar -from ormar.decorators.signals import ( +from ormar import ( post_delete, post_save, post_update, @@ -51,6 +51,7 @@ class Album(ormar.Model): id: int = ormar.Integer(primary_key=True) name: str = ormar.String(max_length=100) is_best_seller: bool = ormar.Boolean(default=False) + play_count: int = ormar.Integer(default=0) cover: Optional[Cover] = ormar.ForeignKey(Cover) @@ -70,6 +71,7 @@ def test_passing_not_callable(): def test_passing_callable_without_kwargs(): with pytest.raises(SignalDefinitionError): + @pre_save(Album) def trigger(sender, instance): # pragma: no cover pass @@ -79,6 +81,7 @@ def test_passing_callable_without_kwargs(): async def test_signal_functions(): async with database: async with database.transaction(force_rollback=True): + @pre_save(Album) async def before_save(sender, instance, **kwargs): await AuditLog( @@ -162,9 +165,9 @@ async def test_signal_functions(): assert len(audits) == 2 assert audits[0].event_type == "PRE_DELETE_album" assert ( - audits[0].event_log.get("id") - == audits[1].event_log.get("id") - == album.id + audits[0].event_log.get("id") + == audits[1].event_log.get("id") + == album.id ) assert audits[1].event_type == "POST_DELETE_album" @@ -178,6 +181,7 @@ async def test_signal_functions(): async def test_multiple_signals(): async with database: async with database.transaction(force_rollback=True): + @pre_save(Album) async def before_save(sender, instance, **kwargs): await AuditLog( @@ -208,6 +212,7 @@ async def test_multiple_signals(): async def test_static_methods_as_signals(): async with database: async with database.transaction(force_rollback=True): + class AlbumAuditor: event_type = "ALBUM_INSTANCE" @@ -232,6 +237,7 @@ async def test_static_methods_as_signals(): async def test_methods_as_signals(): async with database: async with database.transaction(force_rollback=True): + class AlbumAuditor: def __init__(self): self.event_type = "ALBUM_INSTANCE" @@ -252,10 +258,12 @@ async def test_methods_as_signals(): album.signals.pre_save.disconnect(auditor.before_save) + @pytest.mark.asyncio async def test_multiple_senders_signal(): async with database: async with database.transaction(force_rollback=True): + @pre_save([Album, Cover]) async def before_save(sender, instance, **kwargs): await AuditLog( @@ -263,7 +271,7 @@ async def test_multiple_senders_signal(): event_log=instance.json(), ).save() - cover = await Cover(title='Blue').save() + cover = await Cover(title="Blue").save() album = await Album.objects.create(name="San Francisco", cover=cover) audits = await AuditLog.objects.all() @@ -271,7 +279,72 @@ async def test_multiple_senders_signal(): assert audits[0].event_type == "PRE_SAVE_cover" assert audits[0].event_log.get("title") == cover.title assert audits[1].event_type == "PRE_SAVE_album" - assert audits[1].event_log.get("cover") == album.cover.dict(exclude={'albums'}) + assert audits[1].event_log.get("cover") == album.cover.dict( + exclude={"albums"} + ) album.signals.pre_save.disconnect(before_save) cover.signals.pre_save.disconnect(before_save) + + +@pytest.mark.asyncio +async def test_modifing_the_instance(): + async with database: + async with database.transaction(force_rollback=True): + + @pre_update(Album) + async def before_update(sender, instance, **kwargs): + if instance.play_count > 50 and not instance.is_best_seller: + instance.is_best_seller = True + + # here album.play_count ans is_best_seller get default values + album = await Album.objects.create(name="Venice") + assert not album.is_best_seller + assert album.play_count == 0 + + album.play_count = 30 + # here a trigger is called but play_count is too low + await album.update() + assert not album.is_best_seller + + album.play_count = 60 + await album.update() + assert album.is_best_seller + album.signals.pre_update.disconnect(before_update) + + +@pytest.mark.asyncio +async def test_custom_signal(): + async with database: + async with database.transaction(force_rollback=True): + + async def after_update(sender, instance, **kwargs): + if instance.play_count > 50 and not instance.is_best_seller: + instance.is_best_seller = True + elif instance.play_count < 50 and instance.is_best_seller: + instance.is_best_seller = False + await instance.update() + + Album.Meta.signals.custom.connect(after_update) + + # here album.play_count ans is_best_seller get default values + album = await Album.objects.create(name="Venice") + assert not album.is_best_seller + assert album.play_count == 0 + + album.play_count = 30 + # here a trigger is called but play_count is too low + await album.update() + assert not album.is_best_seller + + album.play_count = 60 + await album.update() + assert not album.is_best_seller + await Album.Meta.signals.custom.send(sender=Album, instance=album) + assert album.is_best_seller + + album.play_count = 30 + await album.update() + assert album.is_best_seller + await Album.Meta.signals.custom.send(sender=Album, instance=album) + assert not album.is_best_seller