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

@@ -0,0 +1,588 @@
1
+ """
2
+ HookDispatcher: Single execution path for all hooks.
3
+
4
+ Provides deterministic, priority-ordered hook execution,
5
+ similar to Salesforce's hook framework.
6
+ """
7
+
8
+ import logging
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class HookDispatcher:
14
+ """
15
+ Single execution path for all hooks.
16
+
17
+ Responsibilities:
18
+ - Execute hooks in priority order
19
+ - Filter records based on conditions
20
+ - Provide ChangeSet context to hooks
21
+ - Fail-fast error propagation
22
+ - Manage complete operation lifecycle (VALIDATE, BEFORE, AFTER)
23
+ """
24
+
25
+ def __init__(self, registry):
26
+ """
27
+ Initialize the dispatcher.
28
+
29
+ Args:
30
+ registry: The hook registry (provides get_hooks method)
31
+ """
32
+ self.registry = registry
33
+
34
+ def execute_operation_with_hooks(
35
+ self,
36
+ changeset,
37
+ operation,
38
+ event_prefix,
39
+ bypass_hooks=False,
40
+ ):
41
+ """
42
+ Execute operation with full hook lifecycle.
43
+
44
+ This is the high-level method that coordinates the complete lifecycle:
45
+ 1. VALIDATE_{event}
46
+ 2. BEFORE_{event}
47
+ 3. Actual operation
48
+ 4. AFTER_{event}
49
+
50
+ Args:
51
+ changeset: ChangeSet for the operation
52
+ operation: Callable that performs the actual DB operation
53
+ event_prefix: 'create', 'update', or 'delete'
54
+ bypass_hooks: Skip all hooks if True
55
+
56
+ Returns:
57
+ Result of operation
58
+ """
59
+ if bypass_hooks:
60
+ return operation()
61
+
62
+ # VALIDATE phase
63
+ self.dispatch(changeset, f"validate_{event_prefix}", bypass_hooks=False)
64
+
65
+ # BEFORE phase
66
+ self.dispatch(changeset, f"before_{event_prefix}", bypass_hooks=False)
67
+
68
+ # Execute the actual operation
69
+ result = operation()
70
+
71
+ # AFTER phase - use result if operation returns modified data
72
+ if result and isinstance(result, list) and event_prefix == "create":
73
+ # For create, rebuild changeset with assigned PKs
74
+ from django_bulk_hooks.helpers import build_changeset_for_create
75
+
76
+ changeset = build_changeset_for_create(changeset.model_cls, result)
77
+
78
+ self.dispatch(changeset, f"after_{event_prefix}", bypass_hooks=False)
79
+
80
+ return result
81
+
82
+ def dispatch(self, changeset, event, bypass_hooks=False):
83
+ """
84
+ Dispatch hooks for a changeset with deterministic ordering.
85
+
86
+ This is the single execution path for ALL hooks in the system.
87
+
88
+ Args:
89
+ changeset: ChangeSet instance with record changes
90
+ event: Event name (e.g., 'after_update', 'before_create')
91
+ bypass_hooks: If True, skip all hook execution
92
+
93
+ Raises:
94
+ Exception: Any exception raised by a hook (fails fast)
95
+ RecursionError: If hooks create an infinite loop (Python's built-in limit)
96
+ """
97
+ if bypass_hooks:
98
+ return
99
+
100
+ # Get hooks sorted by priority (deterministic order)
101
+ hooks = self.registry.get_hooks(changeset.model_cls, event)
102
+
103
+ logger.debug(f"🧵 DISPATCH: changeset.model_cls={changeset.model_cls.__name__}, event={event}")
104
+ logger.debug(
105
+ f"🎣 HOOKS_FOUND: {len(hooks)} hooks for {changeset.model_cls.__name__}.{event}: {[f'{h[0].__name__}.{h[1]}' for h in hooks]}"
106
+ )
107
+
108
+ if not hooks:
109
+ return
110
+
111
+ # Create an operation key that includes the changeset model to avoid
112
+ # deduplicating hooks across different operations on the same records
113
+ # This prevents the same hook from executing multiple times for MTI inheritance chains
114
+ # but allows different operations on the same records to execute their hooks
115
+ record_ids = set()
116
+ for change in changeset.changes:
117
+ if change.new_record and change.new_record.pk:
118
+ record_ids.add(change.new_record.pk)
119
+ if change.old_record and change.old_record.pk:
120
+ record_ids.add(change.old_record.pk)
121
+
122
+ # Sort record IDs safely (handle Mock objects and other non-comparable types)
123
+ try:
124
+ sorted_record_ids = tuple(sorted(record_ids, key=lambda x: str(x)))
125
+ except (TypeError, AttributeError):
126
+ # Fallback for non-comparable objects (like Mock objects in tests)
127
+ sorted_record_ids = tuple(record_ids)
128
+
129
+ # Include changeset model and operation details to make the key more specific
130
+ operation_meta = getattr(changeset, "operation_meta", {}) or {}
131
+ operation_type = getattr(changeset, "operation_type", "unknown")
132
+
133
+ # Include update_kwargs if present to distinguish different queryset operations
134
+ update_kwargs = operation_meta.get("update_kwargs", {})
135
+ if update_kwargs:
136
+ try:
137
+ # Convert to a hashable representation
138
+ update_kwargs_key = tuple(sorted((k, str(v)) for k, v in update_kwargs.items()))
139
+ except (TypeError, AttributeError):
140
+ # Fallback if values are not convertible to string
141
+ update_kwargs_key = tuple(sorted(update_kwargs.keys()))
142
+ else:
143
+ update_kwargs_key = ()
144
+
145
+ operation_key = (event, changeset.model_cls.__name__, operation_type, sorted_record_ids, update_kwargs_key)
146
+
147
+ # Track executed hooks to prevent duplicates in MTI inheritance chains
148
+ if not hasattr(self, "_executed_hooks"):
149
+ self._executed_hooks = set()
150
+
151
+ # Filter out hooks that have already been executed for this operation
152
+ unique_hooks = []
153
+ skipped_hooks = []
154
+ for handler_cls, method_name, condition, priority in hooks:
155
+ hook_key = (handler_cls, method_name, operation_key)
156
+ if hook_key not in self._executed_hooks:
157
+ unique_hooks.append((handler_cls, method_name, condition, priority))
158
+ self._executed_hooks.add(hook_key)
159
+ else:
160
+ skipped_hooks.append((handler_cls.__name__, method_name))
161
+
162
+ # Debug logging for hook deduplication
163
+ if skipped_hooks:
164
+ logger.debug(f"⏭️ SKIPPED_DUPS: {len(skipped_hooks)} duplicate hooks: {[f'{cls}.{method}' for cls, method in skipped_hooks]}")
165
+
166
+ if unique_hooks:
167
+ logger.debug(f"✅ EXECUTING_UNIQUE: {len(unique_hooks)} unique hooks: {[f'{h[0].__name__}.{h[1]}' for h in unique_hooks]}")
168
+
169
+ if not unique_hooks:
170
+ return
171
+
172
+ # Execute hooks in priority order
173
+ logger.info(f"🔥 HOOKS: Executing {len(unique_hooks)} hooks for {changeset.model_cls.__name__}.{event}")
174
+ for handler_cls, method_name, condition, priority in unique_hooks:
175
+ logger.info(f" → {handler_cls.__name__}.{method_name} (priority={priority})")
176
+ self._execute_hook(handler_cls, method_name, condition, changeset, event)
177
+
178
+ def _reset_executed_hooks(self):
179
+ """Reset the executed hooks tracking for a new operation."""
180
+ self._executed_hooks = set()
181
+
182
+ def _execute_hook(self, handler_cls, method_name, condition, changeset, event):
183
+ """
184
+ Execute a single hook with condition checking.
185
+
186
+ Args:
187
+ handler_cls: The hook handler class
188
+ method_name: Name of the method to call
189
+ condition: Optional condition to filter records
190
+ changeset: ChangeSet with all record changes
191
+ event: The hook event (e.g., 'before_create')
192
+ """
193
+ # Use DI factory to create handler instance EARLY to access method decorators
194
+ from django_bulk_hooks.factory import create_hook_instance
195
+
196
+ handler = create_hook_instance(handler_cls)
197
+ method = getattr(handler, method_name)
198
+
199
+ # PRELOAD @select_related RELATIONSHIPS BEFORE CONDITION EVALUATION
200
+ # This ensures both conditions and hook methods have access to preloaded relationships
201
+
202
+ # Check if method has @select_related decorator
203
+ preload_func = getattr(method, "_select_related_preload", None)
204
+ if preload_func:
205
+ # Preload relationships to prevent N+1 queries in both conditions and hook methods
206
+ try:
207
+ model_cls_override = getattr(handler, "model_cls", None)
208
+
209
+ # Get FK fields being updated to avoid preloading conflicting relationships
210
+ skip_fields = changeset.operation_meta.get("fk_fields_being_updated", set())
211
+
212
+ # Preload for new_records (needed for condition evaluation and hook execution)
213
+ if changeset.new_records:
214
+ preload_func(
215
+ changeset.new_records,
216
+ model_cls=model_cls_override,
217
+ skip_fields=skip_fields,
218
+ )
219
+
220
+ # Also preload for old_records (for conditions that check previous values)
221
+ if changeset.old_records:
222
+ preload_func(
223
+ changeset.old_records,
224
+ model_cls=model_cls_override,
225
+ skip_fields=skip_fields,
226
+ )
227
+
228
+ # Mark that relationships have been preloaded to avoid duplicate condition preloading
229
+ changeset.operation_meta["relationships_preloaded"] = True
230
+ logger.debug(f"🔗 @select_related: Preloaded relationships for {handler_cls.__name__}.{method_name}")
231
+
232
+ except Exception as e:
233
+ logger.warning(f"Failed to preload relationships for {handler_cls.__name__}.{method_name}: {e}")
234
+
235
+ # SPECIAL HANDLING: Explicit @select_related support for BEFORE_CREATE hooks
236
+ # (This can stay for additional BEFORE_CREATE-specific logic if needed)
237
+ select_related_fields = getattr(method, "_select_related_fields", None)
238
+ if select_related_fields and event == "before_create" and changeset.new_records:
239
+ self._preload_select_related_for_before_create(changeset, select_related_fields)
240
+
241
+ # NOW condition evaluation is safe - relationships are preloaded
242
+ if condition:
243
+ # Skip per-hook preloading if relationships were already preloaded upfront
244
+ if not changeset.operation_meta.get("relationships_preloaded", False):
245
+ condition_relationships = self._extract_condition_relationships(condition, changeset.model_cls)
246
+ logger.info(
247
+ f"🔍 CONDITION: {handler_cls.__name__}.{method_name} has condition, extracted relationships: {condition_relationships}"
248
+ )
249
+ if condition_relationships:
250
+ logger.info(f"🔗 PRELOADING: Preloading condition relationships for {len(changeset.changes)} records")
251
+ self._preload_condition_relationships(changeset, condition_relationships)
252
+ else:
253
+ logger.debug(f"🔍 CONDITION: {handler_cls.__name__}.{method_name} has condition (relationships already preloaded)")
254
+
255
+ # Filter records based on condition (now safe - relationships are preloaded)
256
+ if condition:
257
+ logger.info(f"⚡ EVALUATING: Checking condition for {handler_cls.__name__}.{method_name} on {len(changeset.changes)} records")
258
+ filtered_changes = [change for change in changeset.changes if condition.check(change.new_record, change.old_record)]
259
+ logger.info(f"✅ CONDITION: {len(filtered_changes)}/{len(changeset.changes)} records passed condition filter")
260
+
261
+ if not filtered_changes:
262
+ # No records match condition, skip this hook
263
+ return
264
+
265
+ # Create filtered changeset
266
+ from django_bulk_hooks.changeset import ChangeSet
267
+
268
+ filtered_changeset = ChangeSet(
269
+ changeset.model_cls,
270
+ filtered_changes,
271
+ changeset.operation_type,
272
+ changeset.operation_meta,
273
+ )
274
+ else:
275
+ # No condition, use full changeset
276
+ filtered_changeset = changeset
277
+
278
+ # Execute hook with ChangeSet
279
+ #
280
+ # ARCHITECTURE NOTE: Hook Contract
281
+ # ====================================
282
+ # All hooks must accept **kwargs for forward compatibility.
283
+ # We pass: changeset, new_records, old_records
284
+ #
285
+ # Old hooks that don't use changeset: def hook(self, new_records, old_records, **kwargs)
286
+ # New hooks that do use changeset: def hook(self, changeset, new_records, old_records, **kwargs)
287
+ #
288
+ # This is standard Python framework design (see Django signals, Flask hooks, etc.)
289
+ logger.info(f" 🚀 Executing: {handler_cls.__name__}.{method_name}")
290
+ try:
291
+ method(
292
+ changeset=filtered_changeset,
293
+ new_records=filtered_changeset.new_records,
294
+ old_records=filtered_changeset.old_records,
295
+ )
296
+ logger.info(f" ✅ Completed: {handler_cls.__name__}.{method_name}")
297
+ except Exception as e:
298
+ # Fail-fast: re-raise to rollback transaction
299
+ logger.error(
300
+ f"Hook {handler_cls.__name__}.{method_name} failed: {e}",
301
+ exc_info=True,
302
+ )
303
+ raise
304
+
305
+ def _extract_condition_relationships(self, condition, model_cls):
306
+ """
307
+ Extract relationship paths that a condition might access.
308
+
309
+ Args:
310
+ condition: HookCondition instance
311
+ model_cls: The model class
312
+
313
+ Returns:
314
+ set: Set of relationship field names to preload
315
+ """
316
+ relationships = set()
317
+
318
+ # Guard against Mock objects and non-condition objects
319
+ if not hasattr(condition, "check") or hasattr(condition, "_mock_name"):
320
+ return relationships
321
+
322
+ # Handle different condition types
323
+ if hasattr(condition, "field"):
324
+ # Extract relationships from field path (e.g., "status__value" -> "status")
325
+ field_path = condition.field
326
+ if isinstance(field_path, str):
327
+ if "__" in field_path:
328
+ # Take the first part before __ (the relationship to preload)
329
+ rel_field = field_path.split("__")[0]
330
+
331
+ # Normalize FK field names: business_id -> business
332
+ if rel_field.endswith("_id"):
333
+ potential_field_name = rel_field[:-3] # Remove '_id'
334
+ if self._is_relationship_field(model_cls, potential_field_name):
335
+ rel_field = potential_field_name
336
+
337
+ relationships.add(rel_field)
338
+ else:
339
+ # Handle single field (no __ notation)
340
+ rel_field = field_path
341
+
342
+ # Normalize FK field names: business_id -> business
343
+ if rel_field.endswith("_id"):
344
+ potential_field_name = rel_field[:-3] # Remove '_id'
345
+ if self._is_relationship_field(model_cls, potential_field_name):
346
+ rel_field = potential_field_name
347
+
348
+ # Only add if it's actually a relationship field
349
+ if self._is_relationship_field(model_cls, rel_field):
350
+ relationships.add(rel_field)
351
+
352
+ # Handle composite conditions (AndCondition, OrCondition)
353
+ if hasattr(condition, "cond1") and hasattr(condition, "cond2"):
354
+ relationships.update(self._extract_condition_relationships(condition.cond1, model_cls))
355
+ relationships.update(self._extract_condition_relationships(condition.cond2, model_cls))
356
+
357
+ # Handle NotCondition
358
+ if hasattr(condition, "cond"):
359
+ relationships.update(self._extract_condition_relationships(condition.cond, model_cls))
360
+
361
+ return relationships
362
+
363
+ def _is_relationship_field(self, model_cls, field_name):
364
+ """Check if a field is a relationship field."""
365
+ try:
366
+ field = model_cls._meta.get_field(field_name)
367
+ return field.is_relation and not field.many_to_many
368
+ except:
369
+ return False
370
+
371
+ def _preload_condition_relationships(self, changeset, relationships):
372
+ """
373
+ Preload relationships needed for condition evaluation.
374
+
375
+ This prevents N+1 queries when conditions access relationships on both
376
+ old_records and new_records (e.g., HasChanged conditions).
377
+
378
+ Args:
379
+ changeset: ChangeSet with records
380
+ relationships: Set of relationship field names to preload
381
+ """
382
+ if not relationships:
383
+ return
384
+
385
+ # Use Django's select_related to preload relationships
386
+ relationship_list = list(relationships)
387
+
388
+ # Collect all unique PKs from both new_records and old_records
389
+ all_ids = set()
390
+
391
+ # Add PKs from new_records
392
+ if changeset.new_records:
393
+ all_ids.update(obj.pk for obj in changeset.new_records if obj.pk is not None)
394
+
395
+ # Add PKs from old_records
396
+ if changeset.old_records:
397
+ all_ids.update(obj.pk for obj in changeset.old_records if obj.pk is not None)
398
+
399
+ # Bulk preload relationships for all records that have PKs
400
+ if all_ids:
401
+ preloaded = changeset.model_cls.objects.filter(pk__in=list(all_ids)).select_related(*relationship_list).in_bulk()
402
+
403
+ # Update new_records with preloaded relationships
404
+ if changeset.new_records:
405
+ for obj in changeset.new_records:
406
+ if obj.pk and obj.pk in preloaded:
407
+ preloaded_obj = preloaded[obj.pk]
408
+ for rel in relationship_list:
409
+ if hasattr(preloaded_obj, rel):
410
+ # Preserve FK _id values in __dict__ before setattr (MTI fix)
411
+ id_field_name = f"{rel}_id"
412
+ field_was_in_dict = id_field_name in obj.__dict__
413
+ preserved_id = obj.__dict__.get(id_field_name) if field_was_in_dict else None
414
+
415
+ logger.debug("🔄 PRESERVE_FK_NEW: obj.pk=%s, %s in __dict__=%s, preserved=%s",
416
+ obj.pk, id_field_name, field_was_in_dict, preserved_id)
417
+
418
+ setattr(obj, rel, getattr(preloaded_obj, rel))
419
+
420
+ after_setattr = obj.__dict__.get(id_field_name, "NOT_IN_DICT")
421
+ logger.debug("🔄 AFTER_SETATTR_NEW: obj.pk=%s, %s=%s (was %s)",
422
+ obj.pk, id_field_name, after_setattr, preserved_id)
423
+
424
+ # Restore FK _id if it was in __dict__ (prevents Django descriptor from clearing it)
425
+ # This includes restoring None if that's what was explicitly set
426
+ if field_was_in_dict:
427
+ obj.__dict__[id_field_name] = preserved_id
428
+ # Also clear the relationship from fields_cache to prevent Django from
429
+ # returning the cached object when preserved_id is None
430
+ if preserved_id is None and hasattr(obj, "_state") and hasattr(obj._state, "fields_cache"):
431
+ if rel in obj._state.fields_cache:
432
+ obj._state.fields_cache.pop(rel, None)
433
+ logger.debug("🔄 CLEARED_CACHE: obj.pk=%s, cleared '%s' from fields_cache",
434
+ obj.pk, rel)
435
+ logger.debug("🔄 RESTORED_FK_NEW: obj.pk=%s, %s=%s",
436
+ obj.pk, id_field_name, obj.__dict__.get(id_field_name))
437
+
438
+ # Update old_records with preloaded relationships
439
+ # NOTE: We do NOT preserve/restore FK _id values for old_records because:
440
+ # 1. old_records represent the "before" state for hook conditions
441
+ # 2. They should reflect database values, not in-memory user changes
442
+ # 3. When new_records and old_records share the same object instances,
443
+ # restoring DB values here would overwrite user's in-memory changes
444
+ if changeset.old_records:
445
+ for obj in changeset.old_records:
446
+ if obj.pk and obj.pk in preloaded:
447
+ preloaded_obj = preloaded[obj.pk]
448
+ for rel in relationship_list:
449
+ if hasattr(preloaded_obj, rel):
450
+ setattr(obj, rel, getattr(preloaded_obj, rel))
451
+
452
+ # Log final state after preloading
453
+ if changeset.new_records:
454
+ for obj in changeset.new_records:
455
+ if "business_id" in obj.__dict__:
456
+ logger.debug("🔄 FINAL_STATE_NEW: obj.pk=%s, business_id=%s",
457
+ obj.pk, obj.__dict__.get("business_id"))
458
+
459
+ # Handle unsaved new_records by preloading their FK targets (bulk query to avoid N+1)
460
+ if changeset.new_records:
461
+ # Collect FK IDs for each relationship from unsaved records
462
+ field_ids_map = {rel: set() for rel in relationship_list}
463
+
464
+ for obj in changeset.new_records:
465
+ if obj.pk is None: # Unsaved object
466
+ for rel in relationship_list:
467
+ if hasattr(obj, f"{rel}_id"):
468
+ rel_id = getattr(obj, f"{rel}_id")
469
+ if rel_id:
470
+ field_ids_map[rel].add(rel_id)
471
+
472
+ # Bulk load relationships for unsaved records
473
+ field_objects_map = {}
474
+ for rel, ids in field_ids_map.items():
475
+ if not ids:
476
+ continue
477
+ try:
478
+ rel_model = getattr(changeset.model_cls._meta.get_field(rel).remote_field, "model")
479
+ field_objects_map[rel] = rel_model.objects.in_bulk(ids)
480
+ except Exception:
481
+ field_objects_map[rel] = {}
482
+
483
+ # Attach relationships to unsaved records
484
+ for obj in changeset.new_records:
485
+ if obj.pk is None: # Unsaved object
486
+ for rel in relationship_list:
487
+ rel_id = getattr(obj, f"{rel}_id", None)
488
+ if rel_id and rel in field_objects_map:
489
+ rel_obj = field_objects_map[rel].get(rel_id)
490
+ if rel_obj:
491
+ setattr(obj, rel, rel_obj)
492
+
493
+ def _preload_select_related_for_before_create(self, changeset, select_related_fields):
494
+ """
495
+ Explicit bulk preloading for @select_related on BEFORE_CREATE hooks.
496
+
497
+ This method provides guaranteed N+1 elimination by:
498
+ 1. Collecting all FK IDs from unsaved new_records
499
+ 2. Bulk querying related objects
500
+ 3. Attaching relationships to each record
501
+
502
+ Args:
503
+ changeset: ChangeSet with new_records (unsaved objects)
504
+ select_related_fields: List of field names to preload (e.g., ['financial_account'])
505
+ """
506
+ # Ensure select_related_fields is actually iterable (not a Mock in tests)
507
+ if not select_related_fields or not changeset.new_records or not hasattr(select_related_fields, "__iter__"):
508
+ return
509
+
510
+ logger.info(f"🔗 BULK PRELOAD: Preloading {select_related_fields} for {len(changeset.new_records)} unsaved records")
511
+
512
+ # Collect FK IDs for each field
513
+ field_ids_map = {field: set() for field in select_related_fields}
514
+
515
+ for record in changeset.new_records:
516
+ for field in select_related_fields:
517
+ fk_id = getattr(record, f"{field}_id", None)
518
+ if fk_id is not None:
519
+ field_ids_map[field].add(fk_id)
520
+
521
+ # Bulk query related objects for each field
522
+ field_objects_map = {}
523
+ for field, ids in field_ids_map.items():
524
+ if not ids:
525
+ continue
526
+
527
+ try:
528
+ # Get the related model
529
+ relation_field = changeset.model_cls._meta.get_field(field)
530
+ if not relation_field.is_relation:
531
+ continue
532
+
533
+ related_model = relation_field.remote_field.model
534
+
535
+ # Bulk query: related_model.objects.filter(id__in=ids)
536
+ field_objects_map[field] = related_model.objects.in_bulk(ids)
537
+ logger.info(f" ✅ Bulk loaded {len(field_objects_map[field])} {related_model.__name__} objects for field '{field}'")
538
+
539
+ except Exception as e:
540
+ logger.warning(f" ❌ Failed to bulk load field '{field}': {e}")
541
+ field_objects_map[field] = {}
542
+
543
+ # Attach relationships to each record
544
+ for record in changeset.new_records:
545
+ for field in select_related_fields:
546
+ fk_id = getattr(record, f"{field}_id", None)
547
+ if fk_id is not None and field in field_objects_map:
548
+ related_obj = field_objects_map[field].get(fk_id)
549
+ if related_obj is not None:
550
+ setattr(record, field, related_obj)
551
+ # Also cache in Django's fields_cache for consistency
552
+ if hasattr(record, "_state") and hasattr(record._state, "fields_cache"):
553
+ record._state.fields_cache[field] = related_obj
554
+
555
+ logger.info(f"🔗 BULK PRELOAD: Completed relationship attachment for {len(changeset.new_records)} records")
556
+
557
+
558
+ # Global dispatcher instance
559
+ _dispatcher: HookDispatcher | None = None
560
+
561
+
562
+ def get_dispatcher():
563
+ """
564
+ Get the global dispatcher instance.
565
+
566
+ Creates the dispatcher on first access (singleton pattern).
567
+
568
+ Returns:
569
+ HookDispatcher instance
570
+ """
571
+ global _dispatcher
572
+ if _dispatcher is None:
573
+ # Import here to avoid circular dependency
574
+ from django_bulk_hooks.registry import get_registry
575
+
576
+ # Create dispatcher with the registry instance
577
+ _dispatcher = HookDispatcher(get_registry())
578
+ return _dispatcher
579
+
580
+
581
+ def reset_dispatcher():
582
+ """
583
+ Reset the global dispatcher instance.
584
+
585
+ Useful for testing to ensure clean state between tests.
586
+ """
587
+ global _dispatcher
588
+ _dispatcher = None