From 2bbfd0501743b199d8fd1d0a5778c9f50383317c Mon Sep 17 00:00:00 2001 From: collerek Date: Sun, 6 Dec 2020 10:28:48 +0100 Subject: [PATCH] add base signal class --- docs/releases.md | 9 ++++- ormar/__init__.py | 3 +- ormar/decorators/property_field.py | 2 +- ormar/exceptions.py | 48 +++++++++++++++++++++++-- ormar/queryset/queryset.py | 4 +-- ormar/signals/__init__.py | 0 ormar/signals/signal.py | 53 ++++++++++++++++++++++++++++ tests/test_queryset_level_methods.py | 4 +-- 8 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 ormar/signals/__init__.py create mode 100644 ormar/signals/signal.py diff --git a/docs/releases.md b/docs/releases.md index f940a36..96e799e 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,3 +1,10 @@ +# 0.7.0 + +* **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` +* +* Performance optimization + # 0.6.2 * Performance optimization @@ -12,7 +19,7 @@ # 0.6.0 -* **Breaking:** calling instance.load() when the instance row was deleted from db now raises ormar.NoMatch instead of ValueError +* **Breaking:** calling instance.load() when the instance row was deleted from db now raises `NoMatch` instead of `ValueError` * **Breaking:** calling add and remove on ReverseForeignKey relation now updates the child model in db setting/removing fk column * **Breaking:** ReverseForeignKey relation now exposes QuerySetProxy API like ManyToMany relation * **Breaking:** querying related models from ManyToMany cleans list of related models loaded on parent model: diff --git a/ormar/__init__.py b/ormar/__init__.py index 6e196db..036e372 100644 --- a/ormar/__init__.py +++ b/ormar/__init__.py @@ -1,5 +1,5 @@ from ormar.decorators import property_field -from ormar.exceptions import ModelDefinitionError, ModelNotSet, MultipleMatches, NoMatch +from ormar.exceptions import ModelDefinitionError, MultipleMatches, NoMatch from ormar.protocols import QuerySetProtocol, RelationProtocol # noqa: I100 from ormar.fields import ( # noqa: I100 BigInteger, @@ -47,7 +47,6 @@ __all__ = [ "ManyToMany", "Model", "ModelDefinitionError", - "ModelNotSet", "MultipleMatches", "NoMatch", "ForeignKey", diff --git a/ormar/decorators/property_field.py b/ormar/decorators/property_field.py index 8d6a2e2..a377202 100644 --- a/ormar/decorators/property_field.py +++ b/ormar/decorators/property_field.py @@ -13,7 +13,7 @@ def property_field(func: Callable) -> Union[property, Callable]: if len(arguments) > 1 or arguments[0] != "self": raise ModelDefinitionError( "property_field decorator can be used " - "only on class methods with no arguments" + "only on methods with no arguments" ) func.__dict__["__property_field__"] = True return func diff --git a/ormar/exceptions.py b/ormar/exceptions.py index 0ec0c8e..3dbf763 100644 --- a/ormar/exceptions.py +++ b/ormar/exceptions.py @@ -1,28 +1,57 @@ class AsyncOrmException(Exception): + """ + Base ormar Exception + """ + pass class ModelDefinitionError(AsyncOrmException): + """ + Raised for errors related to the model definition itself. + * setting @property_field on method with arguments other than func(self) + * defining a Field without required parameters + * defining a model with more than one primary_key + * defining a model without primary_key + * setting primary_key column as pydantic_only + """ + pass class ModelError(AsyncOrmException): - pass + """ + Raised for initialization of model with non-existing field keyword. + """ - -class ModelNotSet(AsyncOrmException): pass class NoMatch(AsyncOrmException): + """ + Raised for database queries that has no matching result (empty result). + """ + pass class MultipleMatches(AsyncOrmException): + """ + Raised for database queries that should return one row (i.e. get, first etc.) + but has multiple matching results in response. + """ + pass class QueryDefinitionError(AsyncOrmException): + """ + Raised for errors in query definition. + * using contains or icontains filter with instance of the Model + * using Queryset.update() without filter and setting each flag to True + * using Queryset.delete() without filter and setting each flag to True + """ + pass @@ -31,4 +60,17 @@ class RelationshipInstanceError(AsyncOrmException): class ModelPersistenceError(AsyncOrmException): + """ + Raised for update of models without primary_key set (cannot retrieve from db) + or for saving a model with relation to unsaved model (cannot extract fk value). + """ + + pass + + +class SignalDefinitionError(AsyncOrmException): + """ + Raised when non callable receiver is passed as signal callback. + """ + pass diff --git a/ormar/queryset/queryset.py b/ormar/queryset/queryset.py index f6defd4..0b7a31d 100644 --- a/ormar/queryset/queryset.py +++ b/ormar/queryset/queryset.py @@ -6,7 +6,7 @@ from sqlalchemy import bindparam import ormar # noqa I100 from ormar import MultipleMatches, NoMatch -from ormar.exceptions import QueryDefinitionError +from ormar.exceptions import ModelPersistenceError, QueryDefinitionError from ormar.queryset import FilterQuery from ormar.queryset.clause import QueryClause from ormar.queryset.prefetch_query import PrefetchQuery @@ -446,7 +446,7 @@ class QuerySet: for objt in objects: new_kwargs = objt.dict() if pk_name not in new_kwargs or new_kwargs.get(pk_name) is None: - raise QueryDefinitionError( + raise ModelPersistenceError( "You cannot update unsaved objects. " f"{self.model.__name__} has to have {pk_name} filled." ) diff --git a/ormar/signals/__init__.py b/ormar/signals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ormar/signals/signal.py b/ormar/signals/signal.py new file mode 100644 index 0000000..836bd2d --- /dev/null +++ b/ormar/signals/signal.py @@ -0,0 +1,53 @@ +import asyncio +import inspect +from typing import Any, Callable, List, Tuple, Union + +from ormar.exceptions import SignalDefinitionError + + +def callable_accepts_kwargs(func: Callable) -> bool: + return any( + p + for p in inspect.signature(func).parameters.values() + if p.kind == p.VAR_KEYWORD + ) + + +def make_id(target: Any) -> Union[int, Tuple[int, int]]: + if hasattr(target, "__func__"): + return id(target.__self__), id(target.__func__) + return id(target) + + +class Signal: + def __init__(self) -> None: + self._receivers: List[Tuple[Union[int, Tuple[int, int]], Callable]] = [] + + def connect(self, receiver: Callable) -> None: + if not callable(receiver): + raise SignalDefinitionError("Signal receivers must be callable.") + if not callable_accepts_kwargs(receiver): + raise SignalDefinitionError( + "Signal receivers must accept **kwargs argument." + ) + new_receiver_key = make_id(receiver) + if not any(rec_id == new_receiver_key for rec_id, _ in self._receivers): + self._receivers.append((new_receiver_key, receiver)) + + def disconnect(self, receiver: Callable) -> bool: + removed = False + new_receiver_key = make_id(receiver) + for ind, rec in enumerate(self._receivers): + rec_id, _ = rec + if rec_id == new_receiver_key: + removed = True + del self._receivers[ind] + break + return removed + + async def send(self, sender: Any, **kwargs: Any) -> None: + receivers = [] + for receiver in self._receivers: + _, receiver_func = receiver + receivers.append(receiver_func(sender, **kwargs)) + await asyncio.gather(*receivers) diff --git a/tests/test_queryset_level_methods.py b/tests/test_queryset_level_methods.py index 5ea5443..c7db39f 100644 --- a/tests/test_queryset_level_methods.py +++ b/tests/test_queryset_level_methods.py @@ -5,7 +5,7 @@ import pytest import sqlalchemy import ormar -from ormar.exceptions import QueryDefinitionError +from ormar.exceptions import ModelPersistenceError, QueryDefinitionError from tests.settings import DATABASE_URL database = databases.Database(DATABASE_URL, force_rollback=True) @@ -302,7 +302,7 @@ async def test_bulk_update_with_relation(): async def test_bulk_update_not_saved_objts(): async with database: category = await Category.objects.create(name="Sample Category") - with pytest.raises(QueryDefinitionError): + with pytest.raises(ModelPersistenceError): await Note.objects.bulk_update( [ Note(text="Buy the groceries.", category=category),