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

@@ -103,15 +103,83 @@ class HookDispatcher:
103
103
  # Get hooks sorted by priority (deterministic order)
104
104
  hooks = self.registry.get_hooks(changeset.model_cls, event)
105
105
 
106
+ logger.debug(f"🧵 DISPATCH: changeset.model_cls={changeset.model_cls.__name__}, event={event}")
107
+ logger.debug(f"🎣 HOOKS_FOUND: {len(hooks)} hooks for {changeset.model_cls.__name__}.{event}: {[f'{h[0].__name__}.{h[1]}' for h in hooks]}")
108
+
106
109
  if not hooks:
107
110
  return
108
111
 
109
- # Execute hooks in priority order
110
- logger.info(f"🔥 HOOKS: Executing {len(hooks)} hooks for {changeset.model_cls.__name__}.{event}")
112
+ # Create an operation key that includes the changeset model to avoid
113
+ # deduplicating hooks across different operations on the same records
114
+ # This prevents the same hook from executing multiple times for MTI inheritance chains
115
+ # but allows different operations on the same records to execute their hooks
116
+ record_ids = set()
117
+ for change in changeset.changes:
118
+ if change.new_record and change.new_record.pk:
119
+ record_ids.add(change.new_record.pk)
120
+ if change.old_record and change.old_record.pk:
121
+ record_ids.add(change.old_record.pk)
122
+
123
+ # Sort record IDs safely (handle Mock objects and other non-comparable types)
124
+ try:
125
+ sorted_record_ids = tuple(sorted(record_ids, key=lambda x: str(x)))
126
+ except (TypeError, AttributeError):
127
+ # Fallback for non-comparable objects (like Mock objects in tests)
128
+ sorted_record_ids = tuple(record_ids)
129
+
130
+ # Include changeset model and operation details to make the key more specific
131
+ operation_meta = getattr(changeset, 'operation_meta', {}) or {}
132
+ operation_type = getattr(changeset, 'operation_type', 'unknown')
133
+
134
+ # Include update_kwargs if present to distinguish different queryset operations
135
+ update_kwargs = operation_meta.get('update_kwargs', {})
136
+ if update_kwargs:
137
+ try:
138
+ # Convert to a hashable representation
139
+ update_kwargs_key = tuple(sorted((k, str(v)) for k, v in update_kwargs.items()))
140
+ except (TypeError, AttributeError):
141
+ # Fallback if values are not convertible to string
142
+ update_kwargs_key = tuple(sorted(update_kwargs.keys()))
143
+ else:
144
+ update_kwargs_key = ()
145
+
146
+ operation_key = (event, changeset.model_cls.__name__, operation_type, sorted_record_ids, update_kwargs_key)
147
+
148
+ # Track executed hooks to prevent duplicates in MTI inheritance chains
149
+ if not hasattr(self, '_executed_hooks'):
150
+ self._executed_hooks = set()
151
+
152
+ # Filter out hooks that have already been executed for this operation
153
+ unique_hooks = []
154
+ skipped_hooks = []
111
155
  for handler_cls, method_name, condition, priority in hooks:
156
+ hook_key = (handler_cls, method_name, operation_key)
157
+ if hook_key not in self._executed_hooks:
158
+ unique_hooks.append((handler_cls, method_name, condition, priority))
159
+ self._executed_hooks.add(hook_key)
160
+ else:
161
+ skipped_hooks.append((handler_cls.__name__, method_name))
162
+
163
+ # Debug logging for hook deduplication
164
+ if skipped_hooks:
165
+ logger.debug(f"⏭️ SKIPPED_DUPS: {len(skipped_hooks)} duplicate hooks: {[f'{cls}.{method}' for cls, method in skipped_hooks]}")
166
+
167
+ if unique_hooks:
168
+ logger.debug(f"✅ EXECUTING_UNIQUE: {len(unique_hooks)} unique hooks: {[f'{h[0].__name__}.{h[1]}' for h in unique_hooks]}")
169
+
170
+ if not unique_hooks:
171
+ return
172
+
173
+ # Execute hooks in priority order
174
+ logger.info(f"🔥 HOOKS: Executing {len(unique_hooks)} hooks for {changeset.model_cls.__name__}.{event}")
175
+ for handler_cls, method_name, condition, priority in unique_hooks:
112
176
  logger.info(f" → {handler_cls.__name__}.{method_name} (priority={priority})")
113
177
  self._execute_hook(handler_cls, method_name, condition, changeset, event)
114
178
 
179
+ def _reset_executed_hooks(self):
180
+ """Reset the executed hooks tracking for a new operation."""
181
+ self._executed_hooks = set()
182
+
115
183
  def _execute_hook(self, handler_cls, method_name, condition, changeset, event):
