From 8184986f72bed51b475bfab6f310709bd73ff43d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Feb 2020 22:02:57 +0000 Subject: [PATCH 01/44] Bump django from 2.2.8 to 2.2.10 Bumps [django](https://github.com/django/django) from 2.2.8 to 2.2.10. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.2.8...2.2.10) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 60f80cd..23844e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==2.2.8 +Django==2.2.10 djangorestframework==3.9.4 pytz==2018.3 sqlparse==0.3.0 From 208cc6507f07e8a52ebf3a647444b9c92c174ef9 Mon Sep 17 00:00:00 2001 From: Robert Rollins Date: Fri, 14 Feb 2020 10:19:25 -0800 Subject: [PATCH 02/44] Fixed 'Read on' link in Introduction docs --- docs/index.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/index.md b/docs/index.md index 0833b8f..e889d7f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,13 +30,13 @@ Instead of overriding `save` and `__init___` in a clunky way that hurts readabil ```python # same class and field declarations as above ... - + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._orig_contents = self.contents self._orig_status = self.status - - + + def save(self, *args, **kwargs): if self.pk is not None and self.contents != self._orig_contents): self.updated_at = timezone.now() @@ -58,7 +58,7 @@ Instead of overriding `save` and `__init___` in a clunky way that hurts readabil pip install django-lifecycle ``` -## Getting Started +## Getting Started Either extend the provided abstract base model class: @@ -86,4 +86,4 @@ class YourModel(LifecycleModelMixin, models.Model): If you are using **Django 1.8 or below** and want to extend the base model, you also have to add `django_lifecycle` to `INSTALLED_APPS`. -[Read on](/examples/) to see more examples of how to use lifecycle hooks. +[Read on](examples.md) to see more examples of how to use lifecycle hooks. From 5bb83ddc7e717b1c398958973ef0f11b3f431d80 Mon Sep 17 00:00:00 2001 From: Robert Singer Date: Sat, 15 Feb 2020 14:51:51 -0600 Subject: [PATCH 03/44] Updates requirements --- requirements.txt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/requirements.txt b/requirements.txt index 23844e0..63231c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,17 @@ +Click==7.0 Django==2.2.10 djangorestframework==3.9.4 +Jinja2==2.11.1 +livereload==2.6.1 +Markdown==3.2.1 +MarkupSafe==1.1.1 +mkdocs==1.0.4 +mkdocs-material==4.6.3 +Pygments==2.5.2 +pymdown-extensions==6.3 pytz==2018.3 +PyYAML==5.3 +six==1.14.0 sqlparse==0.3.0 +tornado==6.0.3 urlman==1.2.0 From 8b55f8dc5f6bc6abe748e2f5a6bb2856c8dc5406 Mon Sep 17 00:00:00 2001 From: Baptiste Mispelon Date: Mon, 17 Feb 2020 13:15:55 +0100 Subject: [PATCH 04/44] Fixed typo in readme --- README.md | 2 +- docs/index.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 86a6cd2..362632d 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ class Article(LifecycleModel): send_email(self.editor.email, "An article has published!") ``` -Instead of overriding `save` and `__init___` in a clunky way that hurts readability: +Instead of overriding `save` and `__init__` in a clunky way that hurts readability: ```python # same class and field declarations as above ... diff --git a/docs/index.md b/docs/index.md index e889d7f..fd30729 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,7 +26,7 @@ class Article(LifecycleModel): send_email(self.editor.email, "An article has published!") ``` -Instead of overriding `save` and `__init___` in a clunky way that hurts readability: +Instead of overriding `save` and `__init__` in a clunky way that hurts readability: ```python # same class and field declarations as above ... From 6101d37d6926346e03c1c0186abc3e0479335591 Mon Sep 17 00:00:00 2001 From: Faisal Manzer Date: Thu, 19 Mar 2020 12:50:24 +0530 Subject: [PATCH 05/44] Added hooks names as variable for easy import and use --- README.md | 6 +++--- django_lifecycle/__init__.py | 1 + django_lifecycle/decorators.py | 14 ++------------ django_lifecycle/hooks.py | 23 +++++++++++++++++++++++ 4 files changed, 29 insertions(+), 15 deletions(-) create mode 100644 django_lifecycle/hooks.py diff --git a/README.md b/README.md index 86a6cd2..2813c63 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This project provides a `@hook` decorator as well as a base model and mixin to a In short, you can write model code like this: ```python -from django_lifecycle import LifecycleModel, hook +from django_lifecycle import LifecycleModel, hook, BEFORE_UPDATE, AFTER_UPDATE class Article(LifecycleModel): @@ -19,11 +19,11 @@ class Article(LifecycleModel): status = models.ChoiceField(choices=['draft', 'published']) editor = models.ForeignKey(AuthUser) - @hook('before_update', when='contents', has_changed=True) + @hook(BEFORE_UPDATE, when='contents', has_changed=True) def on_content_change(self): self.updated_at = timezone.now() - @hook('after_update', when="status", was="draft", is_now="published") + @hook(AFTER_UPDATE, when="status", was="draft", is_now="published") def on_publish(self): send_email(self.editor.email, "An article has published!") ``` diff --git a/django_lifecycle/__init__.py b/django_lifecycle/__init__.py index 1e0a73a..f7e2ee4 100644 --- a/django_lifecycle/__init__.py +++ b/django_lifecycle/__init__.py @@ -7,6 +7,7 @@ class NotSet(object): from .decorators import hook from .mixins import LifecycleModelMixin +from .hooks import * if IS_GTE_1_POINT_9: diff --git a/django_lifecycle/decorators.py b/django_lifecycle/decorators.py index a66fa1c..4722824 100644 --- a/django_lifecycle/decorators.py +++ b/django_lifecycle/decorators.py @@ -3,23 +3,13 @@ from django_lifecycle import NotSet +from .hooks import VALID_HOOKS + class DjangoLifeCycleException(Exception): pass -VALID_HOOKS = ( - "before_save", - "after_save", - "before_create", - "after_create", - "before_update", - "after_update", - "before_delete", - "after_delete", -) - - def _validate_hook_params(hook, when, when_any, has_changed): if hook not in VALID_HOOKS: raise DjangoLifeCycleException( diff --git a/django_lifecycle/hooks.py b/django_lifecycle/hooks.py new file mode 100644 index 0000000..3a70715 --- /dev/null +++ b/django_lifecycle/hooks.py @@ -0,0 +1,23 @@ +BEFORE_SAVE = "before_save" +AFTER_SAVE = "after_save" + +BEFORE_CREATE = "before_create" +AFTER_CREATE = "after_create" + +BEFORE_UPDATE = "before_update" +AFTER_UPDATE = "after_update" + +BEFORE_DELETE = "before_delete" +AFTER_DELETE = "after_delete" + + +VALID_HOOKS = ( + BEFORE_SAVE, + AFTER_SAVE, + BEFORE_CREATE, + AFTER_CREATE, + BEFORE_UPDATE, + AFTER_UPDATE, + BEFORE_DELETE, + AFTER_DELETE +) From 53ba6de1748770deb414c5faa37ed412b9a0a726 Mon Sep 17 00:00:00 2001 From: Tom Dyson Date: Mon, 6 Apr 2020 16:53:21 +0100 Subject: [PATCH 06/44] fix README typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 86a6cd2..b0a4cdb 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Package version](https://badge.fury.io/py/django-lifecycle.svg)](https://pypi.python.org/pypi/django-lifecycle) [![Python versions](https://img.shields.io/pypi/status/django-lifecycle.svg)](https://img.shields.io/pypi/status/django-lifecycle.svg/) -This project provides a `@hook` decorator as well as a base model and mixin to add lifecycle hooks to your Django models. Django's built-in approach to offering lifecycle hooks is [Signals](https://docs.djangoproject.com/en/dev/topics/signals/). However, my team often finds that Signals introduce unnesseary indirection and are at odds with Django's "fat models" approach. +This project provides a `@hook` decorator as well as a base model and mixin to add lifecycle hooks to your Django models. Django's built-in approach to offering lifecycle hooks is [Signals](https://docs.djangoproject.com/en/dev/topics/signals/). However, my team often finds that Signals introduce unnecessary indirection and are at odds with Django's "fat models" approach. **Django Lifecycle Hooks** supports Python 3.5, 3.6, and 3.7, Django 2.0.x, 2.1.x, 2.2.x. From 251bf2ac6db916b0258ca3579e46c42c194ca16f Mon Sep 17 00:00:00 2001 From: robertgsinger Date: Sat, 25 Apr 2020 15:58:40 -0500 Subject: [PATCH 07/44] Adds script folder --- pypi_submit.py | 4 ---- script/deploy | 6 ++++++ setup.py | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) delete mode 100644 pypi_submit.py create mode 100755 script/deploy diff --git a/pypi_submit.py b/pypi_submit.py deleted file mode 100644 index ea86ec2..0000000 --- a/pypi_submit.py +++ /dev/null @@ -1,4 +0,0 @@ -import os - -os.system("python setup.py sdist --verbose") -os.system("twine upload dist/*") diff --git a/script/deploy b/script/deploy new file mode 100755 index 0000000..5e016a2 --- /dev/null +++ b/script/deploy @@ -0,0 +1,6 @@ +#!/bin/sh +set -e + +mkdocs gh-deploy +python setup.py sdist --verbose +twine upload dist/* \ No newline at end of file diff --git a/setup.py b/setup.py index d556aad..b4e955f 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ def readme(): ] setup( name="django-lifecycle", - version="0.7.1", + version="0.7.5", description="Declarative model lifecycle hooks.", author="Robert Singer", author_email="robertgsinger@gmail.com", From 7a3d44cf182eef3a9bbd66fd8e598373f34ad712 Mon Sep 17 00:00:00 2001 From: robertgsinger Date: Sat, 25 Apr 2020 16:05:11 -0500 Subject: [PATCH 08/44] Updates readme changelog --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index e8044a2..810ab41 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,10 @@ Instead of overriding `save` and `__init__` in a clunky way that hurts readabili # Changelog +## 0.7.5 (April 2020) +* Adds static typed variables for hook names; thanks @Faisal-Manzer! +* Fixes some typos in docs; thanks @tomdyson and @bmispelon! + ## 0.7.1 (January 2020) * Fixes bug in `utils._get_field_names` that could cause recursion bug in some cases. From eb80f85c9b28bda142e59b71e422f6dea388e1aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Mon, 25 May 2020 00:00:14 +0300 Subject: [PATCH 09/44] docs - hardcoded hook moments replaced by constants --- docs/examples.md | 26 +++++++++++++------------- docs/fk_changes.md | 4 ++-- docs/hooks_and_conditions.md | 27 ++++++++++++++++----------- docs/index.md | 6 +++--- 4 files changed, 34 insertions(+), 29 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index 8a6d5f4..12f5633 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -7,7 +7,7 @@ For simple cases, you might always want something to happen at a certain point, When a user is first created, you could process a thumbnail image in the background and send the user an email: ```python - @hook('after_create') + @hook(AFTER_CREATE) def do_after_create_jobs(self): enqueue_job(process_thumbnail, self.picture_url) @@ -20,7 +20,7 @@ When a user is first created, you could process a thumbnail image in the backgro Or you want to email a user when their account is deleted. You could add the decorated method below: ```python - @hook('after_delete') + @hook(AFTER_DELETE) def email_deleted_user(self): mail.send_mail( 'We have deleted your account', 'We will miss you!.', @@ -35,7 +35,7 @@ Read on to see how to only fire the hooked method if certain conditions about th Maybe you only want the hooked method to run under certain circumstances related to the state of your model. If a model's `status` field change from `"active"` to `"banned"`, you may want to send an email to the user: ```python - @hook('after_update', when='status', was='active', is_now='banned') + @hook(AFTER_UPDATE, when='status', was='active', is_now='banned') def email_banned_user(self): mail.send_mail( 'You have been banned', 'You may or may not deserve it.', @@ -50,7 +50,7 @@ The `was` and `is_now` keyword arguments allow you to compare the model's state You can also enforce certain dissallowed transitions. For example, maybe you don't want your staff to be able to delete an active trial because they should expire instead: ```python - @hook('before_delete', when='has_trial', is_now=True) + @hook(BEFORE_DELETE, when='has_trial', is_now=True) def ensure_trial_not_active(self): raise CannotDeleteActiveTrial('Cannot delete trial user!') ``` @@ -62,7 +62,7 @@ We've ommitted the `was` keyword meaning that the initial state of the `has_tria You can pass the keyword argument `has_changed=True` to run the hooked method if a field has changed. ```python - @hook('before_update', when='address', has_changed=True) + @hook(BEFORE_UPDATE, when='address', has_changed=True) def timestamp_address_change(self): self.address_updated_at = timezone.now() ``` @@ -72,7 +72,7 @@ You can pass the keyword argument `has_changed=True` to run the hooked method if You can have a hooked method fire when a field's value IS NOT equal to a certain value. ```python - @hook('before_save', when='email', is_not=None) + @hook(BEFORE_SAVE, when='email', is_not=None) def lowercase_email(self): self.email = self.email.lower() ``` @@ -82,7 +82,7 @@ You can have a hooked method fire when a field's value IS NOT equal to a certain You can have a hooked method fire when a field's initial value was not equal to a specific value. ```python - @hook('before_save', when='status', was_not="rejected", is_now="published") + @hook(BEFORE_SAVE, when='status', was_not="rejected", is_now="published") def send_publish_alerts(self): send_mass_email() ``` @@ -93,7 +93,7 @@ You can have a hooked method fire when a field's initial value was not equal to but now is. ```python - @hook('before_save', when='status', changes_to="published") + @hook(BEFORE_SAVE, when='status', changes_to="published") def send_publish_alerts(self): send_mass_email() ``` @@ -102,7 +102,7 @@ Generally, `changes_to` is a shorthand for the situation when `was_not` and `is_ same value. The sample above is equal to: ```python - @hook('before_save', when='status', was_not="published", is_now="published") + @hook(BEFORE_SAVE, when='status', was_not="published", is_now="published") def send_publish_alerts(self): send_mass_email() ``` @@ -112,8 +112,8 @@ same value. The sample above is equal to: You can decorate the same method multiple times if you want to hook a method to multiple moments. ```python - @hook("after_update", when="published", has_changed=True) - @hook("after_create", when="type", has_changed=True) + @hook(AFTER_UPDATE, when="published", has_changed=True) + @hook(AFTER_CREATE, when="type", has_changed=True) def handle_update(self): # do something ``` @@ -123,7 +123,7 @@ You can decorate the same method multiple times if you want to hook a method to If you want to hook into the same moment, but base its conditions on multiple fields, you can use the `when_any` parameter. ```python - @hook('before_save', when_any=['status', 'type'], has_changed=True) + @hook(BEFORE_SAVE, when_any=['status', 'type'], has_changed=True) def do_something(self): # do something ``` @@ -133,7 +133,7 @@ If you want to hook into the same moment, but base its conditions on multiple fi If you need to hook into events with more complex conditions, you can take advantage of `has_changed` and `initial_value` [utility methods](advanced.md): ```python - @hook('after_update') + @hook(AFTER_UPDATE) def on_update(self): if self.has_changed('username') and not self.has_changed('password'): # do the thing here diff --git a/docs/fk_changes.md b/docs/fk_changes.md index fa5665d..8af7867 100644 --- a/docs/fk_changes.md +++ b/docs/fk_changes.md @@ -14,7 +14,7 @@ class UserAccount(LifecycleModel): email = models.CharField(max_length=600) employer = models.ForeignKey(Organization, on_delete=models.SET_NULL) - @hook("after_update", when="employer", has_changed=True) + @hook(AFTER_UPDATE, when="employer", has_changed=True) def notify_user_of_employer_change(self): mail.send_mail("Update", "You now work for someone else!", [self.email]) ``` @@ -34,7 +34,7 @@ class UserAccount(LifecycleModel): email = models.CharField(max_length=600) employer = models.ForeignKey(Organization, on_delete=models.SET_NULL) - @hook("after_update", when="employer.name", has_changed=True, is_now="Google") + @hook(AFTER_UPDATE, when="employer.name", has_changed=True, is_now="Google") def notify_user_of_google_buy_out(self): mail.send_mail("Update", "Google bought your employer!", ["to@example.com"],) ``` diff --git a/docs/hooks_and_conditions.md b/docs/hooks_and_conditions.md index 9212a5c..deca58a 100644 --- a/docs/hooks_and_conditions.md +++ b/docs/hooks_and_conditions.md @@ -1,6 +1,8 @@ # Available Hooks & Conditions -You can hook into one or more lifecycle moments by adding the `@hook` decorator to a model's method. The moment name is passed as the first positional argument, `@hook('before_create')`, and optional keyword arguments can be passed to set up conditions for when the method should fire. +You can hook into one or more lifecycle moments by adding the `@hook` decorator to a model's method. The moment name + is passed as the first positional argument, `@hook(BEFORE_CREATE)`, and optional keyword arguments can be passed to + set up conditions for when the method should fire. ## Decorator Signature @@ -21,16 +23,19 @@ You can hook into one or more lifecycle moments by adding the `@hook` decorator Below is a full list of hooks, in the same order in which they will get called during the respective operations: -| Hook name | When it fires | -|:-------------:|:-------------:| -| before_save | Immediately before `save` is called | -| after_save | Immediately after `save` is called -| before_create | Immediately before `save` is called, if `pk` is `None` | -| after_create | Immediately after `save` is called, if `pk` was initially `None` | -| before_update | Immediately before `save` is called, if `pk` is NOT `None` | -| after_update | Immediately after `save` is called, if `pk` was NOT `None` | -| before_delete | Immediately before `delete` is called | -| after_delete | Immediately after `delete` is called | +| Hook constant | Hook name | When it fires | +|:---------------:|:-------------:|:----------------| +| `BEFORE_SAVE` | before_save | Immediately before `save` is called | +| `AFTER_SAVE` | after_save | Immediately after `save` is called +| `BEFORE_CREATE` | before_create | Immediately before `save` is called, if `pk` is `None` | +| `AFTER_CREATE` | after_create | Immediately after `save` is called, if `pk` was initially `None` | +| `BEFORE_UPDATE` | before_update | Immediately before `save` is called, if `pk` is NOT `None` | +| `AFTER_UPDATE` | after_update | Immediately after `save` is called, if `pk` was NOT `None` | +| `BEFORE_DELETE` | before_delete | Immediately before `delete` is called | +| `AFTER_DELETE` | after_delete | Immediately after `delete` is called | + +All of hook constants are strings containing the specific hook name, for example `AFTER_UPDATE` is string + `"after_update"` - preferably way is to use hook constant. ## Condition Keyward Arguments diff --git a/docs/index.md b/docs/index.md index fd30729..958f6c4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,7 +8,7 @@ This project provides a `@hook` decorator as well as a base model and mixin to a In short, you can write model code like this: ```python -from django_lifecycle import LifecycleModel, hook +from django_lifecycle import LifecycleModel, hook, BEFORE_UPDATE, AFTER_UPDATE class Article(LifecycleModel): @@ -17,11 +17,11 @@ class Article(LifecycleModel): status = models.ChoiceField(choices=['draft', 'published']) editor = models.ForeignKey(AuthUser) - @hook('before_update', when='contents', has_changed=True) + @hook(BEFORE_UPDATE, when='contents', has_changed=True) def on_content_change(self): self.updated_at = timezone.now() - @hook('after_update', when="status", was="draft", is_now="published") + @hook(AFTER_UPDATE, when="status", was="draft", is_now="published") def on_publish(self): send_email(self.editor.email, "An article has published!") ``` From 6d7e5dff9d86cb8cd9fcad65e9aacd67ca25eb22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Mon, 25 May 2020 00:09:10 +0300 Subject: [PATCH 10/44] LifecycleModelMixin - replace hardcoded hook names by constants --- django_lifecycle/mixins.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/django_lifecycle/mixins.py b/django_lifecycle/mixins.py index c6d97b5..1d5d6c0 100644 --- a/django_lifecycle/mixins.py +++ b/django_lifecycle/mixins.py @@ -5,8 +5,13 @@ from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist from django.utils.functional import cached_property -from django_lifecycle import NotSet - +from . import NotSet +from .hooks import ( + BEFORE_CREATE, BEFORE_UPDATE, + BEFORE_SAVE, BEFORE_DELETE, + AFTER_CREATE, AFTER_UPDATE, + AFTER_SAVE, AFTER_DELETE, +) from .utils import get_unhookable_attribute_names @@ -115,25 +120,25 @@ def save(self, *args, **kwargs): is_new = self._state.adding if is_new: - self._run_hooked_methods("before_create") + self._run_hooked_methods(BEFORE_CREATE) else: - self._run_hooked_methods("before_update") + self._run_hooked_methods(BEFORE_UPDATE) - self._run_hooked_methods("before_save") + self._run_hooked_methods(BEFORE_SAVE) save(*args, **kwargs) - self._run_hooked_methods("after_save") + self._run_hooked_methods(AFTER_SAVE) if is_new: - self._run_hooked_methods("after_create") + self._run_hooked_methods(AFTER_CREATE) else: - self._run_hooked_methods("after_update") + self._run_hooked_methods(AFTER_UPDATE) self._initial_state = self._snapshot_state() def delete(self, *args, **kwargs): - self._run_hooked_methods("before_delete") + self._run_hooked_methods(BEFORE_DELETE) super().delete(*args, **kwargs) - self._run_hooked_methods("after_delete") + self._run_hooked_methods(AFTER_DELETE) @cached_property def _potentially_hooked_methods(self): From 16bb59b7d1869586b36bc5e21eee4462fb6d7889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Mon, 25 May 2020 01:18:57 +0300 Subject: [PATCH 11/44] Added support for Python 3.8 and Django 3.0 --- README.md | 2 +- requirements.txt | 2 +- setup.py | 1 + tox.ini | 4 +++- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 810ab41..87f3596 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ This project provides a `@hook` decorator as well as a base model and mixin to add lifecycle hooks to your Django models. Django's built-in approach to offering lifecycle hooks is [Signals](https://docs.djangoproject.com/en/dev/topics/signals/). However, my team often finds that Signals introduce unnecessary indirection and are at odds with Django's "fat models" approach. -**Django Lifecycle Hooks** supports Python 3.5, 3.6, and 3.7, Django 2.0.x, 2.1.x, 2.2.x. +**Django Lifecycle Hooks** supports Python 3.5, 3.6, 3.7 and 3.8, Django 2.0.x, 2.1.x, 2.2.x and 3.0.x. In short, you can write model code like this: diff --git a/requirements.txt b/requirements.txt index 63231c5..3f122da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ Click==7.0 -Django==2.2.10 +Django==3.0.6 djangorestframework==3.9.4 Jinja2==2.11.1 livereload==2.6.1 diff --git a/setup.py b/setup.py index b4e955f..8107193 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,7 @@ def readme(): "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", ] setup( name="django-lifecycle", diff --git a/tox.ini b/tox.ini index 6fb63a2..a85348c 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,8 @@ toxworkdir={env:TOXWORKDIR:{toxinidir}/.tox} envlist = {py35,py36,py37}-django20 {py35,py36,py37}-django21 - {py35,py36,py37}-django22 + {py36,py37,py38}-django22 + {py36,py37,py38}-django30 skip_missing_interpreters = True [testenv] @@ -16,3 +17,4 @@ deps = django20: django>=2.0,<2.1 django21: django>=2.1,<2.2 django22: django>=2.2,<3 + django30: django>=3.0,<3.1 From a77e05c3376707b06dc765911968ad5fa37b168c Mon Sep 17 00:00:00 2001 From: robertgsinger Date: Sun, 24 May 2020 17:51:26 -0500 Subject: [PATCH 12/44] Updates changelog --- README.md | 3 +++ setup.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 87f3596..77b76dd 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,9 @@ Instead of overriding `save` and `__init__` in a clunky way that hurts readabili # Changelog +## 0.7.6 (May 2020) +* Updates to use constants for hook names; updates docs to indicate Python 3.8/Django 3.x support. Thanks @thejoeejoee! + ## 0.7.5 (April 2020) * Adds static typed variables for hook names; thanks @Faisal-Manzer! * Fixes some typos in docs; thanks @tomdyson and @bmispelon! diff --git a/setup.py b/setup.py index 8107193..6cc0cff 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ def readme(): ] setup( name="django-lifecycle", - version="0.7.5", + version="0.7.6", description="Declarative model lifecycle hooks.", author="Robert Singer", author_email="robertgsinger@gmail.com", From ec2fc7aaf66a5b53626f64bf315244d2d2818974 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 6 Jun 2020 06:07:55 +0000 Subject: [PATCH 13/44] Bump django from 3.0.6 to 3.0.7 Bumps [django](https://github.com/django/django) from 3.0.6 to 3.0.7. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.0.6...3.0.7) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3f122da..be23687 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ Click==7.0 -Django==3.0.6 +Django==3.0.7 djangorestframework==3.9.4 Jinja2==2.11.1 livereload==2.6.1 From 5865643cd85be51b2c56135693ec5b26eb6b45a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Wed, 27 May 2020 01:51:12 +0300 Subject: [PATCH 14/44] Refactored @hook decorator to use hooks stored on classes, not on instances --- django_lifecycle/decorators.py | 90 ++++++++++++++---------- django_lifecycle/mixins.py | 44 ++++++++---- django_lifecycle/utils.py | 47 +++++++------ tests/testapp/tests/test_decorator.py | 4 +- tests/testapp/tests/test_user_account.py | 4 +- 5 files changed, 109 insertions(+), 80 deletions(-) diff --git a/django_lifecycle/decorators.py b/django_lifecycle/decorators.py index 4722824..96925e1 100644 --- a/django_lifecycle/decorators.py +++ b/django_lifecycle/decorators.py @@ -1,8 +1,7 @@ from functools import wraps from typing import List -from django_lifecycle import NotSet - +from . import NotSet from .hooks import VALID_HOOKS @@ -48,44 +47,57 @@ def _validate_hook_params(hook, when, when_any, has_changed): ) +class HookedMethod: + """ + Replacement for original method with stored information about registered hook. + """ + + def __init__(self, f, hook_spec): + self._f = f + self._hooked = f._hooked if isinstance(f, type(self)) else [] + # FIFO to respect order of @hook decorators definition + self._hooked.append(hook_spec) + self.__name__ = f.__name__ + + def __get__(self, instance, owner): + """ + Getter descriptor for access directly from class -> ModelA.. + Used in LifecycleModelMixin._potentially_hooked_methods for detection hooked methods. + """ + if not instance: + return self + return self._f + + def __call__(self, *args, **kwargs): + """ + Calling directly as model_instance.hooked_method(). + """ + return self._f(*args, **kwargs) + + def hook( - hook: str, - when: str = None, - when_any: List[str] = None, - was="*", - is_now="*", - has_changed: bool = None, - is_not=NotSet, - was_not=NotSet, - changes_to=NotSet, + hook: str, + when: str = None, + when_any: List[str] = None, + was="*", + is_now="*", + has_changed: bool = None, + is_not=NotSet, + was_not=NotSet, + changes_to=NotSet, ): _validate_hook_params(hook, when, when_any, has_changed) - def decorator(hooked_method): - if not hasattr(hooked_method, "_hooked"): - - @wraps(hooked_method) - def func(*args, **kwargs): - hooked_method(*args, **kwargs) - - func._hooked = [] - else: - func = hooked_method - - func._hooked.append( - { - "hook": hook, - "when": when, - "when_any": when_any, - "has_changed": has_changed, - "is_now": is_now, - "is_not": is_not, - "was": was, - "was_not": was_not, - "changes_to": changes_to, - } - ) - - return func - - return decorator + hook_spec = { + "hook": hook, + "when": when, + "when_any": when_any, + "has_changed": has_changed, + "is_now": is_now, + "is_not": is_not, + "was": was, + "was_not": was_not, + "changes_to": changes_to, + } + + return lambda fnc: wraps(fnc)(HookedMethod(fnc, hook_spec)) diff --git a/django_lifecycle/mixins.py b/django_lifecycle/mixins.py index 1d5d6c0..c9b10aa 100644 --- a/django_lifecycle/mixins.py +++ b/django_lifecycle/mixins.py @@ -1,11 +1,13 @@ -from functools import reduce -from inspect import ismethod +from functools import reduce, wraps from typing import Any, List from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist +from django.db.models.base import ModelBase +from django.utils.decorators import classproperty from django.utils.functional import cached_property from . import NotSet +from .decorators import HookedMethod from .hooks import ( BEFORE_CREATE, BEFORE_UPDATE, BEFORE_SAVE, BEFORE_DELETE, @@ -140,17 +142,29 @@ def delete(self, *args, **kwargs): super().delete(*args, **kwargs) self._run_hooked_methods(AFTER_DELETE) - @cached_property - def _potentially_hooked_methods(self): - skip = set(get_unhookable_attribute_names(self)) - collected = [] + # TODO: cache it on class + @classproperty + def _potentially_hooked_methods(cls) -> List[HookedMethod]: + skip = set(get_unhookable_attribute_names(cls)) + + # really important to skip _potentially_hooked_methods to avoid recursion + skip |= set(dir(LifecycleModelMixin)) + + # collect attributes from models: + # * from classes in MRO line + # * including only instances of Django meta class ModelBase (so no object and no mixins) + # * excluding last in line, which is django Model itself + to_dir = [set(dir(cls)) for cls in cls.mro() if isinstance(cls, ModelBase)][:-1] - for name in dir(self): - if name in skip: - continue + possible_names = set() + for parent_dir in to_dir: + possible_names |= parent_dir + + collected = [] + for name in possible_names - skip: try: - attr = getattr(self, name) - if ismethod(attr) and hasattr(attr, "_hooked"): + attr = getattr(cls, name) + if isinstance(attr, HookedMethod): collected.append(attr) except AttributeError: pass @@ -185,7 +199,7 @@ def _run_hooked_methods(self, hook: str) -> List[str]: """ fired = [] - for method in self._potentially_hooked_methods: + for method in self._potentially_hooked_methods: # type: HookedMethod for callback_specs in method._hooked: if callback_specs["hook"] != hook: continue @@ -196,15 +210,15 @@ def _run_hooked_methods(self, hook: str) -> List[str]: if when_field: if self._check_callback_conditions(when_field, callback_specs): fired.append(method.__name__) - method() + method(self) elif when_any_field: for field_name in when_any_field: if self._check_callback_conditions(field_name, callback_specs): fired.append(method.__name__) - method() + method(self) else: fired.append(method.__name__) - method() + method(self) return fired diff --git a/django_lifecycle/utils.py b/django_lifecycle/utils.py index 72cb0e9..4dbd519 100644 --- a/django_lifecycle/utils.py +++ b/django_lifecycle/utils.py @@ -1,24 +1,25 @@ -from typing import List +from typing import Set +from django.db.models.base import ModelBase from django.utils.functional import cached_property from .django_info import DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES -def _get_model_property_names(instance) -> List[str]: +def _get_model_property_names(klass: ModelBase) -> Set[str]: """ Gather up properties and cached_properties which may be methods that were decorated. Need to inspect class versions b/c doing getattr on them could cause unwanted side effects. """ - property_names = [] + property_names = set() - for name in dir(instance): + for name in dir(klass): try: - attr = getattr(type(instance), name) + attr = getattr(type(klass), name) if isinstance(attr, property) or isinstance(attr, cached_property): - property_names.append(name) + property_names.add(name) except AttributeError: pass @@ -26,7 +27,7 @@ def _get_model_property_names(instance) -> List[str]: return property_names -def _get_model_descriptor_names(instance) -> List[str]: +def _get_model_descriptor_names(klass: ModelBase) -> Set[str]: """ Attributes which are Django descriptors. These represent a field which is a one-to-many or many-to-many relationship that is @@ -34,36 +35,38 @@ def _get_model_descriptor_names(instance) -> List[str]: as a field on this model. """ - descriptor_names = [] + descriptor_names = set() - for name in dir(instance): + for name in dir(klass): try: - attr = getattr(type(instance), name) + attr = getattr(type(klass), name) if isinstance(attr, DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES): - descriptor_names.append(name) + descriptor_names.add(name) except AttributeError: pass return descriptor_names -def _get_field_names(instance) -> List[str]: - names = [] +def _get_field_names(klass: ModelBase) -> Set[str]: + names = set() - for f in instance._meta.get_fields(): - names.append(f.name) + for f in klass._meta.get_fields(): + names.add(f.name) - if instance._meta.get_field(f.name).get_internal_type() == "ForeignKey": - names.append(f.name + "_id") + if klass._meta.get_field(f.name).get_internal_type() == "ForeignKey": + # TODO: not robust for cases with custom Field(db_column=...) definition + names.add(f.name + "_id") return names -def get_unhookable_attribute_names(instance) -> List[str]: +def get_unhookable_attribute_names(klass) -> Set[str]: + from . import LifecycleModelMixin return ( - _get_field_names(instance) - + _get_model_descriptor_names(instance) - + _get_model_property_names(instance) - + ["_run_hooked_methods"] + _get_field_names(klass) | + _get_model_descriptor_names(klass) | + _get_model_property_names(klass) | + {'MultipleObjectsReturned', 'DoesNotExist'} ) diff --git a/tests/testapp/tests/test_decorator.py b/tests/testapp/tests/test_decorator.py index 5c55de5..6b89fa1 100644 --- a/tests/testapp/tests/test_decorator.py +++ b/tests/testapp/tests/test_decorator.py @@ -16,5 +16,5 @@ def one_hook(self): pass instance = FakeModel() - self.assertEqual(len(instance.multiple_hooks._hooked), 2) - self.assertEqual(len(instance.one_hook._hooked), 1) + self.assertEqual(len(type(instance).multiple_hooks._hooked), 2) + self.assertEqual(len(type(instance).one_hook._hooked), 1) diff --git a/tests/testapp/tests/test_user_account.py b/tests/testapp/tests/test_user_account.py index c8b6afa..2eb4949 100644 --- a/tests/testapp/tests/test_user_account.py +++ b/tests/testapp/tests/test_user_account.py @@ -97,9 +97,9 @@ def test_additional_notify_sent_for_specific_org_name_change(self): account.save() self.assertEqual(len(mail.outbox), 2) self.assertEqual( - mail.outbox[0].subject, "The name of your organization has changed!" + {mail.outbox[0].subject, mail.outbox[1].subject}, + {"The name of your organization has changed!", "You were moved to our online school!"} ) - self.assertEqual(mail.outbox[1].subject, "You were moved to our online school!") def test_email_user_about_name_change(self): account = UserAccount.objects.create(**self.stub_data) From 9fbebab3360a253cd8ebc6227e91cf59eff09578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Wed, 27 May 2020 02:20:51 +0300 Subject: [PATCH 15/44] Mixin._potentially_hooked_methods is now cached on class, not on each instance --- django_lifecycle/mixins.py | 33 +++++++++------------------------ django_lifecycle/utils.py | 17 ++++++++++++++++- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/django_lifecycle/mixins.py b/django_lifecycle/mixins.py index c9b10aa..09214b3 100644 --- a/django_lifecycle/mixins.py +++ b/django_lifecycle/mixins.py @@ -1,10 +1,8 @@ -from functools import reduce, wraps +from functools import reduce from typing import Any, List from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist from django.db.models.base import ModelBase -from django.utils.decorators import classproperty -from django.utils.functional import cached_property from . import NotSet from .decorators import HookedMethod @@ -14,7 +12,7 @@ AFTER_CREATE, AFTER_UPDATE, AFTER_SAVE, AFTER_DELETE, ) -from .utils import get_unhookable_attribute_names +from .utils import get_unhookable_attribute_names, cached_class_property class LifecycleModelMixin(object): @@ -31,15 +29,9 @@ def _snapshot_state(self): if "_state" in state: del state["_state"] - if "_potentially_hooked_methods" in state: - del state["_potentially_hooked_methods"] - if "_initial_state" in state: del state["_initial_state"] - if "_watched_fk_model_fields" in state: - del state["_watched_fk_model_fields"] - return state @property @@ -67,9 +59,9 @@ def _sanitize_field_name(self, field_name: str) -> str: def _current_value(self, field_name: str) -> Any: if "." in field_name: - def getitem(obj, field_name: str): + def getitem(obj, name: str): try: - return getattr(obj, field_name) + return getattr(obj, name) except (AttributeError, ObjectDoesNotExist): return None @@ -83,10 +75,7 @@ def initial_value(self, field_name: str) -> Any: """ field_name = self._sanitize_field_name(field_name) - if field_name in self._initial_state: - return self._initial_state[field_name] - - return None + return self._initial_state.get(field_name, None) def has_changed(self, field_name: str) -> bool: """ @@ -95,10 +84,7 @@ def has_changed(self, field_name: str) -> bool: changed = self._diff_with_initial.keys() field_name = self._sanitize_field_name(field_name) - if field_name in changed: - return True - - return False + return field_name in changed def _clear_watched_fk_model_cache(self): """ @@ -142,8 +128,7 @@ def delete(self, *args, **kwargs): super().delete(*args, **kwargs) self._run_hooked_methods(AFTER_DELETE) - # TODO: cache it on class - @classproperty + @cached_class_property def _potentially_hooked_methods(cls) -> List[HookedMethod]: skip = set(get_unhookable_attribute_names(cls)) @@ -171,7 +156,7 @@ def _potentially_hooked_methods(cls) -> List[HookedMethod]: return collected - @cached_property + @cached_class_property def _watched_fk_model_fields(self) -> List[str]: """ Gather up all field names (values in 'when' key) that correspond to @@ -187,7 +172,7 @@ def _watched_fk_model_fields(self) -> List[str]: return watched - @cached_property + @cached_class_property def _watched_fk_models(self) -> List[str]: return [_.split(".")[0] for _ in self._watched_fk_model_fields] diff --git a/django_lifecycle/utils.py b/django_lifecycle/utils.py index 4dbd519..d727ed3 100644 --- a/django_lifecycle/utils.py +++ b/django_lifecycle/utils.py @@ -1,3 +1,4 @@ +from functools import wraps from typing import Set from django.db.models.base import ModelBase @@ -63,10 +64,24 @@ def _get_field_names(klass: ModelBase) -> Set[str]: def get_unhookable_attribute_names(klass) -> Set[str]: - from . import LifecycleModelMixin return ( _get_field_names(klass) | _get_model_descriptor_names(klass) | _get_model_property_names(klass) | {'MultipleObjectsReturned', 'DoesNotExist'} ) + + +def cached_class_property(getter): + class CachedClassProperty: + def __init__(self, f): + self._f = f + self._name = f.__name__ + '__' + CachedClassProperty.__name__ + + @wraps(getter) + def __get__(self, instance, cls): + if not hasattr(cls, self._name): + setattr(cls, self._name, self._f(cls)) + return getattr(cls, self._name) + + return CachedClassProperty(getter) From e876c7c349b579b058659305b25e8897a3d1c43c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Wed, 27 May 2020 02:51:50 +0300 Subject: [PATCH 16/44] Fix parameter name in hooks lookup methods --- django_lifecycle/mixins.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/django_lifecycle/mixins.py b/django_lifecycle/mixins.py index 09214b3..e5da2c5 100644 --- a/django_lifecycle/mixins.py +++ b/django_lifecycle/mixins.py @@ -157,7 +157,7 @@ def _potentially_hooked_methods(cls) -> List[HookedMethod]: return collected @cached_class_property - def _watched_fk_model_fields(self) -> List[str]: + def _watched_fk_model_fields(cls) -> List[str]: """ Gather up all field names (values in 'when' key) that correspond to field names on FK-related models. These will be strings that contain @@ -165,7 +165,7 @@ def _watched_fk_model_fields(self) -> List[str]: """ watched = [] # List[str] - for method in self._potentially_hooked_methods: + for method in cls._potentially_hooked_methods: for specs in method._hooked: if specs["when"] is not None and "." in specs["when"]: watched.append(specs["when"]) @@ -173,8 +173,8 @@ def _watched_fk_model_fields(self) -> List[str]: return watched @cached_class_property - def _watched_fk_models(self) -> List[str]: - return [_.split(".")[0] for _ in self._watched_fk_model_fields] + def _watched_fk_models(cls) -> List[str]: + return [_.split(".")[0] for _ in cls._watched_fk_model_fields] def _run_hooked_methods(self, hook: str) -> List[str]: """ From 20f02514dc8ba233d17fe1438a466a7963117e7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Wed, 27 May 2020 03:35:57 +0300 Subject: [PATCH 17/44] Added test for builtin_cached_property --- tests/testapp/models.py | 9 +++++++-- tests/testapp/tests/test_mixin.py | 19 +++++++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/tests/testapp/models.py b/tests/testapp/models.py index 9d5a451..7b071ed 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -3,7 +3,8 @@ from django.utils import timezone from django.core import mail from django.db import models -from django.utils.functional import cached_property +from django.utils.functional import cached_property as django_cached_property +from functools import cached_property as builtin_cached_property from django_lifecycle import hook from django_lifecycle.models import LifecycleModel @@ -119,10 +120,14 @@ def email_user_about_name_change(self): ["to@example.com"], ) - @cached_property + @django_cached_property def full_name(self): return self.first_name + " " + self.last_name + @builtin_cached_property + def full_name_with_email(self): + return self.first_name + " " + self.last_name + " (" + (self.email or '') + ")" + class Locale(models.Model): code = models.CharField(max_length=20) diff --git a/tests/testapp/tests/test_mixin.py b/tests/testapp/tests/test_mixin.py index 9527728..4f8f869 100644 --- a/tests/testapp/tests/test_mixin.py +++ b/tests/testapp/tests/test_mixin.py @@ -324,9 +324,9 @@ def test_changes_to_condition_should_not_pass(self): user_account.last_name = "Bouvier" user_account.save() # `CannotRename` exception is not raised - def test_should_not_call_cached_property(self): + def test_should_not_call_django_cached_property(self): """ - full_name is cached_property. Accessing _potentially_hooked_methods + full_name is cached_property (Django). Accessing _potentially_hooked_methods should not call it incidentally. """ data = self.stub_data @@ -338,6 +338,21 @@ def test_should_not_call_cached_property(self): # Should be first time this property is accessed... self.assertEqual(account.full_name, "Bartholomew Simpson") + def test_should_not_call_builtin_cached_property(self): + """ + full_name is cached_property. Accessing _potentially_hooked_methods + should not call it incidentally. + """ + data = self.stub_data + data["first_name"] = "Bart" + data["last_name"] = "Simpson" + data["email"] = "bart@simpson.com" + account = UserAccount.objects.create(**data) + account._potentially_hooked_methods + account.first_name = "Bartholomew" + # Should be first time this property is accessed... + self.assertEqual(account.full_name_with_email, "Bartholomew Simpson (bart@simpson.com)") + def test_comparison_state_should_reset_after_save(self): data = self.stub_data data["first_name"] = "Marge" From 6dd60e9da784688967b2876841d4e68409be67b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Wed, 27 May 2020 03:44:12 +0300 Subject: [PATCH 18/44] Removed _get_model_property_names Properties cannot be instance of HookedMethod, so they are excluded by-default --- django_lifecycle/utils.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/django_lifecycle/utils.py b/django_lifecycle/utils.py index d727ed3..ef1d2d1 100644 --- a/django_lifecycle/utils.py +++ b/django_lifecycle/utils.py @@ -2,32 +2,10 @@ from typing import Set from django.db.models.base import ModelBase -from django.utils.functional import cached_property from .django_info import DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES -def _get_model_property_names(klass: ModelBase) -> Set[str]: - """ - Gather up properties and cached_properties which may be methods - that were decorated. Need to inspect class versions b/c doing - getattr on them could cause unwanted side effects. - """ - property_names = set() - - for name in dir(klass): - try: - attr = getattr(type(klass), name) - - if isinstance(attr, property) or isinstance(attr, cached_property): - property_names.add(name) - - except AttributeError: - pass - - return property_names - - def _get_model_descriptor_names(klass: ModelBase) -> Set[str]: """ Attributes which are Django descriptors. These represent a field @@ -67,7 +45,6 @@ def get_unhookable_attribute_names(klass) -> Set[str]: return ( _get_field_names(klass) | _get_model_descriptor_names(klass) | - _get_model_property_names(klass) | {'MultipleObjectsReturned', 'DoesNotExist'} ) From 741fdaa71ee301202947c936b97c92b5c4d1a8f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Mon, 8 Jun 2020 01:46:23 +0300 Subject: [PATCH 19/44] Simplified getting possible hookable attributes from model class --- django_lifecycle/mixins.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/django_lifecycle/mixins.py b/django_lifecycle/mixins.py index e5da2c5..0c708cc 100644 --- a/django_lifecycle/mixins.py +++ b/django_lifecycle/mixins.py @@ -2,7 +2,6 @@ from typing import Any, List from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist -from django.db.models.base import ModelBase from . import NotSet from .decorators import HookedMethod @@ -135,15 +134,8 @@ def _potentially_hooked_methods(cls) -> List[HookedMethod]: # really important to skip _potentially_hooked_methods to avoid recursion skip |= set(dir(LifecycleModelMixin)) - # collect attributes from models: - # * from classes in MRO line - # * including only instances of Django meta class ModelBase (so no object and no mixins) - # * excluding last in line, which is django Model itself - to_dir = [set(dir(cls)) for cls in cls.mro() if isinstance(cls, ModelBase)][:-1] - - possible_names = set() - for parent_dir in to_dir: - possible_names |= parent_dir + # collect all possible hooked attrs from class + possible_names = set(dir(cls)) collected = [] for name in possible_names - skip: From 5931d97930cc8ed1c68de50b33b9424dfa0d954b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Mon, 8 Jun 2020 02:06:07 +0300 Subject: [PATCH 20/44] Removed _get_model_descriptor_names Reverse descriptors for related django fields are now skipped by isinstance(..., HookedMethod), so this function is useless now. --- django_lifecycle/utils.py | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/django_lifecycle/utils.py b/django_lifecycle/utils.py index ef1d2d1..4223c1a 100644 --- a/django_lifecycle/utils.py +++ b/django_lifecycle/utils.py @@ -3,30 +3,6 @@ from django.db.models.base import ModelBase -from .django_info import DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES - - -def _get_model_descriptor_names(klass: ModelBase) -> Set[str]: - """ - Attributes which are Django descriptors. These represent a field - which is a one-to-many or many-to-many relationship that is - potentially defined in another model, and doesn't otherwise appear - as a field on this model. - """ - - descriptor_names = set() - - for name in dir(klass): - try: - attr = getattr(type(klass), name) - - if isinstance(attr, DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES): - descriptor_names.add(name) - except AttributeError: - pass - - return descriptor_names - def _get_field_names(klass: ModelBase) -> Set[str]: names = set() @@ -44,7 +20,6 @@ def _get_field_names(klass: ModelBase) -> Set[str]: def get_unhookable_attribute_names(klass) -> Set[str]: return ( _get_field_names(klass) | - _get_model_descriptor_names(klass) | {'MultipleObjectsReturned', 'DoesNotExist'} ) From e160325caa29fc182e3124baca056f70afc1ab67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Mon, 8 Jun 2020 02:06:58 +0300 Subject: [PATCH 21/44] Deprecated subpackage django_info since has no usage in library --- django_lifecycle/__init__.py | 9 ++++++--- django_lifecycle/django_info.py | 4 +++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/django_lifecycle/__init__.py b/django_lifecycle/__init__.py index f7e2ee4..5e0a6a6 100644 --- a/django_lifecycle/__init__.py +++ b/django_lifecycle/__init__.py @@ -1,4 +1,8 @@ -from .django_info import IS_GTE_1_POINT_9 +from distutils.version import StrictVersion + +import django + +IS_DJANGO_GTE_1_POINT_9 = StrictVersion(django.__version__) >= StrictVersion("1.9") class NotSet(object): @@ -9,6 +13,5 @@ class NotSet(object): from .mixins import LifecycleModelMixin from .hooks import * - -if IS_GTE_1_POINT_9: +if IS_DJANGO_GTE_1_POINT_9: from .models import LifecycleModel diff --git a/django_lifecycle/django_info.py b/django_lifecycle/django_info.py index 0ad5675..4b87df7 100644 --- a/django_lifecycle/django_info.py +++ b/django_lifecycle/django_info.py @@ -1,7 +1,10 @@ +import warnings from distutils.version import StrictVersion import django +warnings.warn("Module django_info is unused by django_lifecycle itself and will be removed.", DeprecationWarning) + DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES = [] if StrictVersion(django.__version__) < StrictVersion("1.9"): @@ -45,6 +48,5 @@ DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES.extend([ForwardOneToOneDescriptor]) - DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES = tuple(DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES) IS_GTE_1_POINT_9 = StrictVersion(django.__version__) >= StrictVersion("1.9") From ad6fc500404ea85c595e8be632531c2b8536013a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Mon, 8 Jun 2020 02:23:49 +0300 Subject: [PATCH 22/44] advanced.md - replaced hardcoded hook name, fixed syntax of example code --- docs/advanced.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/advanced.md b/docs/advanced.md index 269c7c3..b4536b4 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -13,8 +13,7 @@ These are available on your model instance when the mixin or extend the base mod You can use these methods for more advanced checks, for example: ```python -from datetime import timedelta -from django_lifecycle import LifecycleModel +from django_lifecycle import LifecycleModel, AFTER_UPDATE, hook class UserAccount(LifecycleModel): @@ -23,9 +22,9 @@ class UserAccount(LifecycleModel): email = models.CharField(max_length=100) marietal_status = models.CharField(max_length=100) - @hook("after_update") + @hook(AFTER_UPDATE) def on_name_change_heck_on_marietal_status(self): - if self.has_changed('last_name') and not self.has_changed('marietal_status) + if self.has_changed('last_name') and not self.has_changed('marietal_status'): send_mail(to=self.email, "Has your marietal status changed recently?") ``` From f931e371ef715c12f4f955c55f5d4685bc907111 Mon Sep 17 00:00:00 2001 From: Udit <23015406+udit-001@users.noreply.github.com> Date: Sun, 14 Jun 2020 12:05:25 +0530 Subject: [PATCH 23/44] Fixes spelling errors in docs. --- docs/advanced.md | 8 ++++---- docs/examples.md | 6 +++--- docs/hooks_and_conditions.md | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/advanced.md b/docs/advanced.md index 269c7c3..e0c2a04 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -21,12 +21,12 @@ class UserAccount(LifecycleModel): first_name = models.CharField(max_length=100) last_name = models.CharField(max_length=100) email = models.CharField(max_length=100) - marietal_status = models.CharField(max_length=100) + marital_status = models.CharField(max_length=100) @hook("after_update") - def on_name_change_heck_on_marietal_status(self): - if self.has_changed('last_name') and not self.has_changed('marietal_status) - send_mail(to=self.email, "Has your marietal status changed recently?") + def on_name_change_heck_on_marital_status(self): + if self.has_changed('last_name') and not self.has_changed('marital_status) + send_mail(to=self.email, "Has your marital status changed recently?") ``` diff --git a/docs/examples.md b/docs/examples.md index 12f5633..53ce527 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -30,7 +30,7 @@ Or you want to email a user when their account is deleted. You could add the dec Read on to see how to only fire the hooked method if certain conditions about the model's current and previous state are met. -## Transitions btwn specific values +## Transitions between specific values Maybe you only want the hooked method to run under certain circumstances related to the state of your model. If a model's `status` field change from `"active"` to `"banned"`, you may want to send an email to the user: @@ -47,7 +47,7 @@ The `was` and `is_now` keyword arguments allow you to compare the model's state ## Preventing state transitions -You can also enforce certain dissallowed transitions. For example, maybe you don't want your staff to be able to delete an active trial because they should expire instead: +You can also enforce certain disallowed transitions. For example, maybe you don't want your staff to be able to delete an active trial because they should expire instead: ```python @hook(BEFORE_DELETE, when='has_trial', is_now=True) @@ -55,7 +55,7 @@ You can also enforce certain dissallowed transitions. For example, maybe you don raise CannotDeleteActiveTrial('Cannot delete trial user!') ``` -We've ommitted the `was` keyword meaning that the initial state of the `has_trial` field can be any value ("*"). +We've omitted the `was` keyword meaning that the initial state of the `has_trial` field can be any value ("*"). ## Any change to a field diff --git a/docs/hooks_and_conditions.md b/docs/hooks_and_conditions.md index deca58a..eee9656 100644 --- a/docs/hooks_and_conditions.md +++ b/docs/hooks_and_conditions.md @@ -38,11 +38,11 @@ All of hook constants are strings containing the specific hook name, for example `"after_update"` - preferably way is to use hook constant. -## Condition Keyward Arguments +## Condition Keyword Arguments If you do not use any conditional parameters, the hook will fire every time the lifecycle moment occurs. You can use the keyword arguments below to conditionally fire the method depending on the initial or current state of a model instance's fields. -| Keywarg arg | Type | Details | +| Keyword arg | Type | Details | |:-------------:|:-------------:|:-------------:| | when | str | The name of the field that you want to check against; required for the conditions below to be checked. Use the name of a FK field to watch changes to the related model *reference* or use dot-notation to watch changes to the *values* of fields on related models, e.g. `"organization.name"`. But [please be aware](fk_changes.md#fk-hook-warning) of potential performance drawbacks. | | when_any | List[str] | Similar to the `when` parameter, but takes a list of field names. The hooked method will fire if any of the corresponding fields meet the keyword conditions. Useful if you don't like stacking decorators. | From 7944c30fe2499b2df4eef17cb4b05e273e1ffad4 Mon Sep 17 00:00:00 2001 From: Udit <23015406+udit-001@users.noreply.github.com> Date: Sun, 14 Jun 2020 12:08:04 +0530 Subject: [PATCH 24/44] Fixes typo in method name. --- docs/advanced.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/advanced.md b/docs/advanced.md index e0c2a04..364c174 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -24,7 +24,7 @@ class UserAccount(LifecycleModel): marital_status = models.CharField(max_length=100) @hook("after_update") - def on_name_change_heck_on_marital_status(self): + def on_name_change_check_on_marital_status(self): if self.has_changed('last_name') and not self.has_changed('marital_status) send_mail(to=self.email, "Has your marital status changed recently?") ``` From b5e4522dbff1aab136efe4111d32ed10ad71274c Mon Sep 17 00:00:00 2001 From: Robert Singer Date: Sun, 28 Jun 2020 12:07:29 -0500 Subject: [PATCH 25/44] Revert "Feature: hooked methods cached on class" --- django_lifecycle/__init__.py | 9 +-- django_lifecycle/decorators.py | 90 ++++++++++-------------- django_lifecycle/django_info.py | 4 +- django_lifecycle/mixins.py | 67 ++++++++++-------- django_lifecycle/utils.py | 84 +++++++++++++++------- tests/testapp/models.py | 9 +-- tests/testapp/tests/test_decorator.py | 4 +- tests/testapp/tests/test_mixin.py | 19 +---- tests/testapp/tests/test_user_account.py | 4 +- 9 files changed, 146 insertions(+), 144 deletions(-) diff --git a/django_lifecycle/__init__.py b/django_lifecycle/__init__.py index 5e0a6a6..f7e2ee4 100644 --- a/django_lifecycle/__init__.py +++ b/django_lifecycle/__init__.py @@ -1,8 +1,4 @@ -from distutils.version import StrictVersion - -import django - -IS_DJANGO_GTE_1_POINT_9 = StrictVersion(django.__version__) >= StrictVersion("1.9") +from .django_info import IS_GTE_1_POINT_9 class NotSet(object): @@ -13,5 +9,6 @@ class NotSet(object): from .mixins import LifecycleModelMixin from .hooks import * -if IS_DJANGO_GTE_1_POINT_9: + +if IS_GTE_1_POINT_9: from .models import LifecycleModel diff --git a/django_lifecycle/decorators.py b/django_lifecycle/decorators.py index 96925e1..4722824 100644 --- a/django_lifecycle/decorators.py +++ b/django_lifecycle/decorators.py @@ -1,7 +1,8 @@ from functools import wraps from typing import List -from . import NotSet +from django_lifecycle import NotSet + from .hooks import VALID_HOOKS @@ -47,57 +48,44 @@ def _validate_hook_params(hook, when, when_any, has_changed): ) -class HookedMethod: - """ - Replacement for original method with stored information about registered hook. - """ - - def __init__(self, f, hook_spec): - self._f = f - self._hooked = f._hooked if isinstance(f, type(self)) else [] - # FIFO to respect order of @hook decorators definition - self._hooked.append(hook_spec) - self.__name__ = f.__name__ - - def __get__(self, instance, owner): - """ - Getter descriptor for access directly from class -> ModelA.. - Used in LifecycleModelMixin._potentially_hooked_methods for detection hooked methods. - """ - if not instance: - return self - return self._f - - def __call__(self, *args, **kwargs): - """ - Calling directly as model_instance.hooked_method(). - """ - return self._f(*args, **kwargs) - - def hook( - hook: str, - when: str = None, - when_any: List[str] = None, - was="*", - is_now="*", - has_changed: bool = None, - is_not=NotSet, - was_not=NotSet, - changes_to=NotSet, + hook: str, + when: str = None, + when_any: List[str] = None, + was="*", + is_now="*", + has_changed: bool = None, + is_not=NotSet, + was_not=NotSet, + changes_to=NotSet, ): _validate_hook_params(hook, when, when_any, has_changed) - hook_spec = { - "hook": hook, - "when": when, - "when_any": when_any, - "has_changed": has_changed, - "is_now": is_now, - "is_not": is_not, - "was": was, - "was_not": was_not, - "changes_to": changes_to, - } - - return lambda fnc: wraps(fnc)(HookedMethod(fnc, hook_spec)) + def decorator(hooked_method): + if not hasattr(hooked_method, "_hooked"): + + @wraps(hooked_method) + def func(*args, **kwargs): + hooked_method(*args, **kwargs) + + func._hooked = [] + else: + func = hooked_method + + func._hooked.append( + { + "hook": hook, + "when": when, + "when_any": when_any, + "has_changed": has_changed, + "is_now": is_now, + "is_not": is_not, + "was": was, + "was_not": was_not, + "changes_to": changes_to, + } + ) + + return func + + return decorator diff --git a/django_lifecycle/django_info.py b/django_lifecycle/django_info.py index 4b87df7..0ad5675 100644 --- a/django_lifecycle/django_info.py +++ b/django_lifecycle/django_info.py @@ -1,10 +1,7 @@ -import warnings from distutils.version import StrictVersion import django -warnings.warn("Module django_info is unused by django_lifecycle itself and will be removed.", DeprecationWarning) - DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES = [] if StrictVersion(django.__version__) < StrictVersion("1.9"): @@ -48,5 +45,6 @@ DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES.extend([ForwardOneToOneDescriptor]) + DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES = tuple(DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES) IS_GTE_1_POINT_9 = StrictVersion(django.__version__) >= StrictVersion("1.9") diff --git a/django_lifecycle/mixins.py b/django_lifecycle/mixins.py index 0c708cc..1d5d6c0 100644 --- a/django_lifecycle/mixins.py +++ b/django_lifecycle/mixins.py @@ -1,17 +1,18 @@ from functools import reduce +from inspect import ismethod from typing import Any, List from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist +from django.utils.functional import cached_property from . import NotSet -from .decorators import HookedMethod from .hooks import ( BEFORE_CREATE, BEFORE_UPDATE, BEFORE_SAVE, BEFORE_DELETE, AFTER_CREATE, AFTER_UPDATE, AFTER_SAVE, AFTER_DELETE, ) -from .utils import get_unhookable_attribute_names, cached_class_property +from .utils import get_unhookable_attribute_names class LifecycleModelMixin(object): @@ -28,9 +29,15 @@ def _snapshot_state(self): if "_state" in state: del state["_state"] + if "_potentially_hooked_methods" in state: + del state["_potentially_hooked_methods"] + if "_initial_state" in state: del state["_initial_state"] + if "_watched_fk_model_fields" in state: + del state["_watched_fk_model_fields"] + return state @property @@ -58,9 +65,9 @@ def _sanitize_field_name(self, field_name: str) -> str: def _current_value(self, field_name: str) -> Any: if "." in field_name: - def getitem(obj, name: str): + def getitem(obj, field_name: str): try: - return getattr(obj, name) + return getattr(obj, field_name) except (AttributeError, ObjectDoesNotExist): return None @@ -74,7 +81,10 @@ def initial_value(self, field_name: str) -> Any: """ field_name = self._sanitize_field_name(field_name) - return self._initial_state.get(field_name, None) + if field_name in self._initial_state: + return self._initial_state[field_name] + + return None def has_changed(self, field_name: str) -> bool: """ @@ -83,7 +93,10 @@ def has_changed(self, field_name: str) -> bool: changed = self._diff_with_initial.keys() field_name = self._sanitize_field_name(field_name) - return field_name in changed + if field_name in changed: + return True + + return False def _clear_watched_fk_model_cache(self): """ @@ -127,29 +140,25 @@ def delete(self, *args, **kwargs): super().delete(*args, **kwargs) self._run_hooked_methods(AFTER_DELETE) - @cached_class_property - def _potentially_hooked_methods(cls) -> List[HookedMethod]: - skip = set(get_unhookable_attribute_names(cls)) - - # really important to skip _potentially_hooked_methods to avoid recursion - skip |= set(dir(LifecycleModelMixin)) - - # collect all possible hooked attrs from class - possible_names = set(dir(cls)) - + @cached_property + def _potentially_hooked_methods(self): + skip = set(get_unhookable_attribute_names(self)) collected = [] - for name in possible_names - skip: + + for name in dir(self): + if name in skip: + continue try: - attr = getattr(cls, name) - if isinstance(attr, HookedMethod): + attr = getattr(self, name) + if ismethod(attr) and hasattr(attr, "_hooked"): collected.append(attr) except AttributeError: pass return collected - @cached_class_property - def _watched_fk_model_fields(cls) -> List[str]: + @cached_property + def _watched_fk_model_fields(self) -> List[str]: """ Gather up all field names (values in 'when' key) that correspond to field names on FK-related models. These will be strings that contain @@ -157,16 +166,16 @@ def _watched_fk_model_fields(cls) -> List[str]: """ watched = [] # List[str] - for method in cls._potentially_hooked_methods: + for method in self._potentially_hooked_methods: for specs in method._hooked: if specs["when"] is not None and "." in specs["when"]: watched.append(specs["when"]) return watched - @cached_class_property - def _watched_fk_models(cls) -> List[str]: - return [_.split(".")[0] for _ in cls._watched_fk_model_fields] + @cached_property + def _watched_fk_models(self) -> List[str]: + return [_.split(".")[0] for _ in self._watched_fk_model_fields] def _run_hooked_methods(self, hook: str) -> List[str]: """ @@ -176,7 +185,7 @@ def _run_hooked_methods(self, hook: str) -> List[str]: """ fired = [] - for method in self._potentially_hooked_methods: # type: HookedMethod + for method in self._potentially_hooked_methods: for callback_specs in method._hooked: if callback_specs["hook"] != hook: continue @@ -187,15 +196,15 @@ def _run_hooked_methods(self, hook: str) -> List[str]: if when_field: if self._check_callback_conditions(when_field, callback_specs): fired.append(method.__name__) - method(self) + method() elif when_any_field: for field_name in when_any_field: if self._check_callback_conditions(field_name, callback_specs): fired.append(method.__name__) - method(self) + method() else: fired.append(method.__name__) - method(self) + method() return fired diff --git a/django_lifecycle/utils.py b/django_lifecycle/utils.py index 4223c1a..72cb0e9 100644 --- a/django_lifecycle/utils.py +++ b/django_lifecycle/utils.py @@ -1,39 +1,69 @@ -from functools import wraps -from typing import Set +from typing import List -from django.db.models.base import ModelBase +from django.utils.functional import cached_property +from .django_info import DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES -def _get_field_names(klass: ModelBase) -> Set[str]: - names = set() - for f in klass._meta.get_fields(): - names.add(f.name) +def _get_model_property_names(instance) -> List[str]: + """ + Gather up properties and cached_properties which may be methods + that were decorated. Need to inspect class versions b/c doing + getattr on them could cause unwanted side effects. + """ + property_names = [] - if klass._meta.get_field(f.name).get_internal_type() == "ForeignKey": - # TODO: not robust for cases with custom Field(db_column=...) definition - names.add(f.name + "_id") + for name in dir(instance): + try: + attr = getattr(type(instance), name) - return names + if isinstance(attr, property) or isinstance(attr, cached_property): + property_names.append(name) + except AttributeError: + pass -def get_unhookable_attribute_names(klass) -> Set[str]: - return ( - _get_field_names(klass) | - {'MultipleObjectsReturned', 'DoesNotExist'} - ) + return property_names + + +def _get_model_descriptor_names(instance) -> List[str]: + """ + Attributes which are Django descriptors. These represent a field + which is a one-to-many or many-to-many relationship that is + potentially defined in another model, and doesn't otherwise appear + as a field on this model. + """ + + descriptor_names = [] + + for name in dir(instance): + try: + attr = getattr(type(instance), name) + + if isinstance(attr, DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES): + descriptor_names.append(name) + except AttributeError: + pass + return descriptor_names -def cached_class_property(getter): - class CachedClassProperty: - def __init__(self, f): - self._f = f - self._name = f.__name__ + '__' + CachedClassProperty.__name__ - @wraps(getter) - def __get__(self, instance, cls): - if not hasattr(cls, self._name): - setattr(cls, self._name, self._f(cls)) - return getattr(cls, self._name) +def _get_field_names(instance) -> List[str]: + names = [] - return CachedClassProperty(getter) + for f in instance._meta.get_fields(): + names.append(f.name) + + if instance._meta.get_field(f.name).get_internal_type() == "ForeignKey": + names.append(f.name + "_id") + + return names + + +def get_unhookable_attribute_names(instance) -> List[str]: + return ( + _get_field_names(instance) + + _get_model_descriptor_names(instance) + + _get_model_property_names(instance) + + ["_run_hooked_methods"] + ) diff --git a/tests/testapp/models.py b/tests/testapp/models.py index 7b071ed..9d5a451 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -3,8 +3,7 @@ from django.utils import timezone from django.core import mail from django.db import models -from django.utils.functional import cached_property as django_cached_property -from functools import cached_property as builtin_cached_property +from django.utils.functional import cached_property from django_lifecycle import hook from django_lifecycle.models import LifecycleModel @@ -120,14 +119,10 @@ def email_user_about_name_change(self): ["to@example.com"], ) - @django_cached_property + @cached_property def full_name(self): return self.first_name + " " + self.last_name - @builtin_cached_property - def full_name_with_email(self): - return self.first_name + " " + self.last_name + " (" + (self.email or '') + ")" - class Locale(models.Model): code = models.CharField(max_length=20) diff --git a/tests/testapp/tests/test_decorator.py b/tests/testapp/tests/test_decorator.py index 6b89fa1..5c55de5 100644 --- a/tests/testapp/tests/test_decorator.py +++ b/tests/testapp/tests/test_decorator.py @@ -16,5 +16,5 @@ def one_hook(self): pass instance = FakeModel() - self.assertEqual(len(type(instance).multiple_hooks._hooked), 2) - self.assertEqual(len(type(instance).one_hook._hooked), 1) + self.assertEqual(len(instance.multiple_hooks._hooked), 2) + self.assertEqual(len(instance.one_hook._hooked), 1) diff --git a/tests/testapp/tests/test_mixin.py b/tests/testapp/tests/test_mixin.py index 4f8f869..9527728 100644 --- a/tests/testapp/tests/test_mixin.py +++ b/tests/testapp/tests/test_mixin.py @@ -324,21 +324,7 @@ def test_changes_to_condition_should_not_pass(self): user_account.last_name = "Bouvier" user_account.save() # `CannotRename` exception is not raised - def test_should_not_call_django_cached_property(self): - """ - full_name is cached_property (Django). Accessing _potentially_hooked_methods - should not call it incidentally. - """ - data = self.stub_data - data["first_name"] = "Bart" - data["last_name"] = "Simpson" - account = UserAccount.objects.create(**data) - account._potentially_hooked_methods - account.first_name = "Bartholomew" - # Should be first time this property is accessed... - self.assertEqual(account.full_name, "Bartholomew Simpson") - - def test_should_not_call_builtin_cached_property(self): + def test_should_not_call_cached_property(self): """ full_name is cached_property. Accessing _potentially_hooked_methods should not call it incidentally. @@ -346,12 +332,11 @@ def test_should_not_call_builtin_cached_property(self): data = self.stub_data data["first_name"] = "Bart" data["last_name"] = "Simpson" - data["email"] = "bart@simpson.com" account = UserAccount.objects.create(**data) account._potentially_hooked_methods account.first_name = "Bartholomew" # Should be first time this property is accessed... - self.assertEqual(account.full_name_with_email, "Bartholomew Simpson (bart@simpson.com)") + self.assertEqual(account.full_name, "Bartholomew Simpson") def test_comparison_state_should_reset_after_save(self): data = self.stub_data diff --git a/tests/testapp/tests/test_user_account.py b/tests/testapp/tests/test_user_account.py index 2eb4949..c8b6afa 100644 --- a/tests/testapp/tests/test_user_account.py +++ b/tests/testapp/tests/test_user_account.py @@ -97,9 +97,9 @@ def test_additional_notify_sent_for_specific_org_name_change(self): account.save() self.assertEqual(len(mail.outbox), 2) self.assertEqual( - {mail.outbox[0].subject, mail.outbox[1].subject}, - {"The name of your organization has changed!", "You were moved to our online school!"} + mail.outbox[0].subject, "The name of your organization has changed!" ) + self.assertEqual(mail.outbox[1].subject, "You were moved to our online school!") def test_email_user_about_name_change(self): account = UserAccount.objects.create(**self.stub_data) From 1b39cec8f3b7cf9f7972c4be810daa230fc7227a Mon Sep 17 00:00:00 2001 From: Andreu Vallbona Date: Sun, 5 Jul 2020 18:12:46 +0200 Subject: [PATCH 26/44] added support for python 3.8 for django 2.x versions * small refactor setup.py * added flake8 linter * added python versions badge * added django versions badge * added .travis.yml --- .gitignore | 4 ++- .travis.yml | 41 ++++++++++++++++++++++++ README.md | 3 ++ django_lifecycle/__init__.py | 4 +++ docs/index.md | 3 ++ manage.py | 4 +-- setup.py | 31 +++++++++++++++--- tests/settings.py | 1 - tests/testapp/migrations/0001_initial.py | 5 ++- tests/testapp/models.py | 10 +++--- tests/testapp/tests/test_mixin.py | 4 --- tests/testapp/tests/test_user_account.py | 3 +- tests/testapp/views.py | 3 -- tox.ini | 20 +++++++++--- 14 files changed, 109 insertions(+), 27 deletions(-) create mode 100644 .travis.yml diff --git a/.gitignore b/.gitignore index b2d40e4..5087e76 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,6 @@ __pycache__/ README.txt .tox django_lifecycle.egg-info -site/ \ No newline at end of file +site/ +venv +.idea \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..f204493 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,41 @@ +language: python +install: + - pip install tox +dist: xenial +sudo: required +script: + - tox +matrix: + include: + - python: 3.5 + env: TOXENV=py35-django20 + - python: 3.6 + env: TOXENV=py36-django20 + - python: 3.7 + env: TOXENV=py37-django20 + - python: 3.8 + env: TOXENV=py38-django20 + - python: 3.5 + env: TOXENV=py35-django21 + - python: 3.6 + env: TOXENV=py36-django21 + - python: 3.7 + env: TOXENV=py37-django21 + - python: 3.8 + env: TOXENV=py38-django21 + - python: 3.5 + env: TOXENV=py35-django22 + - python: 3.6 + env: TOXENV=py36-django22 + - python: 3.7 + env: TOXENV=py37-django22 + - python: 3.8 + env: TOXENV=py38-django22 + - python: 3.6 + env: TOXENV=py36-django30 + - python: 3.7 + env: TOXENV=py37-django30 + - python: 3.8 + env: TOXENV=py38-django30 + - python: 3.7 + env: TOXENV=flake8 diff --git a/README.md b/README.md index 77b76dd..9510d07 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ [![Package version](https://badge.fury.io/py/django-lifecycle.svg)](https://pypi.python.org/pypi/django-lifecycle) [![Python versions](https://img.shields.io/pypi/status/django-lifecycle.svg)](https://img.shields.io/pypi/status/django-lifecycle.svg/) +[![Python versions](https://img.shields.io/pypi/pyversions/django-lifecycle.svg)](https://pypi.org/project/django-lifecycle/) +![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-lifecycle) + This project provides a `@hook` decorator as well as a base model and mixin to add lifecycle hooks to your Django models. Django's built-in approach to offering lifecycle hooks is [Signals](https://docs.djangoproject.com/en/dev/topics/signals/). However, my team often finds that Signals introduce unnecessary indirection and are at odds with Django's "fat models" approach. diff --git a/django_lifecycle/__init__.py b/django_lifecycle/__init__.py index f7e2ee4..4e7f931 100644 --- a/django_lifecycle/__init__.py +++ b/django_lifecycle/__init__.py @@ -1,5 +1,9 @@ from .django_info import IS_GTE_1_POINT_9 +__version__ = "0.7.6" +__author__ = "Robert Singer" +__author_email__ = "robertgsinger@gmail.com" + class NotSet(object): pass diff --git a/docs/index.md b/docs/index.md index 958f6c4..a84f77a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,6 +2,9 @@ [![Package version](https://badge.fury.io/py/django-lifecycle.svg)](https://pypi.python.org/pypi/django-lifecycle) [![Python versions](https://img.shields.io/pypi/status/django-lifecycle.svg)](https://img.shields.io/pypi/status/django-lifecycle.svg/) +[![Python versions](https://img.shields.io/pypi/pyversions/django-lifecycle.svg)](https://pypi.org/project/django-lifecycle/) +![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-lifecycle) + This project provides a `@hook` decorator as well as a base model and mixin to add lifecycle hooks to your Django models. Django's built-in approach to offering lifecycle hooks is [Signals](https://docs.djangoproject.com/en/dev/topics/signals/). However, my team often finds that Signals introduce unnesseary indirection and are at odds with Django's "fat models" approach. diff --git a/manage.py b/manage.py index 5bba3ba..1e5b998 100755 --- a/manage.py +++ b/manage.py @@ -6,10 +6,10 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") try: from django.core.management import execute_from_command_line - except ImportError as exc: + except ImportError: raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" ) - execute_from_command_line(sys.argv) \ No newline at end of file + execute_from_command_line(sys.argv) diff --git a/setup.py b/setup.py index 6cc0cff..b96956b 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,22 @@ #!/usr/bin/env python +import codecs +import os +import re + from setuptools import setup from codecs import open +def get_metadata(package, field): + """ + Return package data as listed in `__{field}__` in `init.py`. + """ + init_py = codecs.open(os.path.join(package, "__init__.py"), encoding="utf-8").read() + return re.search( + "^__{}__ = ['\"]([^'\"]+)['\"]".format(field), init_py, re.MULTILINE + ).group(1) + + def readme(): with open("README.md", "r") as infile: return infile.read() @@ -12,18 +26,23 @@ def readme(): # Pick your license as you wish (should match "license" above) "Development Status :: 4 - Beta", "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.4", + "Programming Language :: Python", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Framework :: Django", + "Framework :: Django :: 2.0", + "Framework :: Django :: 2.1", + "Framework :: Django :: 2.2", + "Framework :: Django :: 3.0", ] setup( name="django-lifecycle", - version="0.7.6", + version=get_metadata("django_lifecycle", "version"), description="Declarative model lifecycle hooks.", - author="Robert Singer", - author_email="robertgsinger@gmail.com", + author=get_metadata("django_lifecycle", "author"), + author_email=get_metadata("django_lifecycle", "author_email"), packages=["django_lifecycle"], url="https://github.com/rsinger86/django-lifecycle", license="MIT", @@ -31,4 +50,8 @@ def readme(): long_description=readme(), classifiers=classifiers, long_description_content_type="text/markdown", + install_requires=[ + "Django>=2.0", + "urlman>=1.2.0" + ], ) diff --git a/tests/settings.py b/tests/settings.py index 8f6ceaa..6ea20ee 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -120,4 +120,3 @@ # https://docs.djangoproject.com/en/2.0/howto/static-files/ STATIC_URL = "/static/" - diff --git a/tests/testapp/migrations/0001_initial.py b/tests/testapp/migrations/0001_initial.py index 0f1a6de..65fc524 100644 --- a/tests/testapp/migrations/0001_initial.py +++ b/tests/testapp/migrations/0001_initial.py @@ -25,7 +25,10 @@ class Migration(migrations.Migration): ('password_updated_at', models.DateTimeField(null=True)), ('joined_at', models.DateTimeField(null=True)), ('has_trial', models.BooleanField(default=False)), - ('status', models.CharField(choices=[('active', 'Active'), ('banned', 'Banned'), ('inactive', 'Inactive')], default='active', max_length=30)), + ('status', models.CharField( + choices=[('active', 'Active'), ('banned', 'Banned'), ('inactive', 'Inactive')], + default='active', max_length=30 + )), ], options={ 'abstract': False, diff --git a/tests/testapp/models.py b/tests/testapp/models.py index 9d5a451..8a12fa7 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -1,14 +1,14 @@ import uuid -from django.utils import timezone from django.core import mail from django.db import models +from django.utils import timezone from django.utils.functional import cached_property +from urlman import Urls + from django_lifecycle import hook from django_lifecycle.models import LifecycleModel -import urlman - class CannotDeleteActiveTrial(Exception): pass @@ -39,7 +39,7 @@ class UserAccount(LifecycleModel): choices=(("active", "Active"), ("banned", "Banned"), ("inactive", "Inactive")), ) - class urls(urlman.Urls): + class urls(Urls): view = "/books/{self.pk}/" @hook("before_save", when="email", is_not=None) @@ -52,7 +52,7 @@ def timestamp_joined_at(self): @hook("after_create") def do_after_create_jobs(self): - ## queue background job to process thumbnail image... + # queue background job to process thumbnail image... mail.send_mail( "Welcome!", "Thank you for joining.", "from@example.com", ["to@example.com"] ) diff --git a/tests/testapp/tests/test_mixin.py b/tests/testapp/tests/test_mixin.py index 9527728..ce73734 100644 --- a/tests/testapp/tests/test_mixin.py +++ b/tests/testapp/tests/test_mixin.py @@ -304,8 +304,6 @@ def test_is_not_condition_should_not_pass(self): self.assertFalse(user_account._check_is_not_condition("first_name", specs)) def test_changes_to_condition_should_pass(self): - specs = {"when": "last_name", "changes_to": "Flanders"} - data = self.stub_data UserAccount.objects.create(**data) user_account = UserAccount.objects.get() @@ -314,8 +312,6 @@ def test_changes_to_condition_should_pass(self): user_account.save() def test_changes_to_condition_should_not_pass(self): - specs = {"when": "last_name", "changes_to": "Bouvier"} - data = self.stub_data data["first_name"] = "Marge" data["last_name"] = "Simpson" diff --git a/tests/testapp/tests/test_user_account.py b/tests/testapp/tests/test_user_account.py index c8b6afa..c68eed3 100644 --- a/tests/testapp/tests/test_user_account.py +++ b/tests/testapp/tests/test_user_account.py @@ -22,7 +22,7 @@ def test_update_joined_at_before_create(self): self.assertTrue(isinstance(account.joined_at, datetime.datetime)) def test_send_welcome_email_after_create(self): - account = UserAccount.objects.create(**self.stub_data) + UserAccount.objects.create(**self.stub_data) self.assertEqual(len(mail.outbox), 1) self.assertEqual(mail.outbox[0].subject, "Welcome!") @@ -118,4 +118,3 @@ def test_skip_hooks(self): account.email = "Homer.Simpson@springfieldnuclear" account.save(skip_hooks=True) self.assertEqual(account.email, "Homer.Simpson@springfieldnuclear") - diff --git a/tests/testapp/views.py b/tests/testapp/views.py index 91ea44a..e69de29 100644 --- a/tests/testapp/views.py +++ b/tests/testapp/views.py @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/tox.ini b/tox.ini index a85348c..85dddce 100644 --- a/tox.ini +++ b/tox.ini @@ -1,20 +1,32 @@ [tox] toxworkdir={env:TOXWORKDIR:{toxinidir}/.tox} envlist = - {py35,py36,py37}-django20 - {py35,py36,py37}-django21 - {py36,py37,py38}-django22 + {py35,py36,py37,py38}-django20 + {py35,py36,py37,py38}-django21 + {py35,py36,py37,py38}-django22 {py36,py37,py38}-django30 + flake8 skip_missing_interpreters = True +[flake8] +max-line-length = 120 + [testenv] -commands = python ./manage.py test --settings=tests.settings +commands = python ./manage.py test envdir = {toxworkdir}/venvs/{envname} setenv = PYTHONDONTWRITEBYTECODE=1 PYTHONWARNINGS=once + DJANGO_SETTINGS_MODULE=tests.settings deps = django20: django>=2.0,<2.1 django21: django>=2.1,<2.2 django22: django>=2.2,<3 django30: django>=3.0,<3.1 + +[testenv:flake8] +basepython = python3.7 +deps = + flake8==3.8.2 +commands = + flake8 . --exclude=venv/,.tox/,django_lifecycle/__init__.py From ff58c80733ae3366c674e4215190991c339ae280 Mon Sep 17 00:00:00 2001 From: Ryan Febriansyah <37971350+sodrooome@users.noreply.github.com> Date: Tue, 28 Jul 2020 11:14:53 +0700 Subject: [PATCH 27/44] fix typo in index --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index a84f77a..d763cb2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,7 +6,7 @@ ![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-lifecycle) -This project provides a `@hook` decorator as well as a base model and mixin to add lifecycle hooks to your Django models. Django's built-in approach to offering lifecycle hooks is [Signals](https://docs.djangoproject.com/en/dev/topics/signals/). However, my team often finds that Signals introduce unnesseary indirection and are at odds with Django's "fat models" approach. +This project provides a `@hook` decorator as well as a base model and mixin to add lifecycle hooks to your Django models. Django's built-in approach to offering lifecycle hooks is [Signals](https://docs.djangoproject.com/en/dev/topics/signals/). However, my team often finds that Signals introduce unnecessary indirection and are at odds with Django's "fat models" approach. In short, you can write model code like this: From b4d59cb68fa906f5a432c0d88343fe71376c054c Mon Sep 17 00:00:00 2001 From: Ryan Febriansyah <37971350+sodrooome@users.noreply.github.com> Date: Tue, 28 Jul 2020 11:23:08 +0700 Subject: [PATCH 28/44] fix dot-notation spelling --- docs/fk_changes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/fk_changes.md b/docs/fk_changes.md index 8af7867..83341f4 100644 --- a/docs/fk_changes.md +++ b/docs/fk_changes.md @@ -22,7 +22,7 @@ class UserAccount(LifecycleModel): To be clear: This hook will fire when the value in the database column that stores the foreign key (in this case, `organization_id`) changes. Read on to see how to watch for changes to *fields on the related model*. ## ForeignKey Field Value Changes -You can have a hooked method fire based on the *value of a field* on a foreign key-related model using dot notation: +You can have a hooked method fire based on the *value of a field* on a foreign key-related model using dot-notation: ```python class Organization(models.Model): From 24df20fd0094169efa7185ef10d7bd5ccab59e99 Mon Sep 17 00:00:00 2001 From: Ryan Febriansyah <37971350+sodrooome@users.noreply.github.com> Date: Tue, 28 Jul 2020 11:29:29 +0700 Subject: [PATCH 29/44] fix any type with capitalize case --- docs/hooks_and_conditions.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/hooks_and_conditions.md b/docs/hooks_and_conditions.md index eee9656..70b854f 100644 --- a/docs/hooks_and_conditions.md +++ b/docs/hooks_and_conditions.md @@ -47,8 +47,8 @@ If you do not use any conditional parameters, the hook will fire every time the | when | str | The name of the field that you want to check against; required for the conditions below to be checked. Use the name of a FK field to watch changes to the related model *reference* or use dot-notation to watch changes to the *values* of fields on related models, e.g. `"organization.name"`. But [please be aware](fk_changes.md#fk-hook-warning) of potential performance drawbacks. | | when_any | List[str] | Similar to the `when` parameter, but takes a list of field names. The hooked method will fire if any of the corresponding fields meet the keyword conditions. Useful if you don't like stacking decorators. | | has_changed | bool | Only fire the hooked method if the value of the `when` field has changed since the model was initialized | -| is_now | any | Only fire the hooked method if the value of the `when` field is currently equal to this value; defaults to `*`. | -| is_not | any | Only fire the hooked method if the value of the `when` field is NOT equal to this value | -| was | any | Only fire the hooked method if the value of the `when` field was equal to this value when first initialized; defaults to `*`. | -| was_not | any | Only fire the hooked method if the value of the `when` field was NOT equal to this value when first initialized. | -| changes_to | any | Only fire the hooked method if the value of the `when` field was NOT equal to this value when first initialized but is currently equal to this value. | \ No newline at end of file +| is_now | Any | Only fire the hooked method if the value of the `when` field is currently equal to this value; defaults to `*`. | +| is_not | Any | Only fire the hooked method if the value of the `when` field is NOT equal to this value | +| was | Any | Only fire the hooked method if the value of the `when` field was equal to this value when first initialized; defaults to `*`. | +| was_not | Any | Only fire the hooked method if the value of the `when` field was NOT equal to this value when first initialized. | +| changes_to | Any | Only fire the hooked method if the value of the `when` field was NOT equal to this value when first initialized but is currently equal to this value. | From 8a9fead31dc6461ff787e655bb8bcc57c715c944 Mon Sep 17 00:00:00 2001 From: Brian Bouterse Date: Mon, 3 Aug 2020 15:52:51 -0400 Subject: [PATCH 30/44] Skip GenericForeignKey fields The `GenericForeignKey` field does not provide a `get_internal_type` method so when checking if it's a ForeignKey or not an AttributeError is raised. This adjusts the code to ignore this `AttributeError` which effectively un-monitors the `GenericForeignKey` itself. However, it does leave the underlying `ForeignKey` to the `ContentType` table and the primary key storage field indexing into that table monitored. This does not enable support for hooking on the name of the `GenericForeignKey`, but hooking on the underlying fields that support that `GenericForeignKey` should still be possible. closes #42 --- django_lifecycle/utils.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/django_lifecycle/utils.py b/django_lifecycle/utils.py index 72cb0e9..d640594 100644 --- a/django_lifecycle/utils.py +++ b/django_lifecycle/utils.py @@ -54,8 +54,14 @@ def _get_field_names(instance) -> List[str]: for f in instance._meta.get_fields(): names.append(f.name) - if instance._meta.get_field(f.name).get_internal_type() == "ForeignKey": - names.append(f.name + "_id") + try: + internal_type = instance._meta.get_field(f.name).get_internal_type() + except AttributeError: + # Skip fields which don't provide a `get_internal_type` method, e.g. GenericForeignKey + continue + else: + if internal_type == "ForeignKey": + names.append(f.name + "_id") return names From 7e825a1c09a7ff1683d97e6b4840f1dcdd3c72f0 Mon Sep 17 00:00:00 2001 From: robertgsinger Date: Wed, 5 Aug 2020 20:48:27 -0500 Subject: [PATCH 31/44] Updates version and release notes --- README.md | 3 +++ django_lifecycle/__init__.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9510d07..606788f 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,9 @@ Instead of overriding `save` and `__init__` in a clunky way that hurts readabili # Changelog +## 0.7.7 (August 2020) +* Fixes issue with `GenericForeignKey`. Thanks @bmbouter! + ## 0.7.6 (May 2020) * Updates to use constants for hook names; updates docs to indicate Python 3.8/Django 3.x support. Thanks @thejoeejoee! diff --git a/django_lifecycle/__init__.py b/django_lifecycle/__init__.py index 4e7f931..b2f8cad 100644 --- a/django_lifecycle/__init__.py +++ b/django_lifecycle/__init__.py @@ -1,6 +1,6 @@ from .django_info import IS_GTE_1_POINT_9 -__version__ = "0.7.6" +__version__ = "0.7.7" __author__ = "Robert Singer" __author_email__ = "robertgsinger@gmail.com" From bb644df4694d762a1bb1a5f245bf27dfe4c0f90f Mon Sep 17 00:00:00 2001 From: Adam Mertz Date: Tue, 25 Aug 2020 22:54:35 -0500 Subject: [PATCH 32/44] Add django31 environments --- tox.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tox.ini b/tox.ini index 85dddce..effb263 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,7 @@ envlist = {py35,py36,py37,py38}-django21 {py35,py36,py37,py38}-django22 {py36,py37,py38}-django30 + {py36,py37,py38}-django31 flake8 skip_missing_interpreters = True @@ -23,6 +24,7 @@ deps = django21: django>=2.1,<2.2 django22: django>=2.2,<3 django30: django>=3.0,<3.1 + django31: django>=3.1,<4 [testenv:flake8] basepython = python3.7 From 538f80592741a350003e1df844b1e31fa0189187 Mon Sep 17 00:00:00 2001 From: Adam Mertz Date: Tue, 25 Aug 2020 22:58:39 -0500 Subject: [PATCH 33/44] add environments to travis ci --- .travis.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.travis.yml b/.travis.yml index f204493..ed6e84f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -37,5 +37,11 @@ matrix: env: TOXENV=py37-django30 - python: 3.8 env: TOXENV=py38-django30 + - python: 3.6 + env: TOXENV=py36-django31 + - python: 3.7 + env: TOXENV=py37-django31 + - python: 3.8 + env: TOXENV=py38-django31 - python: 3.7 env: TOXENV=flake8 From eed6c922b8e12a6397b7c037723b366bcc88371c Mon Sep 17 00:00:00 2001 From: Daniel Alley Date: Tue, 29 Sep 2020 19:17:54 -0400 Subject: [PATCH 34/44] Make django-lifecycle much, much faster Some of the work that the lifecycle mixin is during the initialization of new model objects is very expensive and unnecessary. It's calculating (and caching) field names and foreign key models per-object, rather than per-class / model. All instances of a model are going to have the same field names and foreign key model types so this work actually only needs to be done once per model type. Replacing cached methods with cached classmethods yields a very sizable performance improvement when creating a bunch of new model instances. --- django_lifecycle/mixins.py | 105 +++++++++++++++++++++++++++++++------ django_lifecycle/utils.py | 75 -------------------------- 2 files changed, 89 insertions(+), 91 deletions(-) delete mode 100644 django_lifecycle/utils.py diff --git a/django_lifecycle/mixins.py b/django_lifecycle/mixins.py index 1d5d6c0..84844cf 100644 --- a/django_lifecycle/mixins.py +++ b/django_lifecycle/mixins.py @@ -1,4 +1,4 @@ -from functools import reduce +from functools import reduce, lru_cache from inspect import ismethod from typing import Any, List @@ -12,7 +12,8 @@ AFTER_CREATE, AFTER_UPDATE, AFTER_SAVE, AFTER_DELETE, ) -from .utils import get_unhookable_attribute_names + +from .django_info import DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES class LifecycleModelMixin(object): @@ -23,7 +24,7 @@ def __init__(self, *args, **kwargs): def _snapshot_state(self): state = self.__dict__.copy() - for watched_related_field in self._watched_fk_model_fields: + for watched_related_field in self.__class__._watched_fk_model_fields(): state[watched_related_field] = self._current_value(watched_related_field) if "_state" in state: @@ -102,7 +103,7 @@ def _clear_watched_fk_model_cache(self): """ """ - for watched_field_name in self._watched_fk_models: + for watched_field_name in self.__class__._watched_fk_models(): field = self._meta.get_field(watched_field_name) if field.is_relation and field.is_cached(self): @@ -140,16 +141,16 @@ def delete(self, *args, **kwargs): super().delete(*args, **kwargs) self._run_hooked_methods(AFTER_DELETE) - @cached_property - def _potentially_hooked_methods(self): - skip = set(get_unhookable_attribute_names(self)) + @classmethod + def _potentially_hooked_methods(cls): + skip = set(cls._get_unhookable_attribute_names()) collected = [] - for name in dir(self): + for name in dir(cls): if name in skip: continue try: - attr = getattr(self, name) + attr = getattr(cls, name) if ismethod(attr) and hasattr(attr, "_hooked"): collected.append(attr) except AttributeError: @@ -157,8 +158,9 @@ def _potentially_hooked_methods(self): return collected - @cached_property - def _watched_fk_model_fields(self) -> List[str]: + @classmethod + @lru_cache(typed=True) + def _watched_fk_model_fields(cls) -> List[str]: """ Gather up all field names (values in 'when' key) that correspond to field names on FK-related models. These will be strings that contain @@ -166,16 +168,17 @@ def _watched_fk_model_fields(self) -> List[str]: """ watched = [] # List[str] - for method in self._potentially_hooked_methods: + for method in cls._potentially_hooked_methods(): for specs in method._hooked: if specs["when"] is not None and "." in specs["when"]: watched.append(specs["when"]) return watched - @cached_property - def _watched_fk_models(self) -> List[str]: - return [_.split(".")[0] for _ in self._watched_fk_model_fields] + @classmethod + @lru_cache(typed=True) + def _watched_fk_models(cls) -> List[str]: + return [_.split(".")[0] for _ in cls._watched_fk_model_fields()] def _run_hooked_methods(self, hook: str) -> List[str]: """ @@ -185,7 +188,7 @@ def _run_hooked_methods(self, hook: str) -> List[str]: """ fired = [] - for method in self._potentially_hooked_methods: + for method in self.__class__._potentially_hooked_methods(): for callback_specs in method._hooked: if callback_specs["hook"] != hook: continue @@ -257,3 +260,73 @@ def _check_changes_to_condition(self, field_name: str, specs: dict) -> bool: changes_to is NotSet, (self.initial_value(field_name) != changes_to and self._current_value(field_name) == changes_to) ]) + + @classmethod + def _get_model_property_names(cls) -> List[str]: + """ + Gather up properties and cached_properties which may be methods + that were decorated. Need to inspect class versions b/c doing + getattr on them could cause unwanted side effects. + """ + property_names = [] + + for name in dir(cls): + try: + attr = getattr(cls, name) + + if isinstance(attr, property) or isinstance(attr, cached_property): + property_names.append(name) + + except AttributeError: + pass + + return property_names + + @classmethod + def _get_model_descriptor_names(cls) -> List[str]: + """ + Attributes which are Django descriptors. These represent a field + which is a one-to-many or many-to-many relationship that is + potentially defined in another model, and doesn't otherwise appear + as a field on this model. + """ + + descriptor_names = [] + + for name in dir(cls): + try: + attr = getattr(cls, name) + + if isinstance(attr, DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES): + descriptor_names.append(name) + except AttributeError: + pass + + return descriptor_names + + @classmethod + def _get_field_names(cls) -> List[str]: + names = [] + + for f in cls._meta.get_fields(): + names.append(f.name) + + try: + internal_type = cls._meta.get_field(f.name).get_internal_type() + except AttributeError: + # Skip fields which don't provide a `get_internal_type` method, e.g. GenericForeignKey + continue + else: + if internal_type == "ForeignKey": + names.append(f.name + "_id") + + return names + + @classmethod + def _get_unhookable_attribute_names(cls) -> List[str]: + return ( + cls._get_field_names() + + cls._get_model_descriptor_names() + + cls._get_model_property_names() + + ["_run_hooked_methods"] + ) diff --git a/django_lifecycle/utils.py b/django_lifecycle/utils.py deleted file mode 100644 index d640594..0000000 --- a/django_lifecycle/utils.py +++ /dev/null @@ -1,75 +0,0 @@ -from typing import List - -from django.utils.functional import cached_property - -from .django_info import DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES - - -def _get_model_property_names(instance) -> List[str]: - """ - Gather up properties and cached_properties which may be methods - that were decorated. Need to inspect class versions b/c doing - getattr on them could cause unwanted side effects. - """ - property_names = [] - - for name in dir(instance): - try: - attr = getattr(type(instance), name) - - if isinstance(attr, property) or isinstance(attr, cached_property): - property_names.append(name) - - except AttributeError: - pass - - return property_names - - -def _get_model_descriptor_names(instance) -> List[str]: - """ - Attributes which are Django descriptors. These represent a field - which is a one-to-many or many-to-many relationship that is - potentially defined in another model, and doesn't otherwise appear - as a field on this model. - """ - - descriptor_names = [] - - for name in dir(instance): - try: - attr = getattr(type(instance), name) - - if isinstance(attr, DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES): - descriptor_names.append(name) - except AttributeError: - pass - - return descriptor_names - - -def _get_field_names(instance) -> List[str]: - names = [] - - for f in instance._meta.get_fields(): - names.append(f.name) - - try: - internal_type = instance._meta.get_field(f.name).get_internal_type() - except AttributeError: - # Skip fields which don't provide a `get_internal_type` method, e.g. GenericForeignKey - continue - else: - if internal_type == "ForeignKey": - names.append(f.name + "_id") - - return names - - -def get_unhookable_attribute_names(instance) -> List[str]: - return ( - _get_field_names(instance) - + _get_model_descriptor_names(instance) - + _get_model_property_names(instance) - + ["_run_hooked_methods"] - ) From 79914e791ba7ee40d72ca7044976dcc26b0d6fef Mon Sep 17 00:00:00 2001 From: Daniel Alley Date: Wed, 30 Sep 2020 00:08:08 -0400 Subject: [PATCH 35/44] fixups --- django_lifecycle/mixins.py | 16 ++-- tests/testapp/tests/test_mixin.py | 140 +++++++++++++++--------------- 2 files changed, 80 insertions(+), 76 deletions(-) diff --git a/django_lifecycle/mixins.py b/django_lifecycle/mixins.py index 84844cf..91465b2 100644 --- a/django_lifecycle/mixins.py +++ b/django_lifecycle/mixins.py @@ -1,5 +1,5 @@ from functools import reduce, lru_cache -from inspect import ismethod +from inspect import isfunction from typing import Any, List from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist @@ -24,7 +24,7 @@ def __init__(self, *args, **kwargs): def _snapshot_state(self): state = self.__dict__.copy() - for watched_related_field in self.__class__._watched_fk_model_fields(): + for watched_related_field in self._watched_fk_model_fields(): state[watched_related_field] = self._current_value(watched_related_field) if "_state" in state: @@ -103,7 +103,7 @@ def _clear_watched_fk_model_cache(self): """ """ - for watched_field_name in self.__class__._watched_fk_models(): + for watched_field_name in self._watched_fk_models(): field = self._meta.get_field(watched_field_name) if field.is_relation and field.is_cached(self): @@ -151,7 +151,7 @@ def _potentially_hooked_methods(cls): continue try: attr = getattr(cls, name) - if ismethod(attr) and hasattr(attr, "_hooked"): + if isfunction(attr) and hasattr(attr, "_hooked"): collected.append(attr) except AttributeError: pass @@ -188,7 +188,7 @@ def _run_hooked_methods(self, hook: str) -> List[str]: """ fired = [] - for method in self.__class__._potentially_hooked_methods(): + for method in self._potentially_hooked_methods(): for callback_specs in method._hooked: if callback_specs["hook"] != hook: continue @@ -199,15 +199,15 @@ def _run_hooked_methods(self, hook: str) -> List[str]: if when_field: if self._check_callback_conditions(when_field, callback_specs): fired.append(method.__name__) - method() + method(self) elif when_any_field: for field_name in when_any_field: if self._check_callback_conditions(field_name, callback_specs): fired.append(method.__name__) - method() + method(self) else: fired.append(method.__name__) - method() + method(self) return fired diff --git a/tests/testapp/tests/test_mixin.py b/tests/testapp/tests/test_mixin.py index ce73734..39506f5 100644 --- a/tests/testapp/tests/test_mixin.py +++ b/tests/testapp/tests/test_mixin.py @@ -92,80 +92,84 @@ def test_current_value_for_watched_fk_model_field(self): def test_run_hooked_methods_for_when(self): instance = UserAccount(first_name="Bob") - instance._potentially_hooked_methods = [ - MagicMock( - __name__="method_that_does_fires", - _hooked=[ - { - "hook": "after_create", - "when": "first_name", - "when_any": None, - "has_changed": None, - "is_now": "Bob", - "is_not": NotSet, - "was": "*", - "was_not": NotSet, - "changes_to": NotSet, - } - ], - ), - MagicMock( - __name__="method_that_does_not_fire", - _hooked=[ - { - "hook": "after_create", - "when": "first_name", - "when_any": None, - "has_changed": None, - "is_now": "Bill", - "is_not": NotSet, - "was": "*", - "was_not": NotSet, - "changes_to": NotSet, - } - ], - ), - ] + instance._potentially_hooked_methods = MagicMock( + return_value=[ + MagicMock( + __name__="method_that_does_fires", + _hooked=[ + { + "hook": "after_create", + "when": "first_name", + "when_any": None, + "has_changed": None, + "is_now": "Bob", + "is_not": NotSet, + "was": "*", + "was_not": NotSet, + "changes_to": NotSet, + } + ], + ), + MagicMock( + __name__="method_that_does_not_fire", + _hooked=[ + { + "hook": "after_create", + "when": "first_name", + "when_any": None, + "has_changed": None, + "is_now": "Bill", + "is_not": NotSet, + "was": "*", + "was_not": NotSet, + "changes_to": NotSet, + } + ], + ), + ] + ) fired_methods = instance._run_hooked_methods("after_create") self.assertEqual(fired_methods, ["method_that_does_fires"]) def test_run_hooked_methods_for_when_any(self): instance = UserAccount(first_name="Bob") - instance._potentially_hooked_methods = [ - MagicMock( - __name__="method_that_does_fires", - _hooked=[ - { - "hook": "after_create", - "when": None, - "when_any": ["first_name", "last_name", "password"], - "has_changed": None, - "is_now": "Bob", - "is_not": NotSet, - "was": "*", - "was_not": NotSet, - "changes_to": NotSet, - } - ], - ), - MagicMock( - __name__="method_that_does_not_fire", - _hooked=[ - { - "hook": "after_create", - "when": "first_name", - "when_any": None, - "has_changed": None, - "is_now": "Bill", - "is_not": NotSet, - "was": "*", - "was_not": NotSet, - "changes_to": NotSet, - } - ], - ), - ] + instance._potentially_hooked_methods = MagicMock( + return_value = [ + MagicMock( + __name__="method_that_does_fires", + _hooked=[ + { + "hook": "after_create", + "when": None, + "when_any": ["first_name", "last_name", "password"], + "has_changed": None, + "is_now": "Bob", + "is_not": NotSet, + "was": "*", + "was_not": NotSet, + "changes_to": NotSet, + } + ], + ), + MagicMock( + __name__="method_that_does_not_fire", + _hooked=[ + { + "hook": "after_create", + "when": "first_name", + "when_any": None, + "has_changed": None, + "is_now": "Bill", + "is_not": NotSet, + "was": "*", + "was_not": NotSet, + "changes_to": NotSet, + } + ], + ), + ] + ) fired_methods = instance._run_hooked_methods("after_create") self.assertEqual(fired_methods, ["method_that_does_fires"]) From ec35e91591c79a3de7119ad8a4c3b3f06991b701 Mon Sep 17 00:00:00 2001 From: Daniel Alley Date: Sun, 11 Oct 2020 00:25:51 -0400 Subject: [PATCH 36/44] minor simplification --- django_lifecycle/mixins.py | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/django_lifecycle/mixins.py b/django_lifecycle/mixins.py index 91465b2..9dfaa28 100644 --- a/django_lifecycle/mixins.py +++ b/django_lifecycle/mixins.py @@ -142,6 +142,7 @@ def delete(self, *args, **kwargs): self._run_hooked_methods(AFTER_DELETE) @classmethod + @lru_cache(typed=True) def _potentially_hooked_methods(cls): skip = set(cls._get_unhookable_attribute_names()) collected = [] @@ -234,11 +235,7 @@ def _check_callback_conditions(self, field_name: str, specs: dict) -> bool: def _check_has_changed(self, field_name: str, specs: dict) -> bool: has_changed = specs["has_changed"] - - if has_changed is None: - return True - - return has_changed == self.has_changed(field_name) + return has_changed is None or has_changed == self.has_changed(field_name) def _check_is_now_condition(self, field_name: str, specs: dict) -> bool: return specs["is_now"] in (self._current_value(field_name), "*") @@ -271,14 +268,10 @@ def _get_model_property_names(cls) -> List[str]: property_names = [] for name in dir(cls): - try: - attr = getattr(cls, name) + attr = getattr(cls, name, None) - if isinstance(attr, property) or isinstance(attr, cached_property): - property_names.append(name) - - except AttributeError: - pass + if attr and (isinstance(attr, property) or isinstance(attr, cached_property)): + property_names.append(name) return property_names @@ -294,13 +287,10 @@ def _get_model_descriptor_names(cls) -> List[str]: descriptor_names = [] for name in dir(cls): - try: - attr = getattr(cls, name) + attr = getattr(cls, name, None) - if isinstance(attr, DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES): - descriptor_names.append(name) - except AttributeError: - pass + if attr and isinstance(attr, DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES): + descriptor_names.append(name) return descriptor_names From 5f83023ee32c89e0a25e811783c0ddeeed25607b Mon Sep 17 00:00:00 2001 From: Robert Singer Date: Sun, 11 Oct 2020 15:08:29 -0500 Subject: [PATCH 37/44] Bumps version number, adds release notes --- README.md | 4 ++++ django_lifecycle/__init__.py | 2 +- pypi_submit.py | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 pypi_submit.py diff --git a/README.md b/README.md index 606788f..ae93959 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,10 @@ Instead of overriding `save` and `__init__` in a clunky way that hurts readabili # Changelog +## 0.8.0 (October 2020) +* Significant performance improvements. Thanks @dralley! + + ## 0.7.7 (August 2020) * Fixes issue with `GenericForeignKey`. Thanks @bmbouter! diff --git a/django_lifecycle/__init__.py b/django_lifecycle/__init__.py index b2f8cad..01cf70f 100644 --- a/django_lifecycle/__init__.py +++ b/django_lifecycle/__init__.py @@ -1,6 +1,6 @@ from .django_info import IS_GTE_1_POINT_9 -__version__ = "0.7.7" +__version__ = "0.8.0" __author__ = "Robert Singer" __author_email__ = "robertgsinger@gmail.com" diff --git a/pypi_submit.py b/pypi_submit.py new file mode 100644 index 0000000..cf30789 --- /dev/null +++ b/pypi_submit.py @@ -0,0 +1,4 @@ +import os + +os.system("python setup.py sdist --verbose") +os.system("twine upload dist/*") \ No newline at end of file From 28347862bd80c11e9fae3ceae533a846cfe7437b Mon Sep 17 00:00:00 2001 From: robertgsinger Date: Sun, 24 Jan 2021 22:47:27 -0600 Subject: [PATCH 38/44] Returns value in delete method override --- README.md | 4 ++- django_lifecycle/__init__.py | 2 +- django_lifecycle/mixins.py | 32 ++++++++++++++++-------- tests/testapp/tests/test_user_account.py | 11 ++++++++ 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ae93959..102fe01 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,12 @@ Instead of overriding `save` and `__init__` in a clunky way that hurts readabili # Changelog +## 0.8.1 (January 2021) +* Added missing return to `delete()` method override. Thanks @oaosman84! + ## 0.8.0 (October 2020) * Significant performance improvements. Thanks @dralley! - ## 0.7.7 (August 2020) * Fixes issue with `GenericForeignKey`. Thanks @bmbouter! diff --git a/django_lifecycle/__init__.py b/django_lifecycle/__init__.py index 01cf70f..8daf9a7 100644 --- a/django_lifecycle/__init__.py +++ b/django_lifecycle/__init__.py @@ -1,6 +1,6 @@ from .django_info import IS_GTE_1_POINT_9 -__version__ = "0.8.0" +__version__ = "0.8.1" __author__ = "Robert Singer" __author_email__ = "robertgsinger@gmail.com" diff --git a/django_lifecycle/mixins.py b/django_lifecycle/mixins.py index 9dfaa28..84d5938 100644 --- a/django_lifecycle/mixins.py +++ b/django_lifecycle/mixins.py @@ -7,10 +7,14 @@ from . import NotSet from .hooks import ( - BEFORE_CREATE, BEFORE_UPDATE, - BEFORE_SAVE, BEFORE_DELETE, - AFTER_CREATE, AFTER_UPDATE, - AFTER_SAVE, AFTER_DELETE, + BEFORE_CREATE, + BEFORE_UPDATE, + BEFORE_SAVE, + BEFORE_DELETE, + AFTER_CREATE, + AFTER_UPDATE, + AFTER_SAVE, + AFTER_DELETE, ) from .django_info import DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES @@ -138,8 +142,9 @@ def save(self, *args, **kwargs): def delete(self, *args, **kwargs): self._run_hooked_methods(BEFORE_DELETE) - super().delete(*args, **kwargs) + value = super().delete(*args, **kwargs) self._run_hooked_methods(AFTER_DELETE) + return value @classmethod @lru_cache(typed=True) @@ -253,10 +258,15 @@ def _check_was_not_condition(self, field_name: str, specs: dict) -> bool: def _check_changes_to_condition(self, field_name: str, specs: dict) -> bool: changes_to = specs["changes_to"] - return any([ - changes_to is NotSet, - (self.initial_value(field_name) != changes_to and self._current_value(field_name) == changes_to) - ]) + return any( + [ + changes_to is NotSet, + ( + self.initial_value(field_name) != changes_to + and self._current_value(field_name) == changes_to + ), + ] + ) @classmethod def _get_model_property_names(cls) -> List[str]: @@ -270,7 +280,9 @@ def _get_model_property_names(cls) -> List[str]: for name in dir(cls): attr = getattr(cls, name, None) - if attr and (isinstance(attr, property) or isinstance(attr, cached_property)): + if attr and ( + isinstance(attr, property) or isinstance(attr, cached_property) + ): property_names.append(name) return property_names diff --git a/tests/testapp/tests/test_user_account.py b/tests/testapp/tests/test_user_account.py index c68eed3..7d53b31 100644 --- a/tests/testapp/tests/test_user_account.py +++ b/tests/testapp/tests/test_user_account.py @@ -118,3 +118,14 @@ def test_skip_hooks(self): account.email = "Homer.Simpson@springfieldnuclear" account.save(skip_hooks=True) self.assertEqual(account.email, "Homer.Simpson@springfieldnuclear") + + def test_delete_should_return_default_django_value(self): + """ + Hooked method that auto-lowercases email should be skipped. + """ + UserAccount.objects.create(**self.stub_data) + value = UserAccount.objects.all().delete() + + self.assertEqual( + value, (1, {"testapp.Locale_users": 0, "testapp.UserAccount": 1}) + ) From 0db8de753eef8df27d996354ae121ee6e3c77dd1 Mon Sep 17 00:00:00 2001 From: Dmytro Litvinov Date: Thu, 11 Feb 2021 22:18:22 +0200 Subject: [PATCH 39/44] Update tox.ini --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index effb263..6b7e433 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ envlist = {py35,py36,py37,py38}-django21 {py35,py36,py37,py38}-django22 {py36,py37,py38}-django30 - {py36,py37,py38}-django31 + {py36,py37,py38,py39}-django31 flake8 skip_missing_interpreters = True @@ -24,11 +24,11 @@ deps = django21: django>=2.1,<2.2 django22: django>=2.2,<3 django30: django>=3.0,<3.1 - django31: django>=3.1,<4 + django31: django>=3.1,<3.2 [testenv:flake8] basepython = python3.7 deps = - flake8==3.8.2 + flake8==3.8.4 commands = flake8 . --exclude=venv/,.tox/,django_lifecycle/__init__.py From 55b30cd6bee45c430a438299ad1d079668447bb4 Mon Sep 17 00:00:00 2001 From: Dmytro Litvinov Date: Thu, 11 Feb 2021 22:20:24 +0200 Subject: [PATCH 40/44] Update tox.ini --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index 6b7e433..496df14 100644 --- a/tox.ini +++ b/tox.ini @@ -2,9 +2,9 @@ toxworkdir={env:TOXWORKDIR:{toxinidir}/.tox} envlist = {py35,py36,py37,py38}-django20 - {py35,py36,py37,py38}-django21 - {py35,py36,py37,py38}-django22 - {py36,py37,py38}-django30 + {py35,py36,py37}-django21 + {py35,py36,py37,py38,py39}-django22 + {py36,py37,py38,py39}-django30 {py36,py37,py38,py39}-django31 flake8 skip_missing_interpreters = True From 45df413694e398ab3a8e3f867f4e6eabc612c177 Mon Sep 17 00:00:00 2001 From: Dmytro Litvinov Date: Thu, 11 Feb 2021 22:21:36 +0200 Subject: [PATCH 41/44] Update tox.ini --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 496df14..4b58b81 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] toxworkdir={env:TOXWORKDIR:{toxinidir}/.tox} envlist = - {py35,py36,py37,py38}-django20 + {py35,py36,py37}-django20 {py35,py36,py37}-django21 {py35,py36,py37,py38,py39}-django22 {py36,py37,py38,py39}-django30 From 0a5aaec0779509f446a9297c374a92780fabd5f9 Mon Sep 17 00:00:00 2001 From: Dmytro Litvinov Date: Thu, 11 Feb 2021 22:23:15 +0200 Subject: [PATCH 42/44] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 102fe01..bb0cdfb 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This project provides a `@hook` decorator as well as a base model and mixin to add lifecycle hooks to your Django models. Django's built-in approach to offering lifecycle hooks is [Signals](https://docs.djangoproject.com/en/dev/topics/signals/). However, my team often finds that Signals introduce unnecessary indirection and are at odds with Django's "fat models" approach. -**Django Lifecycle Hooks** supports Python 3.5, 3.6, 3.7 and 3.8, Django 2.0.x, 2.1.x, 2.2.x and 3.0.x. +**Django Lifecycle Hooks** supports Python 3.5, 3.6, 3.7, 3.8 and 3.9, Django 2.0.x, 2.1.x, 2.2.x, 3.0.x and 3.1.x. In short, you can write model code like this: From f55e3301106fcca3430ec3963e99ba0f32b998e2 Mon Sep 17 00:00:00 2001 From: Dmytro Litvinov Date: Thu, 11 Feb 2021 22:23:59 +0200 Subject: [PATCH 43/44] Update setup.py --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index b96956b..8b7c7e4 100644 --- a/setup.py +++ b/setup.py @@ -31,11 +31,13 @@ def readme(): "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "Framework :: Django", "Framework :: Django :: 2.0", "Framework :: Django :: 2.1", "Framework :: Django :: 2.2", "Framework :: Django :: 3.0", + "Framework :: Django :: 3.1", ] setup( name="django-lifecycle", From 59d05959e51917165c83b82432200bdc8cf57a62 Mon Sep 17 00:00:00 2001 From: Dmytro Litvinov Date: Thu, 11 Feb 2021 22:26:10 +0200 Subject: [PATCH 44/44] Update index.md --- docs/index.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/index.md b/docs/index.md index d763cb2..2f0cb75 100644 --- a/docs/index.md +++ b/docs/index.md @@ -52,8 +52,8 @@ Instead of overriding `save` and `__init__` in a clunky way that hurts readabili ## Requirements -* Python (3.3+) -* Django (1.8+) +* Python (3.5+) +* Django (2.0+) ## Installation @@ -87,6 +87,4 @@ class YourModel(LifecycleModelMixin, models.Model): ``` -If you are using **Django 1.8 or below** and want to extend the base model, you also have to add `django_lifecycle` to `INSTALLED_APPS`. - [Read on](examples.md) to see more examples of how to use lifecycle hooks.