django-bulk-hooks 0.1.230__py3-none-any.whl → 0.1.232__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of django-bulk-hooks might be problematic. Click here for more details.

@@ -2,6 +2,8 @@ import logging
2
2
  import threading
3
3
  from collections import deque
4
4
 
5
+ from django.db import transaction
6
+
5
7
  from django_bulk_hooks.registry import get_hooks, register_hook
6
8
 
7
9
  logger = logging.getLogger(__name__)
@@ -59,41 +61,28 @@ class HookContextState:
59
61
  return hook_vars.model
60
62
 
61
63
 
64
+ Hook = HookContextState()
65
+
66
+
62
67
  class HookMeta(type):
63
- """Metaclass that automatically registers hooks when Hook classes are defined."""
64
-
68
+ _registered = set()
69
+
65
70
  def __new__(mcs, name, bases, namespace):
66
71
  cls = super().__new__(mcs, name, bases, namespace)
67
-
68
- # Register hooks for this class, including inherited methods
69
- # We need to check all methods in the MRO to handle inheritance
70
- for attr_name in dir(cls):
71
- if attr_name.startswith('_'):
72
- continue
73
-
74
- try:
75
- attr = getattr(cls, attr_name)
76
- if callable(attr) and hasattr(attr, "hooks_hooks"):
77
- for model_cls, event, condition, priority in attr.hooks_hooks:
78
- # Register the hook
72
+ for method_name, method in namespace.items():
73
+ if hasattr(method, "hooks_hooks"):
74
+ for model_cls, event, condition, priority in method.hooks_hooks:
75
+ key = (model_cls, event, cls, method_name)
76
+ if key not in HookMeta._registered:
79
77
  register_hook(
80
78
  model=model_cls,
81
79
  event=event,
82
80
  handler_cls=cls,
83
- method_name=attr_name,
81
+ method_name=method_name,
84
82
  condition=condition,
85
83
  priority=priority,
86
84
  )
87
-
88
- logger.debug(
89
- f"Registered hook {cls.__name__}.{attr_name} "
90
- f"for {model_cls.__name__}.{event} with priority {priority}"
91
- )
92
- except Exception as e:
93
- # Skip attributes that can't be accessed
94
- logger.debug(f"Skipping attribute {attr_name}: {e}")
95
- continue
96
-
85
+ HookMeta._registered.add(key)
97
86
  return cls
98
87
 
99
88
 
@@ -134,7 +123,7 @@ class Hook(metaclass=HookMeta):
134
123
  hook_vars.event = event
135
124
  hook_vars.model = model
136
125
 
137
- hooks = get_hooks(model, event)
126
+ hooks = sorted(get_hooks(model, event), key=lambda x: x[3])
138
127
 
139
128
  def _execute():
140
129
  new_local = new_records or []
@@ -163,18 +152,16 @@ class Hook(metaclass=HookMeta):
163
152
  logger.exception(
164
153
  "Error in hook %s.%s", handler_cls.__name__, method_name
165
154
  )
166
- # Re-raise the exception to ensure proper error handling
167
- raise
168
155
 
156
+ conn = transaction.get_connection()
169
157
  try:
170
- _execute()
158
+ if conn.in_atomic_block and event.startswith("after_"):
159
+ transaction.on_commit(_execute)
160
+ else:
161
+ _execute()
171
162
  finally:
172
163
  hook_vars.new = None
173
164
  hook_vars.old = None
174
165
  hook_vars.event = None
175
166
  hook_vars.model = None
176
167
  hook_vars.depth -= 1
177
-
178
-
179
- # Create a global Hook instance for context access
180
- HookContext = HookContextState()
@@ -1,135 +1,113 @@
1
- from typing import Iterable, Sequence, Any
2
1
  from django.db import models
3
2
 
4
3
  from django_bulk_hooks.queryset import HookQuerySet, HookQuerySetMixin
5
4
 
6
5
 
7
6
  class BulkHookManager(models.Manager):