116
184
  """
117
185
  Execute a single hook with condition checking.
@@ -142,8 +142,11 @@ class BulkOperationCoordinator:
142
142
  event_suffix: Event name suffix (e.g., 'before_create', 'validate_update')
143
143
  bypass_hooks: Whether to skip hook execution
144
144
  """
145
- for model_cls in models_in_chain:
145
+ logger.debug(f"🔄 DISPATCH_MODELS: Iterating through {len(models_in_chain)} models for {event_suffix}: {[m.__name__ for m in models_in_chain]}")
146
+ for i, model_cls in enumerate(models_in_chain):
147
+ logger.debug(f"🔄 DISPATCH_ITERATION: {i+1}/{len(models_in_chain)} - Dispatching to {model_cls.__name__} for {event_suffix}")
146
148
  model_changeset = self._build_changeset_for_model(changeset, model_cls)
149
+ logger.debug(f"🔄 CHANGESET_MODEL: Created changeset with model_cls={model_changeset.model_cls.__name__}")
147
150
  self.dispatcher.dispatch(model_changeset, event_suffix, bypass_hooks=bypass_hooks)
148
151
 
149
152
  # ==================== PUBLIC API ====================
@@ -700,8 +703,13 @@ class BulkOperationCoordinator:
700
703
  if bypass_hooks:
701
704
  return operation()
702
705
 
706
+ # Reset hook execution tracking for this new operation
707
+ self.dispatcher._reset_executed_hooks()
708
+ logger.debug(f"🚀 MTI_OPERATION_START: {event_prefix} operation for {changeset.model_cls.__name__}")
709
+
703
710
  # Get all models in inheritance chain
704
711
  models_in_chain = self._get_models_in_chain(changeset.model_cls)
712
+ logger.debug(f"🔗 MTI_CHAIN_START: {len(models_in_chain)} models in chain for {changeset.model_cls.__name__}")
705
713
 
706
714
  # VALIDATE phase - for all models in chain
707
715
  if not bypass_validation:
@@ -796,6 +804,7 @@ class BulkOperationCoordinator:
796
804
  result_objects: List of objects returned from the operation
797
805
  models_in_chain: List of model classes in the MTI inheritance chain
