django-bulk-hooks 0.1.102__py3-none-any.whl → 0.1.103__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.

@@ -17,113 +17,10 @@ from django_bulk_hooks.queryset import HookQuerySet
17
17
 
18
18
 
19
19
  class BulkHookManager(models.Manager):
20
- # Default chunk sizes - can be overridden per model
21
- DEFAULT_CHUNK_SIZE = 200
22
- DEFAULT_RELATED_CHUNK_SIZE = 500 # Higher for related object fetching
23
-
24
- def __init__(self):
25
- super().__init__()
26
- self._chunk_size = self.DEFAULT_CHUNK_SIZE
27
- self._related_chunk_size = self.DEFAULT_RELATED_CHUNK_SIZE
28
- self._prefetch_related_fields = set()
29
- self._select_related_fields = set()
30
-
31
- def configure(
32
- self,
33
- chunk_size=None,
34
- related_chunk_size=None,
35
- select_related=None,
36
- prefetch_related=None,
37
- ):
38
- """
39
- Configure bulk operation parameters for this manager.
40
-
41
- Args:
42
- chunk_size: Number of objects to process in each bulk operation chunk
43
- related_chunk_size: Number of objects to fetch in each related object query
44
- select_related: List of fields to always select_related in bulk operations
45
- prefetch_related: List of fields to always prefetch_related in bulk operations
46
- """
47
- if chunk_size is not None:
48
- self._chunk_size = chunk_size
49
- if related_chunk_size is not None:
50
- self._related_chunk_size = related_chunk_size
51
- if select_related:
52
- self._select_related_fields.update(select_related)
53
- if prefetch_related:
54
- self._prefetch_related_fields.update(prefetch_related)
55
-
56
- def _load_originals_optimized(self, pks, fields_to_fetch=None):
57
- """
58
- Optimized loading of original instances with smart batching and field selection.
59
- """
60
- queryset = self.model.objects.filter(pk__in=pks)
61
-
62
- # Only select specific fields if provided and not empty
63
- if fields_to_fetch and len(fields_to_fetch) > 0:
64
- queryset = queryset.only("pk", *fields_to_fetch)
65
-
66
- # Apply configured related field optimizations
67
- if self._select_related_fields:
68
- queryset = queryset.select_related(*self._select_related_fields)
69
- if self._prefetch_related_fields:
70
- queryset = queryset.prefetch_related(*self._prefetch_related_fields)
71
-
72
- # Batch load in chunks to avoid memory issues
73
- all_originals = []
74
- for i in range(0, len(pks), self._related_chunk_size):
75
- chunk_pks = pks[i : i + self._related_chunk_size]
76
- chunk_originals = list(queryset.filter(pk__in=chunk_pks))
77
- all_originals.extend(chunk_originals)
20
+ CHUNK_SIZE = 200
78
21
 
79
- return all_originals
80
-
81
- def _get_fields_to_fetch(self, objs, fields):
82
- """
83
- Determine which fields need to be fetched based on what's being updated
84
- and what's needed for hooks.
85
- """
86
- fields_to_fetch = set(fields)
87
-
88
- # Add fields needed by registered hooks
89
- from django_bulk_hooks.registry import get_hooks
90
-
91
- hooks = get_hooks(self.model, "before_update") + get_hooks(
92
- self.model, "after_update"
93
- )
94
-
95
- for handler_cls, method_name, condition, _ in hooks:
96
- if condition:
97
- # If there's a condition, we need all fields it might access
98
- fields_to_fetch.update(condition.get_required_fields())
99
-
100
- # Filter out fields that don't exist on the model
101
- valid_fields = set()
102
- invalid_fields = set()
103
- for field_name in fields_to_fetch:
104
- try:
105
- self.model._meta.get_field(field_name)
106
- valid_fields.add(field_name)
107
- except Exception as e:
108
- # Field doesn't exist, skip it
109
- invalid_fields.add(field_name)
110
- import logging
111
-
112
- logger = logging.getLogger(__name__)
113
- logger.debug(
114
- f"Field '{field_name}' requested by hook condition but doesn't exist on {self.model.__name__}: {e}"
115
- )
116
- continue
117
-
118
- if invalid_fields:
119
- import logging
120
- logger = logging.getLogger(__name__)
121
- logger.warning(
122
- f"Invalid fields requested for {self.model.__name__}: {invalid_fields}. "
123
- f"These fields were ignored to prevent errors."
124
- )
125
-
126
- return valid_fields
22
+ def get_queryset(self):
23
+ return HookQuerySet(self.model, using=self._db)
127
24
 