8
- """Manager that ensures all queryset operations are hook-aware.
7
+ def get_queryset(self):
8
+ # Use super().get_queryset() to let Django and MRO build the queryset
9
+ # This ensures cooperation with other managers
10
+ base_queryset = super().get_queryset()
11
+
12
+ # If the base queryset already has hook functionality, return it as-is
13
+ if isinstance(base_queryset, HookQuerySetMixin):
14
+ return base_queryset
15
+
16
+ # Otherwise, create a new HookQuerySet with the same parameters
17
+ # This is much simpler and avoids dynamic class creation issues
18
+ return HookQuerySet(
19
+ model=base_queryset.model,
20
+ query=base_queryset.query,
21
+ using=base_queryset._db,
22
+ hints=base_queryset._hints
23
+ )
9
24
 
10
- Delegates operations to a hook-enabled queryset while preserving any
11
- customizations from other managers in the MRO by starting with
12
- ``super().get_queryset()``.
13
- """
25
+ def bulk_create(
26
+ self,
27
+ objs,
28
+ batch_size=None,
29
+ ignore_conflicts=False,
30
+ update_conflicts=False,
31
+ update_fields=None,
32
+ unique_fields=None,
33
+ bypass_hooks=False,
34
+ bypass_validation=False,
35
+ **kwargs,
36
+ ):
37
+ """
38
+ Delegate to QuerySet's bulk_create implementation.
39
+ This follows Django's pattern where Manager methods call QuerySet methods.
40
+ """
41
+ return self.get_queryset().bulk_create(
42
+ objs,
43
+ bypass_hooks=bypass_hooks,
44
+ bypass_validation=bypass_validation,
45
+ batch_size=batch_size,
46
+ ignore_conflicts=ignore_conflicts,
47
+ update_conflicts=update_conflicts,
48
+ update_fields=update_fields,
49
+ unique_fields=unique_fields,
50
+ **kwargs,
51
+ )
14
52
 
15
- # Cache for composed queryset classes to preserve custom queryset APIs
16
- _qs_compose_cache = {}
53
+ def bulk_update(
54
+ self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
55
+ ):
56
+ """
57
+ Delegate to QuerySet's bulk_update implementation.
58
+ This follows Django's pattern where Manager methods call QuerySet methods.
59
+ """
60
+ return self.get_queryset().bulk_update(
61
+ objs,
62
+ fields,
63
+ bypass_hooks=bypass_hooks,
64
+ bypass_validation=bypass_validation,
65
+ **kwargs,
66
+ )
17
67
 
18
- def get_queryset(self) -> HookQuerySet:
19
- # Use super().get_queryset() to let Django and MRO build the queryset
20
- base_queryset = super().get_queryset()
68
+ def bulk_delete(
69
+ self,
70
+ objs,
71
+ batch_size=None,
72
+ bypass_hooks=False,
73
+ bypass_validation=False,
74
+ **kwargs,
75
+ ):
76
+ """
77
+ Delegate to QuerySet's bulk_delete implementation.
78
+ This follows Django's pattern where Manager methods call QuerySet methods.
79
+ """
80
+ return self.get_queryset().bulk_delete(
81
+ objs,
82
+ bypass_hooks=bypass_hooks,
83
+ bypass_validation=bypass_validation,
84
+ batch_size=batch_size,
85
+ **kwargs,
86
+ )
21
87
 
22
- # If the base queryset already has hook functionality, return it as-is
23
- if isinstance(base_queryset, HookQuerySetMixin):
24
- return base_queryset # type: ignore[return-value]
88
+ def delete(self):
89
+ """
90
+ Delegate to QuerySet's delete implementation.
91
+ This follows Django's pattern where Manager methods call QuerySet methods.
92
+ """
93
+ return self.get_queryset().delete()
25
94
 
26
- # Otherwise, dynamically compose a queryset class that preserves the
27
- # base queryset's custom API while adding hook behavior
28
- base_cls = base_queryset.__class__
29
- composed_cls = self._qs_compose_cache.get(base_cls)
30
- if composed_cls is None:
31
- composed_name = f"ComposedHookQuerySet_{base_cls.__name__}"
32
- composed_cls = type(composed_name, (HookQuerySetMixin, base_cls), {})
33
- self._qs_compose_cache[base_cls] = composed_cls
95
+ def update(self, **kwargs):
96
+ """
97
+ Delegate to QuerySet's update implementation.
98
+ This follows Django's pattern where Manager methods call QuerySet methods.
99
+ """
100
+ return self.get_queryset().update(**kwargs)
34
101
 
