django-bulk-hooks 0.2.69__tar.gz → 0.2.71__tar.gz

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.

Files changed (27) hide show
  1. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/PKG-INFO +55 -4
  2. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/README.md +54 -3
  3. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/dispatcher.py +58 -2
  4. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/manager.py +5 -15
  5. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/operations/coordinator.py +3 -0
  6. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/queryset.py +51 -0
  7. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/pyproject.toml +1 -1
  8. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/LICENSE +0 -0
  9. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/__init__.py +0 -0
  10. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/changeset.py +0 -0
  11. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/conditions.py +0 -0
  12. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/constants.py +0 -0
  13. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/context.py +0 -0
  14. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/decorators.py +0 -0
  15. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/enums.py +0 -0
  16. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/factory.py +0 -0
  17. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/handler.py +0 -0
  18. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/helpers.py +0 -0
  19. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/models.py +0 -0
  20. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/operations/__init__.py +0 -0
  21. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/operations/analyzer.py +0 -0
  22. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/operations/bulk_executor.py +0 -0
  23. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/operations/field_utils.py +0 -0
  24. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/operations/mti_handler.py +0 -0
  25. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/operations/mti_plans.py +0 -0
  26. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/operations/record_classifier.py +0 -0
  27. {django_bulk_hooks-0.2.69 → django_bulk_hooks-0.2.71}/django_bulk_hooks/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.2.69
3
+ Version: 0.2.71
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  License: MIT
6
6
  Keywords: django,bulk,hooks
@@ -244,17 +244,68 @@ LoanAccount.objects.bulk_update(reordered) # fields are auto-detected
244
244
 
245
245
  ## 🧩 Integration with Other Managers
246
246
 
247
- You can extend from `BulkHookManager` to work with other manager classes. The manager uses a cooperative approach that dynamically injects bulk hook functionality into any queryset, ensuring compatibility with other managers.
247
+ ### Recommended: QuerySet-based Composition (New Approach)
248
+
249
+ For the best compatibility and to avoid inheritance conflicts, use the queryset-based composition approach:
250
+
251
+ ```python
252
+ from django_bulk_hooks.queryset import HookQuerySet
253
+ from queryable_properties.managers import QueryablePropertiesManager
254
+
255
+ class MyManager(QueryablePropertiesManager):
256
+ """Manager that combines queryable properties with hooks"""
257
+
258
+ def get_queryset(self):
259
+ # Get the QueryableProperties QuerySet
260
+ qs = super().get_queryset()
261
+ # Apply hooks on top of it
262
+ return HookQuerySet.with_hooks(qs)
263
+
264
+ class Article(models.Model):
265
+ title = models.CharField(max_length=100)
266
+ published = models.BooleanField(default=False)
267
+
268
+ objects = MyManager()
269
+
270
+ # This gives you both queryable properties AND hooks
271
+ # No inheritance conflicts, no MRO issues!
272
+ ```
273
+
274
+ ### Alternative: Explicit Hook Application
275
+
276
+ For more control, you can apply hooks explicitly:
277
+
278
+ ```python
279
+ class MyManager(QueryablePropertiesManager):
280
+ def get_queryset(self):
281
+ return super().get_queryset()
282
+
283
+ def with_hooks(self):
284
+ """Apply hooks to this queryset"""
285
+ return HookQuerySet.with_hooks(self.get_queryset())
286
+
287
+ # Usage:
288
+ Article.objects.with_hooks().filter(published=True).update(title="Updated")
289
+ ```
290
+
291
+ ### Legacy: Manager Inheritance (Not Recommended)
292
+
293
+ The old inheritance approach still works but is not recommended due to potential MRO conflicts:
248
294
 
249
295
  ```python
250
296
  from django_bulk_hooks.manager import BulkHookManager
251
297
  from queryable_properties.managers import QueryablePropertiesManager
252
298
 
253
299
  class MyManager(BulkHookManager, QueryablePropertiesManager):
254
- pass
300
+ pass # ⚠️ Can cause inheritance conflicts
255
301
  ```