128
25
  @transaction.atomic
129
26
  def bulk_update(
@@ -140,42 +37,36 @@ class BulkHookManager(models.Manager):
140
37
  )
141
38
 
142
39
  if not bypass_hooks:
143
- # Determine which fields we need to fetch
144
- fields_to_fetch = self._get_fields_to_fetch(objs, fields)
145
-
146
- # Load originals efficiently
147
- pks = [obj.pk for obj in objs if obj.pk is not None]
148
- originals = self._load_originals_optimized(pks, fields_to_fetch)
149
-
150
- # Create a mapping for quick lookup
151
- original_map = {obj.pk: obj for obj in originals}
152
-
153
- # Align originals with new instances
154
- aligned_originals = [original_map.get(obj.pk) for obj in objs]
40
+ # Load originals for hook comparison and ensure they match the order of new instances
41
+ original_map = {
42
+ obj.pk: obj for obj in model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
43
+ }
44
+ originals = [original_map.get(obj.pk) for obj in objs]
155
45
 
156
46
  ctx = HookContext(model_cls)
157
47
 
158
48
  # Run validation hooks first
159
49
  if not bypass_validation:
160
- engine.run(model_cls, VALIDATE_UPDATE, objs, aligned_originals, ctx=ctx)
50
+ engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
161
51
 
162
52
  # Then run business logic hooks
163
- engine.run(model_cls, BEFORE_UPDATE, objs, aligned_originals, ctx=ctx)
53
+ engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
164
54
 
165
55
  # Automatically detect fields that were modified during BEFORE_UPDATE hooks
166
- modified_fields = self._detect_modified_fields(objs, aligned_originals)
56
+ modified_fields = self._detect_modified_fields(objs, originals)
167
57
  if modified_fields:
58
+ # Convert to set for efficient union operation
168
59
  fields_set = set(fields)
169
60
  fields_set.update(modified_fields)
170
61
  fields = list(fields_set)
171
62
 
172
- # Process in chunks
173
- for i in range(0, len(objs), self._chunk_size):
174
- chunk = objs[i : i + self._chunk_size]
63
+ for i in range(0, len(objs), self.CHUNK_SIZE):
64
+ chunk = objs[i : i + self.CHUNK_SIZE]
65
+ # Call the base implementation to avoid re-triggering this method
175
66
  super(models.Manager, self).bulk_update(chunk, fields, **kwargs)
176
67
 
177
68
  if not bypass_hooks:
178
- engine.run(model_cls, AFTER_UPDATE, objs, aligned_originals, ctx=ctx)
69
+ engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
179
70
 
180
71
  return objs
181
72
 
@@ -187,17 +78,11 @@ class BulkHookManager(models.Manager):
187
78
  if not original_instances:
188
79
  return set()
189
80
 
190
- # Create a mapping of pk to original instance for efficient lookup
191
- original_map = {obj.pk: obj for obj in original_instances if obj.pk is not None}
192
-
193
81
  modified_fields = set()
194
82
 
195
- for new_instance in new_instances:
196
- if new_instance.pk is None:
197
- continue
198
-
199
- original = original_map.get(new_instance.pk)
200
- if not original:
83
+ # Since original_instances is now ordered to match new_instances, we can zip them directly
84
+ for new_instance, original in zip(new_instances, original_instances):
85
+ if new_instance.pk is None or original is None:
201
86
  continue