798
806
  """
807
+ logger.debug(f"🔀 UPSERT_AFTER_START: Processing {len(result_objects)} result objects with {len(models_in_chain)} models in chain")
799
808
  # Split objects based on metadata set by the executor
800
809
  created_objects = []
801
810
  updated_objects = []
@@ -865,6 +874,7 @@ class BulkOperationCoordinator:
865
874
 
866
875
  # Dispatch after_create hooks for created objects
867
876
  if created_objects:
877
+ logger.debug(f"🔀 UPSERT_DISPATCH_CREATE: Dispatching after_create for {len(created_objects)} created objects")
868
878
  from django_bulk_hooks.helpers import build_changeset_for_create
869
879
 
870
880
  create_changeset = build_changeset_for_create(self.model_cls, created_objects)
@@ -873,6 +883,7 @@ class BulkOperationCoordinator:
873
883
 
874
884
  # Dispatch after_update hooks for updated objects
875
885
  if updated_objects:
886
+ logger.debug(f"🔀 UPSERT_DISPATCH_UPDATE: Dispatching after_update for {len(updated_objects)} updated objects")
876
887
  # Fetch old records for proper change detection
877
888
  old_records_map = self.analyzer.fetch_old_records_map(updated_objects)
878
889
 
@@ -78,14 +78,17 @@ class MTIHandler:
78
78
  while current_model:
79
79
  if not current_model._meta.proxy and not current_model._meta.abstract:
80
80
  chain.append(current_model)
81
+ logger.debug(f"🔗 MTI_CHAIN_ADD: Added {current_model.__name__} (abstract: {current_model._meta.abstract}, proxy: {current_model._meta.proxy})")
81
82
 
82
83
  # Get concrete parent models (not abstract, not proxy)
83
84
  parents = [parent for parent in current_model._meta.parents.keys() if not parent._meta.proxy and not parent._meta.abstract]
85
+ logger.debug(f"🔗 MTI_PARENTS: {current_model.__name__} has concrete parents: {[p.__name__ for p in parents]}")
84
86
 
85
87
  current_model = parents[0] if parents else None
86
88
 
87
89
  # Reverse to get root-to-child order
88
90
  chain.reverse()
91
+ logger.debug(f"🔗 MTI_CHAIN_FINAL: {[m.__name__ for m in chain]} (length: {len(chain)})")
89
92
  return chain
90
93
 
91
94
  def get_parent_models(self):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.2.70
3
+ Version: 0.2.72
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
@@ -4,7 +4,7 @@ django_bulk_hooks/conditions.py,sha256=ar4pGjtxLKmgSIlO4S6aZFKmaBNchLtxMmWpkn4g9
4
4
  django_bulk_hooks/constants.py,sha256=PxpEETaO6gdENcTPoXS586lerGKVP3nmjpDvOkmhYxI,509
5
5
  django_bulk_hooks/context.py,sha256=mqaC5-yESDTA5ruI7fuXlt8qSgKuOFp0mjq7h1-4HdQ,1926
6
6
  django_bulk_hooks/decorators.py,sha256=TdkO4FJyFrVU2zqK6Y_6JjEJ4v3nbKkk7aa22jN10sk,11994
7
- django_bulk_hooks/dispatcher.py,sha256=mNRiv9SEf9PwRskGA1GxTIl0kp-CozAcScICqpPHxzM,18809
7
+ django_bulk_hooks/dispatcher.py,sha256=AJJFpP5zvstF1VfKyduetDlor9Dskk-7VbwHkGHRMYM,22352
8
8
  django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
9
9
  django_bulk_hooks/factory.py,sha256=ezrVM5U023KZqOBbJXb6lYUP-pE7WJmi8Olh2Ew-7RA,18085
10
10
  django_bulk_hooks/handler.py,sha256=SRCrMzgolrruTkvMnYBFmXLR-ABiw0JiH3605PEdCZM,4207
@@ -14,14 +14,14 @@ django_bulk_hooks/models.py,sha256=TWN_F-SsLGPx9jrkNT9pmJFR5VsZ0Z_QaVOZOmt7bpw,2
14
14
  django_bulk_hooks/operations/__init__.py,sha256=BtJYjmRhe_sScivLsniDaZmBkm0ZLvcmzXFKL7QY2Xg,550
15
15
  django_bulk_hooks/operations/analyzer.py,sha256=Pz8mc-EL8KDOfLQFYiRuN-r0OmINW3nIBhRJJCma-yo,10360
16
16
  django_bulk_hooks/operations/bulk_executor.py,sha256=po8V_2H3ULiE0RYJ-wbaRIz52SKhss81UHwuQjlz3H8,26214
17
- django_bulk_hooks/operations/coordinator.py,sha256=NHQWg36IvWAsbQnAppsExOhFQsvlk_VD-aMwWsoo1ao,38572
17
+ django_bulk_hooks/operations/coordinator.py,sha256=Gq2vqjlIyyfV8OSoipoED2hC2kb2czaHuy2Cs3gtmGI,39751
18
18
  django_bulk_hooks/operations/field_utils.py,sha256=cQ9w4xdk-z3PrMLFvRzVV07Wc0D2qbpSepwoupqwQH8,7888
19
- django_bulk_hooks/operations/mti_handler.py,sha256=Vmz0C0gtYDvbybmb4cDzIaGglSaQK4DQVkaBK-WuQeE,25855
19
+ django_bulk_hooks/operations/mti_handler.py,sha256=x1uNvP8MkidifPp_AMp4nffsdW3dz3izV6SeaLJ0DaA,26247
20
20
  django_bulk_hooks/operations/mti_plans.py,sha256=HIRJgogHPpm6MV7nZZ-sZhMLUnozpZPV2SzwQHLRzYc,3667
21
21
  django_bulk_hooks/operations/record_classifier.py,sha256=It85hJC2K-UsEOLbTR-QBdY5UPV-acQIJ91TSGa7pYo,7053
22
22
  django_bulk_hooks/queryset.py,sha256=tHt1U2O9Hvozg9sdn5MjzAk_I6wDU-LupuKFTfv1SiQ,7449
23
23
  django_bulk_hooks/registry.py,sha256=4HxP1mVK2z4VzvlohbEw2359wM21UJZJYagJJ1komM0,7947
24
- django_bulk_hooks-0.2.70.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
25
- django_bulk_hooks-0.2.70.dist-info/METADATA,sha256=cgeAfI0qSgIJJDl7WXpsqErM3YzqmuGnIeqPeSUpVOk,10555
26
- django_bulk_hooks-0.2.70.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
27
- django_bulk_hooks-0.2.70.dist-info/RECORD,,
24
+ django_bulk_hooks-0.2.72.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
25
+ django_bulk_hooks-0.2.72.dist-info/METADATA,sha256=QkD3zT88vaJBu79C5vYBSORg0LBh4nCzVpwObeLwpko,10555
26
+ django_bulk_hooks-0.2.72.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
27
+ django_bulk_hooks-0.2.72.dist-info/RECORD,,