256
302
 
257
- This approach uses the industry-standard injection pattern, similar to how `QueryablePropertiesManager` works, ensuring both functionalities work seamlessly together without any framework-specific knowledge.
303
+ **Why the new approach is better:**
304
+ - ✅ No inheritance conflicts
305
+ - ✅ No MRO (Method Resolution Order) issues
306
+ - ✅ Works with any manager combination
307
+ - ✅ Cleaner and more maintainable
308
+ - ✅ Follows Django's queryset enhancement patterns
258
309
 
259
310
  Framework needs to:
260
311
  Register these methods
@@ -225,17 +225,68 @@ LoanAccount.objects.bulk_update(reordered) # fields are auto-detected
225
225
 
226
226
  ## 🧩 Integration with Other Managers
227
227
 
228
- You can extend from `BulkHookManager` to work with other manager classes. The manager uses a cooperative approach that dynamically injects bulk hook functionality into any queryset, ensuring compatibility with other managers.
228
+ ### Recommended: QuerySet-based Composition (New Approach)
229
+
230
+ For the best compatibility and to avoid inheritance conflicts, use the queryset-based composition approach:
231
+
232
+ ```python
233
+ from django_bulk_hooks.queryset import HookQuerySet
234
+ from queryable_properties.managers import QueryablePropertiesManager
235
+
236
+ class MyManager(QueryablePropertiesManager):
237
+ """Manager that combines queryable properties with hooks"""
238
+
239
+ def get_queryset(self):
240
+ # Get the QueryableProperties QuerySet
241
+ qs = super().get_queryset()
242
+ # Apply hooks on top of it
243
+ return HookQuerySet.with_hooks(qs)
244
+
245
+ class Article(models.Model):
246
+ title = models.CharField(max_length=100)
247
+ published = models.BooleanField(default=False)
248
+
249
+ objects = MyManager()
250
+
251
+ # This gives you both queryable properties AND hooks
252
+ # No inheritance conflicts, no MRO issues!
253
+ ```
254
+
255
+ ### Alternative: Explicit Hook Application
256
+
257
+ For more control, you can apply hooks explicitly:
258
+
259
+ ```python
260
+ class MyManager(QueryablePropertiesManager):
261
+ def get_queryset(self):
262
+ return super().get_queryset()
263
+
264
+ def with_hooks(self):
265
+ """Apply hooks to this queryset"""
266
+ return HookQuerySet.with_hooks(self.get_queryset())
267
+
268
+ # Usage:
269
+ Article.objects.with_hooks().filter(published=True).update(title="Updated")
270
+ ```
271
+
272
+ ### Legacy: Manager Inheritance (Not Recommended)
273
+
274
+ The old inheritance approach still works but is not recommended due to potential MRO conflicts:
229
275
 
230
276
  ```python
231
277
  from django_bulk_hooks.manager import BulkHookManager
232
278
  from queryable_properties.managers import QueryablePropertiesManager
233
279
 
234
280
  class MyManager(BulkHookManager, QueryablePropertiesManager):
235
- pass
281
+ pass # ⚠️ Can cause inheritance conflicts
236
282
  ```
237
283
 
238
- This approach uses the industry-standard injection pattern, similar to how `QueryablePropertiesManager` works, ensuring both functionalities work seamlessly together without any framework-specific knowledge.
284
+ **Why the new approach is better:**
285
+ - ✅ No inheritance conflicts
286
+ - ✅ No MRO (Method Resolution Order) issues
287
+ - ✅ Works with any manager combination
288
+ - ✅ Cleaner and more maintainable
289
+ - ✅ Follows Django's queryset enhancement patterns
239
290
 
240
291
  Framework needs to:
241
292
  Register these methods