202
87
 
203
88
  # Compare all fields to detect changes
@@ -224,42 +109,31 @@ class BulkHookManager(models.Manager):
224
109
 
225
110
  @transaction.atomic
226
111
  def bulk_create(self, objs, bypass_hooks=False, bypass_validation=False, **kwargs):
227
- if not objs:
228
- return []
229
-
230
112
  model_cls = self.model
231
- result = []
232
113
 
233
114
  if any(not isinstance(obj, model_cls) for obj in objs):
234
115
  raise TypeError(
235
116
  f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
236
117
  )
237
118
 
119
+ result = []
120
+
238
121
  if not bypass_hooks:
239
122
  ctx = HookContext(model_cls)
240
123
 
241
- # Process validation in chunks to avoid memory issues
124
+ # Run validation hooks first
242
125
  if not bypass_validation:
243
- for i in range(0, len(objs), self._chunk_size):
244
- chunk = objs[i : i + self._chunk_size]
245
- engine.run(model_cls, VALIDATE_CREATE, chunk, ctx=ctx)
126
+ engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
246
127
 
247
- # Process before_create hooks in chunks
248
- for i in range(0, len(objs), self._chunk_size):
249
- chunk = objs[i : i + self._chunk_size]
250
- engine.run(model_cls, BEFORE_CREATE, chunk, ctx=ctx)
128
+ # Then run business logic hooks
129
+ engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
251
130
 
252
- # Perform bulk create in chunks
253
- for i in range(0, len(objs), self._chunk_size):
254
- chunk = objs[i : i + self._chunk_size]
255
- created_chunk = super(models.Manager, self).bulk_create(chunk, **kwargs)
256
- result.extend(created_chunk)
131
+ for i in range(0, len(objs), self.CHUNK_SIZE):
132
+ chunk = objs[i : i + self.CHUNK_SIZE]
133
+ result.extend(super(models.Manager, self).bulk_create(chunk, **kwargs))
257
134
 
258
135
  if not bypass_hooks:
259
- # Process after_create hooks in chunks
260
- for i in range(0, len(result), self._chunk_size):
261
- chunk = result[i : i + self._chunk_size]
262
- engine.run(model_cls, AFTER_CREATE, chunk, ctx=ctx)
136
+ engine.run(model_cls, AFTER_CREATE, result, ctx=ctx)
263
137
 
264
138
  return result
265
139
 
@@ -271,7 +145,6 @@ class BulkHookManager(models.Manager):
271
145
  return []
272
146
 
273
147
  model_cls = self.model
274
- chunk_size = batch_size or self._chunk_size
275
148
 
276
149
  if any(not isinstance(obj, model_cls) for obj in objs):