35
- return composed_cls(
36
- model=base_queryset.model,
37
- query=base_queryset.query,
38
- using=base_queryset._db,
39
- hints=base_queryset._hints,
40
- )
41
-
42
- def bulk_create(
43
- self,
44
- objs: Iterable[models.Model],
45
- batch_size: int | None = None,
46
- ignore_conflicts: bool = False,
47
- update_conflicts: bool = False,
48
- update_fields: Sequence[str] | None = None,
49
- unique_fields: Sequence[str] | None = None,
50
- bypass_hooks: bool = False,
51
- bypass_validation: bool = False,
52
- **kwargs: Any,
53
- ) -> list[models.Model]:
54
- """
55
- Delegate to QuerySet's bulk_create implementation.
56
- This follows Django's pattern where Manager methods call QuerySet methods.
57
- """
58
- return self.get_queryset().bulk_create(
59
- objs,
60
- bypass_hooks=bypass_hooks,
61
- bypass_validation=bypass_validation,
62
- batch_size=batch_size,
63
- ignore_conflicts=ignore_conflicts,
64
- update_conflicts=update_conflicts,
65
- update_fields=update_fields,
66
- unique_fields=unique_fields,
67
- **kwargs,
68
- )
69
-
70
- def bulk_update(
71
- self,
72
- objs: Iterable[models.Model],
73
- fields: Sequence[str],
74
- bypass_hooks: bool = False,
75
- bypass_validation: bool = False,
76
- **kwargs: Any,
77
- ) -> int:
78
- """
79
- Delegate to QuerySet's bulk_update implementation.
80
- This follows Django's pattern where Manager methods call QuerySet methods.
81
- """
82
- return self.get_queryset().bulk_update(
83
- objs,
84
- fields,
85
- bypass_hooks=bypass_hooks,
86
- bypass_validation=bypass_validation,
87
- **kwargs,
88
- )
89
-
90
- def bulk_delete(
91
- self,
92
- objs: Iterable[models.Model],
93
- batch_size: int | None = None,
94
- bypass_hooks: bool = False,
95
- bypass_validation: bool = False,
96
- **kwargs: Any,
97
- ) -> int:
98
- """
99
- Delegate to QuerySet's bulk_delete implementation.
100
- This follows Django's pattern where Manager methods call QuerySet methods.
101
- """
102
- return self.get_queryset().bulk_delete(
103
- objs,
104
- bypass_hooks=bypass_hooks,
105
- bypass_validation=bypass_validation,
106
- batch_size=batch_size,
107
- **kwargs,
108
- )
109
-
110
- def delete(self) -> int:
111
- """
112
- Delegate to QuerySet's delete implementation.
113
- This follows Django's pattern where Manager methods call QuerySet methods.
114
- """
115
- return self.get_queryset().delete()
116
-
117
- def update(self, **kwargs: Any) -> int:
118
- """
119
- Delegate to QuerySet's update implementation.
120
- This follows Django's pattern where Manager methods call QuerySet methods.
121
- """
122
- return self.get_queryset().update(**kwargs)
123
-
124
- def save(self, obj: models.Model) -> models.Model:
125
- """
126
- Save a single object using the appropriate bulk operation.
127
- """
128
- if obj.pk:
129
- self.bulk_update(
130
- [obj],
131
- fields=[field.name for field in obj._meta.fields if field.name != "id"],
132
- )
133
- else:
134
- self.bulk_create([obj])
135
- return obj
102
+ def save(self, obj):
103
+ """
104
+ Save a single object using the appropriate bulk operation.
105
+ """
106
+ if obj.pk:
107
+ self.bulk_update(
108
+ [obj],
109
+ fields=[field.name for field in obj._meta.fields if field.name != "id"],
110
+ )
111
+ else:
112
+ self.bulk_create([obj])
113
+ return obj
@@ -1,5 +1,6 @@
1
1
  import logging