@@ -106,12 +106,68 @@ class HookDispatcher:
106
106
  if not hooks:
107
107
  return
108
108
 
109
- # Execute hooks in priority order
110
- logger.info(f"🔥 HOOKS: Executing {len(hooks)} hooks for {changeset.model_cls.__name__}.{event}")
109
+ # Create an operation key that includes the changeset model to avoid
110
+ # deduplicating hooks across different operations on the same records
111
+ # This prevents the same hook from executing multiple times for MTI inheritance chains
112
+ # but allows different operations on the same records to execute their hooks
113
+ record_ids = set()
114
+ for change in changeset.changes:
115
+ if change.new_record and change.new_record.pk:
116
+ record_ids.add(change.new_record.pk)
117
+ if change.old_record and change.old_record.pk:
118
+ record_ids.add(change.old_record.pk)
119
+
120
+ # Sort record IDs safely (handle Mock objects and other non-comparable types)
121
+ try:
122
+ sorted_record_ids = tuple(sorted(record_ids, key=lambda x: str(x)))
123
+ except (TypeError, AttributeError):
124
+ # Fallback for non-comparable objects (like Mock objects in tests)
125
+ sorted_record_ids = tuple(record_ids)
126
+
127
+ # Include changeset model and operation details to make the key more specific
128
+ operation_meta = getattr(changeset, 'operation_meta', {}) or {}
129
+ operation_type = getattr(changeset, 'operation_type', 'unknown')
130
+
131
+ # Include update_kwargs if present to distinguish different queryset operations
132
+ update_kwargs = operation_meta.get('update_kwargs', {})
133
+ if update_kwargs:
134
+ try:
135
+ # Convert to a hashable representation
136
+ update_kwargs_key = tuple(sorted((k, str(v)) for k, v in update_kwargs.items()))
137
+ except (TypeError, AttributeError):
138
+ # Fallback if values are not convertible to string
139
+ update_kwargs_key = tuple(sorted(update_kwargs.keys()))
140
+ else:
141
+ update_kwargs_key = ()
142
+
143
+ operation_key = (event, changeset.model_cls.__name__, operation_type, sorted_record_ids, update_kwargs_key)
144
+
145
+ # Track executed hooks to prevent duplicates in MTI inheritance chains
146
+ if not hasattr(self, '_executed_hooks'):
147
+ self._executed_hooks = set()
148
+
149
+ # Filter out hooks that have already been executed for this operation
150
+ unique_hooks = []
111
151
  for handler_cls, method_name, condition, priority in hooks:
152
+ hook_key = (handler_cls, method_name, operation_key)
153
+ if hook_key not in self._executed_hooks:
154
+ unique_hooks.append((handler_cls, method_name, condition, priority))
155
+ self._executed_hooks.add(hook_key)
156
+
157
+
158
+ if not unique_hooks:
159
+ return
160
+
161
+ # Execute hooks in priority order
162
+ logger.info(f"🔥 HOOKS: Executing {len(unique_hooks)} hooks for {changeset.model_cls.__name__}.{event}")
163
+ for handler_cls, method_name, condition, priority in unique_hooks:
112
164
  logger.info(f" → {handler_cls.__name__}.{method_name} (priority={priority})")
113
165
  self._execute_hook(handler_cls, method_name, condition, changeset, event)
114
166
 
167
+ def _reset_executed_hooks(self):
168
+ """Reset the executed hooks tracking for a new operation."""
169
+ self._executed_hooks = set()
170
+
115
171
  def _execute_hook(self, handler_cls, method_name, condition, changeset, event):
116
172
  """
117
173
  Execute a single hook with condition checking.
@@ -21,29 +21,19 @@ class BulkHookManager(models.Manager):
21
21
  """
22
22
  Manager that provides hook-aware bulk operations.
23
23
 