277
150
  raise TypeError(
@@ -281,25 +154,21 @@ class BulkHookManager(models.Manager):
281
154
  ctx = HookContext(model_cls)
282
155
 
283
156
  if not bypass_hooks:
284
- # Process hooks in chunks
285
- for i in range(0, len(objs), chunk_size):
286
- chunk = objs[i : i + chunk_size]
157
+ # Run validation hooks first
158
+ if not bypass_validation:
159
+ engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
287
160
 
288
- if not bypass_validation:
289
- engine.run(model_cls, VALIDATE_DELETE, chunk, ctx=ctx)
290
- engine.run(model_cls, BEFORE_DELETE, chunk, ctx=ctx)
161
+ # Then run business logic hooks
162
+ engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
291
163
 
292
- # Collect PKs and delete in chunks
293
164
  pks = [obj.pk for obj in objs if obj.pk is not None]
294
- for i in range(0, len(pks), chunk_size):
295
- chunk_pks = pks[i : i + chunk_size]
296
- model_cls._base_manager.filter(pk__in=chunk_pks).delete()
165
+
166
+ # Use base manager for the actual deletion to prevent recursion
167
+ # The hooks have already been fired above, so we don't need them again
168
+ model_cls._base_manager.filter(pk__in=pks).delete()
297
169
 
298
170
  if not bypass_hooks:
299
- # Process after_delete hooks in chunks
300
- for i in range(0, len(objs), chunk_size):
301
- chunk = objs[i : i + chunk_size]
302
- engine.run(model_cls, AFTER_DELETE, chunk, ctx=ctx)
171
+ engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
303
172
 
304
173
  return objs
305
174
 
@@ -14,35 +14,6 @@ from django_bulk_hooks.constants import (
14
14
  from django_bulk_hooks.context import HookContext
15
15
  from django_bulk_hooks.engine import run
16
16
  from django_bulk_hooks.manager import BulkHookManager
17
- from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor
18
- from functools import wraps
19
- import contextlib
20
-
21
-
22
- @contextlib.contextmanager
23
- def patch_foreign_key_behavior():
24
- """
25
- Temporarily patches Django's foreign key descriptor to return None instead of raising
26
- RelatedObjectDoesNotExist when accessing an unset foreign key field.
27
- """
28
- original_get = ForwardManyToOneDescriptor.__get__
29
-
30
- @wraps(original_get)
31
- def safe_get(self, instance, cls=None):
32
- if instance is None:
33
- return self
34
- try:
35
- return original_get(self, instance, cls)
36
- except self.RelatedObjectDoesNotExist:
37
- return None
38
-
39
- # Patch the descriptor
40
- ForwardManyToOneDescriptor.__get__ = safe_get
41
- try:
42
- yield
43
- finally:
44
- # Restore original behavior
45
- ForwardManyToOneDescriptor.__get__ = original_get
46
17
 
47
18
 
48
19
  class HookModelMixin(models.Model):
@@ -57,70 +28,68 @@ class HookModelMixin(models.Model):
57
28
  This ensures that when Django calls clean() (like in admin forms),
58
29
  it triggers the VALIDATE_* hooks for validation only.
59
30
  """
60
- # Call Django's clean first
61
31
  super().clean()
62
32
 
63
- # Skip hook validation during admin form validation
64
- # This prevents RelatedObjectDoesNotExist errors when Django hasn't
65
- # fully set up the object's relationships yet
66
- if hasattr(self, '_state') and getattr(self._state, 'validating', False):
67
- return
68
-
69
33
  # Determine if this is a create or update operation
70
34
  is_create = self.pk is None
71
35
 
72
36
  if is_create:
73
37
  # For create operations, run VALIDATE_CREATE hooks for validation
74
38
  ctx = HookContext(self.__class__)
75
- with patch_foreign_key_behavior():
76
- run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
39
+ run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
77
40
  else:
78
41
  # For update operations, run VALIDATE_UPDATE hooks for validation
79
42
  try:
80
43
  old_instance = self.__class__.objects.get(pk=self.pk)
81
44
  ctx = HookContext(self.__class__)
82
- with patch_foreign_key_behavior():
83
- run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
45
+ run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
84
46
  except self.__class__.DoesNotExist:
85
47
  # If the old instance doesn't exist, treat as create
86
48
  ctx = HookContext(self.__class__)
87
- with patch_foreign_key_behavior():
88
- run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
49
+ run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
89
50
 
90
51
  def save(self, *args, **kwargs):
91
52
  is_create = self.pk is None
92
- ctx = HookContext(self.__class__)
93
53
 
94
- # Use a single context manager for all hooks
95
- with patch_foreign_key_behavior():
96
- if is_create:
97
- # For create operations
54
+ if is_create:
55
+ # For create operations, we don't have old records
56
+ ctx = HookContext(self.__class__)
57
+ run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
58
+
59
+ super().save(*args, **kwargs)
60
+
61
+ run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
62
+ else:
63
+ # For update operations, we need to get the old record
64
+ try:
65
+ old_instance = self.__class__.objects.get(pk=self.pk)
66
+ ctx = HookContext(self.__class__)
67
+ run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
68
+
69
+ super().save(*args, **kwargs)
70
+
71
+ run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
72
+ except self.__class__.DoesNotExist:
73
+ # If the old instance doesn't exist, treat as create
74
+ ctx = HookContext(self.__class__)
98
75
  run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
76
+
99
77
  super().save(*args, **kwargs)
78
+
100
79
  run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
101
- else:
102
- # For update operations
103
- try:
104
- old_instance = self.__class__.objects.get(pk=self.pk)
105
- run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
106
- super().save(*args, **kwargs)
107
- run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
108
- except self.__class__.DoesNotExist:
109
- # If the old instance doesn't exist, treat as create
110
- run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
111
- super().save(*args, **kwargs)
112
- run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
113
80
 
114
81
  return self
115
82
 
116
83
  def delete(self, *args, **kwargs):
117
84
  ctx = HookContext(self.__class__)
118
85
 
119
- # Use a single context manager for all hooks
120
- with patch_foreign_key_behavior():
121
- run(self.__class__, VALIDATE_DELETE, [self], ctx=ctx)
122
- run(self.__class__, BEFORE_DELETE, [self], ctx=ctx)
123
- result = super().delete(*args, **kwargs)
124
- run(self.__class__, AFTER_DELETE, [self], ctx=ctx)
125
-
86
+ # Run validation hooks first
87
+ run(self.__class__, VALIDATE_DELETE, [self], ctx=ctx)
88
+
89
+ # Then run business logic hooks
90
+ run(self.__class__, BEFORE_DELETE, [self], ctx=ctx)
91
+
92
+ result = super().delete(*args, **kwargs)
93
+
94
+ run(self.__class__, AFTER_DELETE, [self], ctx=ctx)
126
95
  return result
@@ -0,0 +1,16 @@
1
+ from enum import IntEnum
2
+
3
+
4
+ class Priority(IntEnum):
5
+ """
6
+ Named priorities for django-bulk-hooks hooks.
7
+
8
+ Lower values run earlier (higher priority).
9
+ Hooks are sorted in ascending order.
10
+ """
11
+
12
+ HIGHEST = 0 # runs first
13
+ HIGH = 25 # runs early
14
+ NORMAL = 50 # default ordering
15
+ LOW = 75 # runs later
16
+ LOWEST = 100 # runs last
@@ -18,8 +18,9 @@ class HookQuerySet(models.QuerySet):
18
18
  model_cls = self.model
19
19
  pks = [obj.pk for obj in instances]
20
20
 
21
- # Load originals for hook comparison
22
- originals = list(model_cls.objects.filter(pk__in=pks))
21
+ # Load originals for hook comparison and ensure they match the order of instances
22
+ original_map = {obj.pk: obj for obj in model_cls.objects.filter(pk__in=pks)}
23
+ originals = [original_map.get(obj.pk) for obj in instances]
23
24
 
24
25
  # Apply field updates to instances
25
26
  for obj in instances:
@@ -1,7 +1,7 @@
1
1
  from collections.abc import Callable
2
2
  from typing import Union
3
3
 
4
- from django_bulk_hooks.enums import Priority
4
+ from django_bulk_hooks.priority import Priority
5
5
 
6
6
  _hooks: dict[tuple[type, str], list[tuple[type, str, Callable, int]]] = {}
7
7
 
@@ -17,7 +17,8 @@ def register_hook(
17
17
 
18
18
 
19
19
  def get_hooks(model, event):
20
- return _hooks.get((model, event), [])
20
+ hooks = _hooks.get((model, event), [])
21
+ return hooks
21
22
 
22
23
 
23
24
  def list_all_hooks():