2
- from django.db import models, transaction
2
+ from django.db import models
3
+
3
4
  from django_bulk_hooks.constants import (
4
5
  AFTER_CREATE,
5
6
  AFTER_DELETE,
@@ -41,18 +42,18 @@ class HookModelMixin(models.Model):
41
42
 
42
43
  if is_create:
43
44
  # For create operations, run VALIDATE_CREATE hooks for validation
44
- ctx = HookContext(self.__class__, bypass_hooks)
45
+ ctx = HookContext(self.__class__)
45
46
  run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
46
47
  else:
47
48
  # For update operations, run VALIDATE_UPDATE hooks for validation
48
49
  try:
49
50
  # Use _base_manager to avoid triggering hooks recursively
50
51
  old_instance = self.__class__._base_manager.get(pk=self.pk)
51
- ctx = HookContext(self.__class__, bypass_hooks)
52
+ ctx = HookContext(self.__class__)
52
53
  run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
53
54
  except self.__class__.DoesNotExist:
54
55
  # If the old instance doesn't exist, treat as create
55
- ctx = HookContext(self.__class__, bypass_hooks)
56
+ ctx = HookContext(self.__class__)
56
57
  run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
57
58
 
58
59
  def save(self, *args, bypass_hooks=False, **kwargs):
@@ -61,28 +62,12 @@ class HookModelMixin(models.Model):
61
62
  logger.debug(f"save() called with bypass_hooks=True for {self.__class__.__name__} pk={self.pk}")
62
63
  return self._base_manager.save(self, *args, **kwargs)
63
64
 
64
- # Only create a new transaction if we're not already in one
65
- # This allows for proper nested transaction handling
66
- from django.db import connection
67
- if connection.in_atomic_block:
68
- # We're already in a transaction, don't create a new one
69
- return self._save_with_hooks(*args, **kwargs)
70
- else:
71
- # We're not in a transaction, so create one
72
- with transaction.atomic():
73
- return self._save_with_hooks(*args, **kwargs)
74
-
75
- def _save_with_hooks(self, *args, **kwargs):
76
- """Internal method to handle save with hooks."""
77
65
  is_create = self.pk is None
78
-
66
+
79
67
  if is_create:
80
68
  logger.debug(f"save() creating new {self.__class__.__name__} instance")
81
69
  # For create operations, we don't have old records
82
- ctx = HookContext(self.__class__, bypass_hooks=False)
83
-
84
- # Run hooks - if any fail, the transaction will be rolled back
85
- run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
70
+ ctx = HookContext(self.__class__)
86
71
  run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
87
72
 
88
73
  super().save(*args, **kwargs)
@@ -94,10 +79,7 @@ class HookModelMixin(models.Model):
94
79
  try:
95
80
  # Use _base_manager to avoid triggering hooks recursively
96
81
  old_instance = self.__class__._base_manager.get(pk=self.pk)
97
- ctx = HookContext(self.__class__, bypass_hooks=False)
98
-
99
- # Run hooks - if any fail, the transaction will be rolled back
100
- run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
82
+ ctx = HookContext(self.__class__)
101
83
  run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
102
84
 
103
85
  super().save(*args, **kwargs)
@@ -105,10 +87,7 @@ class HookModelMixin(models.Model):
105
87
  run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
106
88
  except self.__class__.DoesNotExist:
107
89
  # If the old instance doesn't exist, treat as create
108
- ctx = HookContext(self.__class__, bypass_hooks=False)
109
-
110
- # Run hooks - if any fail, the transaction will be rolled back
111
- run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
90
+ ctx = HookContext(self.__class__)
112
91
  run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
113
92
 
114
93
  super().save(*args, **kwargs)
@@ -122,22 +101,9 @@ class HookModelMixin(models.Model):
122
101
  if bypass_hooks:
123
102
  return self._base_manager.delete(self, *args, **kwargs)
124
103
 
125
- # Only create a new transaction if we're not already in one
126
- # This allows for proper nested transaction handling
127
- from django.db import connection
128
- if connection.in_atomic_block:
129
- # We're already in a transaction, don't create a new one
130
- return self._delete_with_hooks(*args, **kwargs)
131
- else:
132
- # We're not in a transaction, so create one
133
- with transaction.atomic():
134
- return self._delete_with_hooks(*args, **kwargs)
135
-
136
- def _delete_with_hooks(self, *args, **kwargs):
137
- """Internal method to handle delete with hooks."""
138
- ctx = HookContext(self.__class__, bypass_hooks=False)
104
+ ctx = HookContext(self.__class__)
139
105
 
140
- # Run hooks - if any fail, the transaction will be rolled back
106
+ # Run validation hooks first
141
107
  run(self.__class__, VALIDATE_DELETE, [self], ctx=ctx)
142
108
 
143
109
  # Then run business logic hooks
@@ -5,12 +5,12 @@ class Priority(IntEnum):
5
5
  """
6
6
  Named priorities for django-bulk-hooks hooks.
7
7
 
8
- Higher values run earlier (higher priority).
9
- Hooks are sorted in descending order.
8
+ Lower values run earlier (higher priority).
9
+ Hooks are sorted in ascending order.
10
10
  """
11
11
 
12
- LOWEST = 0 # runs last
13
- LOW = 25 # runs later
12
+ HIGHEST = 0 # runs first
13
+ HIGH = 25 # runs early
14
14
  NORMAL = 50 # default ordering
15
- HIGH = 75 # runs early
16
- HIGHEST = 100 # runs first
15
+ LOW = 75 # runs later
16
+ LOWEST = 100 # runs last