24
- This is a simple facade that returns HookQuerySet,
25
- delegating all bulk operations to it.
24
+ This manager automatically applies hook functionality to its querysets.
25
+ It can be used as a base class or composed with other managers using
26
+ the queryset-based approach.
26
27
  """
27
28
 
28
29
  def get_queryset(self):
29
30
  """
30
31
  Return a HookQuerySet for this manager.
31
32
 
32
- This ensures all bulk operations go through the coordinator.
33
+ Uses the new with_hooks() method for better composition with other managers.
33
34
  """
34
35
  base_queryset = super().get_queryset()
35
-
36
- # If the base queryset is already a HookQuerySet, return it as-is
37
- if isinstance(base_queryset, HookQuerySet):
38
- return base_queryset
39
-
40
- # Otherwise, create a new HookQuerySet with the same parameters
41
- return HookQuerySet(
42
- model=base_queryset.model,
43
- query=base_queryset.query,
44
- using=base_queryset._db,
45
- hints=base_queryset._hints,
46
- )
36
+ return HookQuerySet.with_hooks(base_queryset)
47
37
 
48
38
  def bulk_create(
49
39
  self,
@@ -700,6 +700,9 @@ class BulkOperationCoordinator:
700
700
  if bypass_hooks:
701
701
  return operation()
702
702
 
703
+ # Reset hook execution tracking for this new operation
704
+ self.dispatcher._reset_executed_hooks()
705
+
703
706
  # Get all models in inheritance chain
704
707
  models_in_chain = self._get_models_in_chain(changeset.model_cls)
705
708
 
@@ -35,6 +35,57 @@ class HookQuerySet(models.QuerySet):
35
35
  super().__init__(*args, **kwargs)
36
36
  self._coordinator = None
37
37
 
38
+ @classmethod
39
+ def with_hooks(cls, queryset):
40
+ """
41
+ Apply hook functionality to any queryset.
42
+
43
+ This enables hooks to work with any manager by applying hook
44
+ capabilities at the queryset level rather than through inheritance.
45
+
46
+ Args:
47
+ queryset: Any Django QuerySet instance
48
+
49
+ Returns:
50
+ HookQuerySet instance with the same query parameters
51
+ """
52
+ if isinstance(queryset, cls):
53
+ return queryset # Already has hooks
54
+
55
+ # Create a new HookQuerySet with the same parameters as the original queryset
56
+ hook_qs = cls(
57
+ model=queryset.model,
58
+ query=queryset.query,
59
+ using=queryset._db,
60
+ hints=getattr(queryset, '_hints', {}),
61
+ )
62
+
63
+ # Preserve any additional attributes from the original queryset
64
+ # This allows composition with other queryset enhancements
65
+ cls._preserve_queryset_attributes(hook_qs, queryset)
66
+
67
+ return hook_qs
68
+
69
+ @classmethod
70
+ def _preserve_queryset_attributes(cls, hook_qs, original_qs):
71
+ """
72
+ Preserve attributes from the original queryset.
73
+
74
+ This enables composition with other queryset enhancements like
75
+ queryable properties, annotations, etc.
76
+ """
77
+ # Copy non-method attributes that might be set by other managers
78
+ for attr_name in dir(original_qs):
79
+ if (not attr_name.startswith('_') and
80
+ not hasattr(cls, attr_name) and
81
+ not callable(getattr(original_qs, attr_name, None))):
82
+ try:
83
+ value = getattr(original_qs, attr_name)
84
+ setattr(hook_qs, attr_name, value)
85
+ except (AttributeError, TypeError):
86
+ # Skip attributes that can't be copied
87
+ continue
88
+
38
89
  @property
39
90
  def coordinator(self):
40
91
  """Lazy initialization of coordinator"""
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.2.69"
3
+ version = "0.2.71"
4
4
  description = "Hook-style hooks for Django bulk operations like bulk_create and bulk_update."
5
5
  authors = ["Konrad Beck <konrad.beck@merchantcapital.co.za>"]
6
6
  readme = "README.md"