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

@@ -1,33 +1,29 @@
1
1
  import logging
2
2
 
3
+ from django_bulk_hooks.changeset import ChangeSet
4
+ from django_bulk_hooks.changeset import RecordChange
5
+ from django_bulk_hooks.constants import DEFAULT_BULK_UPDATE_BATCH_SIZE
6
+ from django_bulk_hooks.dispatcher import HookDispatcher
7
+ from django_bulk_hooks.dispatcher import get_dispatcher
8
+ from django_bulk_hooks.factory import clear_hook_factories
9
+ from django_bulk_hooks.factory import configure_hook_container
10
+ from django_bulk_hooks.factory import configure_nested_container
11
+ from django_bulk_hooks.factory import create_hook_instance
12
+ from django_bulk_hooks.factory import is_container_configured
13
+ from django_bulk_hooks.factory import set_default_hook_factory
14
+ from django_bulk_hooks.factory import set_hook_factory
3
15
  from django_bulk_hooks.handler import Hook as HookClass
16
+ from django_bulk_hooks.helpers import build_changeset_for_create
17
+ from django_bulk_hooks.helpers import build_changeset_for_delete
18
+ from django_bulk_hooks.helpers import build_changeset_for_update
19
+ from django_bulk_hooks.helpers import dispatch_hooks_for_operation
4
20
  from django_bulk_hooks.manager import BulkHookManager
5
- from django_bulk_hooks.factory import (
6
- set_hook_factory,
7
- set_default_hook_factory,
8
- configure_hook_container,
9
- configure_nested_container,
10
- clear_hook_factories,
11
- create_hook_instance,
12
- is_container_configured,
13
- )
14
- from django_bulk_hooks.constants import DEFAULT_BULK_UPDATE_BATCH_SIZE
15
- from django_bulk_hooks.changeset import ChangeSet, RecordChange
16
- from django_bulk_hooks.dispatcher import get_dispatcher, HookDispatcher
17
- from django_bulk_hooks.helpers import (
18
- build_changeset_for_create,
19
- build_changeset_for_update,
20
- build_changeset_for_delete,
21
- dispatch_hooks_for_operation,
22
- )
21
+ from django_bulk_hooks.operations import BulkExecutor
23
22
 
24
23
  # Service layer (NEW architecture)
25
- from django_bulk_hooks.operations import (
26
- BulkOperationCoordinator,
27
- ModelAnalyzer,
28
- BulkExecutor,
29
- MTIHandler,
30
- )
24
+ from django_bulk_hooks.operations import BulkOperationCoordinator
25
+ from django_bulk_hooks.operations import ModelAnalyzer
26
+ from django_bulk_hooks.operations import MTIHandler
31
27
 
32
28
  # Add NullHandler to prevent logging messages if the application doesn't configure logging
33
29
  logging.getLogger(__name__).addHandler(logging.NullHandler())
@@ -226,5 +226,5 @@ class ChangeSet:
226
226
  for i in range(0, len(self.changes), chunk_size):
227
227
  chunk_changes = self.changes[i : i + chunk_size]
228
228
  yield ChangeSet(
229
- self.model_cls, chunk_changes, self.operation_type, self.operation_meta
229
+ self.model_cls, chunk_changes, self.operation_type, self.operation_meta,
230
230
  )
@@ -19,9 +19,8 @@ def resolve_field_path(instance, field_path):
19
19
  # For foreign key fields, use attname to get the ID directly
20
20
  # This avoids hooking Django's descriptor protocol
21
21
  return getattr(instance, field.attname, None)
22
- else:
23
- # For regular fields, use normal getattr
24
- return getattr(instance, field_path, None)
22
+ # For regular fields, use normal getattr
23
+ return getattr(instance, field_path, None)
25
24
  except Exception:
26
25
  # If field lookup fails, fall back to normal getattr
27
26
  return getattr(instance, field_path, None)
@@ -41,7 +40,7 @@ def resolve_field_path(instance, field_path):
41
40
  if field.is_relation and not field.many_to_many:
42
41
  # Use attname for the final FK field access
43
42
  current_instance = getattr(
44
- current_instance, field.attname, None
43
+ current_instance, field.attname, None,
45
44
  )
46
45
  continue
47
46
  except:
@@ -85,8 +84,7 @@ class IsNotEqual(HookCondition):
85
84
  return False
86
85
  previous = resolve_field_path(original_instance, self.field)
87
86
  return previous == self.value and current != self.value
88
- else:
89
- return current != self.value
87
+ return current != self.value
90
88
 
91
89
 
92
90
  class IsEqual(HookCondition):
@@ -103,8 +101,7 @@ class IsEqual(HookCondition):
103
101
  return False
104
102
  previous = resolve_field_path(original_instance, self.field)
105
103
  return previous != self.value and current == self.value
106
- else:
107
- return current == self.value
104
+ return current == self.value
108
105
 
109
106
 
110
107
  class HasChanged(HookCondition):
@@ -139,8 +136,7 @@ class WasEqual(HookCondition):
139
136
  if self.only_on_change:
140
137
  current = resolve_field_path(instance, self.field)
141
138
  return previous == self.value and current != self.value
142
- else:
143
- return previous == self.value
139
+ return previous == self.value
144
140
 
145
141
 
146
142
  class ChangesTo(HookCondition):
@@ -207,7 +203,7 @@ class AndCondition(HookCondition):
207
203
 
208
204
  def check(self, instance, original_instance=None):
209
205
  return self.cond1.check(instance, original_instance) and self.cond2.check(
210
- instance, original_instance
206
+ instance, original_instance,
211
207
  )
212
208
 
213
209
 
@@ -218,7 +214,7 @@ class OrCondition(HookCondition):
218
214
 
219
215
  def check(self, instance, original_instance=None):
220
216
  return self.cond1.check(instance, original_instance) or self.cond2.check(
221
- instance, original_instance
217
+ instance, original_instance,
222
218
  )
223
219
 
224
220
 
@@ -41,7 +41,7 @@ def select_related(*related_fields):
41
41
  def preload_related(records, *, model_cls=None, skip_fields=None):
42
42
  if not isinstance(records, list):
43
43
  raise TypeError(
44
- f"@select_related expects a list of model instances, got {type(records)}"
44
+ f"@select_related expects a list of model instances, got {type(records)}",
45
45
  )
46
46
 
47
47
  if not records:
@@ -57,7 +57,7 @@ def select_related(*related_fields):
57
57
  for field in related_fields:
58
58
  if "." in field:
59
59
  raise ValueError(
60
- f"Invalid field notation '{field}'. Use Django ORM __ notation (e.g., 'parent__field')"
60
+ f"Invalid field notation '{field}'. Use Django ORM __ notation (e.g., 'parent__field')",
61
61
  )
62
62
 
63
63
  direct_relation_fields = {}
@@ -85,7 +85,7 @@ def select_related(*related_fields):
85
85
  direct_relation_fields[field] = relation_field
86
86
 
87
87
  unsaved_related_ids_by_field = {
88
- field: set() for field in direct_relation_fields.keys()
88
+ field: set() for field in direct_relation_fields
89
89
  }
90
90
 
91
91
  saved_ids_to_fetch = []
@@ -129,13 +129,13 @@ def select_related(*related_fields):
129
129
  if base_manager is not None:
130
130
  try:
131
131
  fetched_saved = base_manager.select_related(
132
- *validated_fields
132
+ *validated_fields,
133
133
  ).in_bulk(saved_ids_to_fetch)
134
134
  except Exception:
135
135
  fetched_saved = {}
136
136
 
137
137
  fetched_unsaved_by_field = {
138
- field: {} for field in direct_relation_fields.keys()
138
+ field: {} for field in direct_relation_fields
139
139
  }
140
140
 
141
141
  for field_name, relation_field in direct_relation_fields.items():
@@ -225,14 +225,14 @@ def select_related(*related_fields):
225
225
 
226
226
  if "new_records" not in bound.arguments:
227
227
  raise TypeError(
228
- "@preload_related requires a 'new_records' argument in the decorated function"
228
+ "@preload_related requires a 'new_records' argument in the decorated function",
229
229
  )
230
230
 
231
231
  new_records = bound.arguments["new_records"]
232
232
 
233
233
  if not isinstance(new_records, list):
234
234
  raise TypeError(
235
- f"@select_related expects a list of model instances, got {type(new_records)}"
235
+ f"@select_related expects a list of model instances, got {type(new_records)}",
236
236
  )
237
237
 
238
238
  if not new_records:
@@ -243,7 +243,7 @@ def select_related(*related_fields):
243
243
  for field in related_fields:
244
244
  if "." in field:
245
245
  raise ValueError(
246
- f"Invalid field notation '{field}'. Use Django ORM __ notation (e.g., 'parent__field')"
246
+ f"Invalid field notation '{field}'. Use Django ORM __ notation (e.g., 'parent__field')",
247
247
  )
248
248
 
249
249
  # Don't preload here - let the dispatcher handle it
@@ -285,18 +285,16 @@ def bulk_hook(model_cls, event, when=None, priority=None):
285
285
  sig = inspect.signature(func)
286
286
  params = list(sig.parameters.keys())
287
287
 
288
- if 'changeset' in params:
288
+ if "changeset" in params:
289
289
  # New signature with changeset
290
290
  return self.func(changeset, new_records, old_records, **kwargs)
291
- else:
292
- # Old signature without changeset
293
- # Only pass changeset in kwargs if the function accepts **kwargs
294
- if 'kwargs' in params or any(param.startswith('**') for param in sig.parameters):
295
- kwargs['changeset'] = changeset
296
- return self.func(new_records, old_records, **kwargs)
297
- else:
298
- # Function doesn't accept **kwargs, just call with positional args
299
- return self.func(new_records, old_records)
291
+ # Old signature without changeset
292
+ # Only pass changeset in kwargs if the function accepts **kwargs
293
+ if "kwargs" in params or any(param.startswith("**") for param in sig.parameters):
294
+ kwargs["changeset"] = changeset
295
+ return self.func(new_records, old_records, **kwargs)
296
+ # Function doesn't accept **kwargs, just call with positional args
297
+ return self.func(new_records, old_records)
300
298
 
301
299
  # Register the hook using the registry
302
300
  register_hook(
@@ -6,7 +6,6 @@ similar to Salesforce's hook framework.
6
6
  """
7
7
 
8
8
  import logging
9
- from typing import Optional
10
9
 
11
10
  logger = logging.getLogger(__name__)
12
11
 
@@ -162,30 +161,30 @@ class HookDispatcher:
162
161
  model_cls_override = getattr(handler, "model_cls", None)
163
162
 
164
163
  # Get FK fields being updated to avoid preloading conflicting relationships
165
- skip_fields = changeset.operation_meta.get('fk_fields_being_updated', set())
164
+ skip_fields = changeset.operation_meta.get("fk_fields_being_updated", set())
166
165
 
167
166
  # Preload for new_records
168
167
  if filtered_changeset.new_records:
169
168
  logger.debug(
170
169
  f"Preloading relationships for {len(filtered_changeset.new_records)} "
171
- f"new_records for {handler_cls.__name__}.{method_name}"
170
+ f"new_records for {handler_cls.__name__}.{method_name}",
172
171
  )
173
172
  preload_func(
174
173
  filtered_changeset.new_records,
175
174
  model_cls=model_cls_override,
176
- skip_fields=skip_fields
175
+ skip_fields=skip_fields,
177
176
  )
178
177
 
179
178
  # Also preload for old_records (for conditions that check previous values)
180
179
  if filtered_changeset.old_records:
181
180
  logger.debug(
182
181
  f"Preloading relationships for {len(filtered_changeset.old_records)} "
183
- f"old_records for {handler_cls.__name__}.{method_name}"
182
+ f"old_records for {handler_cls.__name__}.{method_name}",
184
183
  )
185
184
  preload_func(
186
185
  filtered_changeset.old_records,
187
186
  model_cls=model_cls_override,
188
- skip_fields=skip_fields
187
+ skip_fields=skip_fields,
189
188
  )
190
189
  except Exception:
191
190
  logger.debug(
@@ -196,15 +195,15 @@ class HookDispatcher:
196
195
  )
197
196
 
198
197
  # Execute hook with ChangeSet
199
- #
198
+ #
200
199
  # ARCHITECTURE NOTE: Hook Contract
201
200
  # ====================================
202
201
  # All hooks must accept **kwargs for forward compatibility.
203
202
  # We pass: changeset, new_records, old_records
204
- #
203
+ #
205
204
  # Old hooks that don't use changeset: def hook(self, new_records, old_records, **kwargs)
206
205
  # New hooks that do use changeset: def hook(self, changeset, new_records, old_records, **kwargs)
207
- #
206
+ #
208
207
  # This is standard Python framework design (see Django signals, Flask hooks, etc.)
209
208
  logger.info(f" 🚀 Executing: {handler_cls.__name__}.{method_name}")
210
209
  try:
@@ -224,7 +223,7 @@ class HookDispatcher:
224
223
 
225
224
 
226
225
  # Global dispatcher instance
227
- _dispatcher: Optional[HookDispatcher] = None
226
+ _dispatcher: HookDispatcher | None = None
228
227
 
229
228
 
230
229
  def get_dispatcher():
@@ -52,7 +52,8 @@ Usage Pattern 3 - Custom Resolver:
52
52
  import logging
53
53
  import re
54
54
  import threading
55
- from typing import Any, Callable, Optional, Type
55
+ from collections.abc import Callable
56
+ from typing import Any
56
57
 
57
58
  logger = logging.getLogger(__name__)
58
59
 
@@ -69,11 +70,11 @@ class HookFactory:
69
70
 
70
71
  def __init__(self):
71
72
  """Initialize an empty factory."""
72
- self._specific_factories: dict[Type, Callable[[], Any]] = {}
73
- self._container_resolver: Optional[Callable[[Type], Any]] = None
73
+ self._specific_factories: dict[type, Callable[[], Any]] = {}
74
+ self._container_resolver: Callable[[type], Any] | None = None
74
75
  self._lock = threading.RLock()
75
76
 
76
- def register_factory(self, hook_cls: Type, factory: Callable[[], Any]) -> None:
77
+ def register_factory(self, hook_cls: type, factory: Callable[[], Any]) -> None:
77
78
  """
78
79
  Register a factory function for a specific hook class.
79
80
 
@@ -98,8 +99,8 @@ class HookFactory:
98
99
  def configure_container(
99
100
  self,
100
101
  container: Any,
101
- provider_name_resolver: Optional[Callable[[Type], str]] = None,
102
- provider_resolver: Optional[Callable[[Any, Type, str], Any]] = None,
102
+ provider_name_resolver: Callable[[type], str] | None = None,
103
+ provider_resolver: Callable[[Any, type, str], Any] | None = None,
103
104
  fallback_to_direct: bool = True,
104
105
  ) -> None:
105
106
  """
@@ -142,7 +143,7 @@ class HookFactory:
142
143
  """
143
144
  name_resolver = provider_name_resolver or self._default_name_resolver
144
145
 
145
- def resolver(hook_cls: Type) -> Any:
146
+ def resolver(hook_cls: type) -> Any:
146
147
  """Resolve hook instance from the container."""
147
148
  provider_name = name_resolver(hook_cls)
148
149
  name = getattr(hook_cls, "__name__", str(hook_cls))
@@ -155,8 +156,7 @@ class HookFactory:
155
156
  except Exception as e:
156
157
  if fallback_to_direct:
157
158
  logger.debug(
158
- f"Custom provider resolver failed for {name} ({e}), "
159
- f"falling back to direct instantiation"
159
+ f"Custom provider resolver failed for {name} ({e}), falling back to direct instantiation",
160
160
  )
161
161
  return hook_cls()
162
162
  raise
@@ -165,34 +165,35 @@ class HookFactory:
165
165
  if hasattr(container, provider_name):
166
166
  provider = getattr(container, provider_name)
167
167
  logger.debug(
168
- f"Resolving {name} from container provider '{provider_name}'"
168
+ f"Resolving {name} from container provider '{provider_name}'",
169
169
  )
170
170
  # Call the provider to get the instance
171
171
  return provider()
172
172
 
173
173
  if fallback_to_direct:
174
174
  logger.debug(
175
- f"Provider '{provider_name}' not found in container for {name}, "
176
- f"falling back to direct instantiation"
175
+ f"Provider '{provider_name}' not found in container for {name}, falling back to direct instantiation",
177
176
  )
178
177
  return hook_cls()
179
178
 
180
179
  raise ValueError(
181
180
  f"Hook {name} not found in container. "
182
181
  f"Expected provider name: '{provider_name}'. "
183
- f"Available providers: {[p for p in dir(container) if not p.startswith('_')]}"
182
+ f"Available providers: {[p for p in dir(container) if not p.startswith('_')]}",
184
183
  )
185
184
 
186
185
  with self._lock:
187
186
  self._container_resolver = resolver
188
187
  container_name = getattr(
189
- container.__class__, "__name__", str(container.__class__)
188
+ container.__class__,
189
+ "__name__",
190
+ str(container.__class__),
190
191
  )
191
192
  logger.info(
192
- f"Configured hook factory to use container: {container_name}"
193
+ f"Configured hook factory to use container: {container_name}",
193
194
  )
194
195
 
195
- def create(self, hook_cls: Type) -> Any:
196
+ def create(self, hook_cls: type) -> Any:
196
197
  """
197
198
  Create a hook instance using the configured resolution strategy.
198
199
 
@@ -249,7 +250,7 @@ class HookFactory:
249
250
  with self._lock:
250
251
  return self._container_resolver is not None
251
252
 
252
- def has_factory(self, hook_cls: Type) -> bool:
253
+ def has_factory(self, hook_cls: type) -> bool:
253
254
  """
254
255
  Check if a hook class has a registered factory.
255
256
 
@@ -262,7 +263,7 @@ class HookFactory:
262
263
  with self._lock:
263
264
  return hook_cls in self._specific_factories
264
265
 
265
- def get_factory(self, hook_cls: Type) -> Optional[Callable[[], Any]]:
266
+ def get_factory(self, hook_cls: type) -> Callable[[], Any] | None:
266
267
  """
267
268
  Get the registered factory for a specific hook class.
268
269
 
@@ -275,7 +276,7 @@ class HookFactory:
275
276
  with self._lock:
276
277
  return self._specific_factories.get(hook_cls)
277
278
 
278
- def list_factories(self) -> dict[Type, Callable]:
279
+ def list_factories(self) -> dict[type, Callable]:
279
280
  """
280
281
  Get a copy of all registered hook factories.
281
282
 
@@ -286,7 +287,7 @@ class HookFactory:
286
287
  return self._specific_factories.copy()
287
288
 
288
289
  @staticmethod
289
- def _default_name_resolver(hook_cls: Type) -> str:
290
+ def _default_name_resolver(hook_cls: type) -> str:
290
291
  """
291
292
  Default naming convention: LoanAccountHook -> loan_account_hook
292
293
 
@@ -303,7 +304,7 @@ class HookFactory:
303
304
 
304
305
 
305
306
  # Global singleton factory
306
- _factory: Optional[HookFactory] = None
307
+ _factory: HookFactory | None = None
307
308
  _factory_lock = threading.Lock()
308
309
 
309
310
 
@@ -329,7 +330,7 @@ def get_factory() -> HookFactory:
329
330
 
330
331
 
331
332
  # Backward-compatible module-level functions
332
- def set_hook_factory(hook_cls: Type, factory: Callable[[], Any]) -> None:
333
+ def set_hook_factory(hook_cls: type, factory: Callable[[], Any]) -> None:
333
334
  """
334
335
  Register a factory function for a specific hook class.
335
336
 
@@ -350,7 +351,7 @@ def set_hook_factory(hook_cls: Type, factory: Callable[[], Any]) -> None:
350
351
  hook_factory.register_factory(hook_cls, factory)
351
352
 
352
353
 
353
- def set_default_hook_factory(factory: Callable[[Type], Any]) -> None:
354
+ def set_default_hook_factory(factory: Callable[[type], Any]) -> None:
354
355
  """
355
356
  DEPRECATED: Use configure_hook_container with provider_resolver instead.
356
357
 
@@ -363,8 +364,7 @@ def set_default_hook_factory(factory: Callable[[Type], Any]) -> None:
363
364
  import warnings
364
365
 
365
366
  warnings.warn(
366
- "set_default_hook_factory is deprecated. "
367
- "Use configure_hook_container with provider_resolver instead.",
367
+ "set_default_hook_factory is deprecated. Use configure_hook_container with provider_resolver instead.",
368
368
  DeprecationWarning,
369
369
  stacklevel=2,
370
370
  )
@@ -379,8 +379,8 @@ def set_default_hook_factory(factory: Callable[[Type], Any]) -> None:
379
379
 
380
380
  def configure_hook_container(
381
381
  container: Any,
382
- provider_name_resolver: Optional[Callable[[Type], str]] = None,
383
- provider_resolver: Optional[Callable[[Any, Type, str], Any]] = None,
382
+ provider_name_resolver: Callable[[type], str] | None = None,
383
+ provider_resolver: Callable[[Any, type, str], Any] | None = None,
384
384
  fallback_to_direct: bool = True,
385
385
  ) -> None:
386
386
  """
@@ -415,7 +415,7 @@ def configure_hook_container(
415
415
  def configure_nested_container(
416
416
  container: Any,
417
417
  container_path: str,
418
- provider_name_resolver: Optional[Callable[[Type], str]] = None,
418
+ provider_name_resolver: Callable[[type], str] | None = None,
419
419
  fallback_to_direct: bool = True,
420
420
  ) -> None:
421
421
  """
@@ -444,8 +444,7 @@ def configure_nested_container(
444
444
  import warnings
445
445
 
446
446
  warnings.warn(
447
- "configure_nested_container is deprecated. "
448
- "Use configure_hook_container with provider_resolver instead.",
447
+ "configure_nested_container is deprecated. Use configure_hook_container with provider_resolver instead.",
449
448
  DeprecationWarning,
450
449
  stacklevel=2,
451
450
  )
@@ -457,7 +456,7 @@ def configure_nested_container(
457
456
  for part in container_path.split("."):
458
457
  if not hasattr(current, part):
459
458
  raise ValueError(
460
- f"Container path '{container_path}' not found. Missing: {part}"
459
+ f"Container path '{container_path}' not found. Missing: {part}",
461
460
  )
462
461
  provider = getattr(current, part)
463
462
  # Call provider to get next level
@@ -466,13 +465,12 @@ def configure_nested_container(
466
465
  # Get the hook provider from sub-container
467
466
  if not hasattr(current, provider_name):
468
467
  raise ValueError(
469
- f"Provider '{provider_name}' not found in sub-container. "
470
- f"Available: {[p for p in dir(current) if not p.startswith('_')]}"
468
+ f"Provider '{provider_name}' not found in sub-container. Available: {[p for p in dir(current) if not p.startswith('_')]}",
471
469
  )
472
470
 
473
471
  hook_provider = getattr(current, provider_name)
474
472
  logger.debug(
475
- f"Resolved {hook_cls.__name__} from {container_path}.{provider_name}"
473
+ f"Resolved {hook_cls.__name__} from {container_path}.{provider_name}",
476
474
  )
477
475
  return hook_provider()
478
476
 
@@ -493,7 +491,7 @@ def clear_hook_factories() -> None:
493
491
  hook_factory.clear()
494
492
 
495
493
 
496
- def create_hook_instance(hook_cls: Type) -> Any:
494
+ def create_hook_instance(hook_cls: type) -> Any:
497
495
  """
498
496
  Create a hook instance using the configured resolution strategy.
499
497
 
@@ -515,7 +513,7 @@ def create_hook_instance(hook_cls: Type) -> Any:
515
513
  return hook_factory.create(hook_cls)
516
514
 
517
515
 
518
- def get_hook_factory(hook_cls: Type) -> Optional[Callable[[], Any]]:
516
+ def get_hook_factory(hook_cls: type) -> Callable[[], Any] | None:
519
517
  """
520
518
  Get the registered factory for a specific hook class.
521
519
 
@@ -529,7 +527,7 @@ def get_hook_factory(hook_cls: Type) -> Optional[Callable[[], Any]]:
529
527
  return hook_factory.get_factory(hook_cls)
530
528
 
531
529
 
532
- def has_hook_factory(hook_cls: Type) -> bool:
530
+ def has_hook_factory(hook_cls: type) -> bool:
533
531
  """
534
532
  Check if a hook class has a registered factory.
535
533
 
@@ -554,7 +552,7 @@ def is_container_configured() -> bool:
554
552
  return hook_factory.is_container_configured()
555
553
 
556
554
 
557
- def list_registered_factories() -> dict[Type, Callable]:
555
+ def list_registered_factories() -> dict[type, Callable]:
558
556
  """
559
557
  Get a copy of all registered hook factories.
560
558
 
@@ -8,7 +8,7 @@ logger = logging.getLogger(__name__)
8
8
  class HookMeta(type):
9
9
  _registered = set()
10
10
  _class_hook_map: dict[
11
- type, set[tuple]
11
+ type, set[tuple],
12
12
  ] = {} # Track which hooks belong to which class
13
13
 
14
14
  def __new__(mcs, name, bases, namespace):
@@ -25,7 +25,7 @@ class HookMeta(type):
25
25
  - Child overrides replace parent implementations (not add to them)
26
26
  - Child can add new hook methods
27
27
  """
28
- from django_bulk_hooks.registry import register_hook, unregister_hook
28
+ from django_bulk_hooks.registry import unregister_hook
29
29
 
30
30
  # Step 1: Unregister ALL hooks from parent classes in the MRO
31
31
  # This ensures only the most-derived class owns the active hooks,
@@ -36,7 +36,7 @@ class HookMeta(type):
36
36
 
37
37
  if base in mcs._class_hook_map:
38
38
  for model_cls, event, base_cls, method_name in list(
39
- mcs._class_hook_map[base]
39
+ mcs._class_hook_map[base],
40
40
  ):
41
41
  key = (model_cls, event, base_cls, method_name)
42
42
  if key in HookMeta._registered:
@@ -44,7 +44,7 @@ class HookMeta(type):
44
44
  HookMeta._registered.discard(key)
45
45
  logger.debug(
46
46
  f"Unregistered base hook: {base_cls.__name__}.{method_name} "
47
- f"(superseded by {cls.__name__})"
47
+ f"(superseded by {cls.__name__})",
48
48
  )
49
49
 
50
50
  # Step 2: Register all hook methods on this class (including inherited ones)
@@ -79,7 +79,7 @@ class HookMeta(type):
79
79
  mcs._class_hook_map[cls].add(key)
80
80
  logger.debug(
81
81
  f"Registered hook: {cls.__name__}.{method_name} "
82
- f"for {model_cls.__name__}.{event}"
82
+ f"for {model_cls.__name__}.{event}",
83
83
  )
84
84
 
85
85
  @classmethod
@@ -112,4 +112,3 @@ class Hook(metaclass=HookMeta):
112
112
  a single, consistent execution path.
113
113
  """
114
114
 
115
- pass
@@ -8,11 +8,12 @@ NOTE: These helpers are pure changeset builders - they don't fetch data.
8
8
  Data fetching is the responsibility of ModelAnalyzer.
9
9
  """
10
10
 
11
- from django_bulk_hooks.changeset import ChangeSet, RecordChange
11
+ from django_bulk_hooks.changeset import ChangeSet
12
+ from django_bulk_hooks.changeset import RecordChange
12
13
 
13
14
 
14
15
  def build_changeset_for_update(
15
- model_cls, instances, update_kwargs, old_records_map=None, **meta
16
+ model_cls, instances, update_kwargs, old_records_map=None, **meta,
16
17
  ):
17
18
  """
18
19
  Build ChangeSet for update operations.
@@ -32,7 +33,7 @@ def build_changeset_for_update(
32
33
 
33
34
  changes = [
34
35
  RecordChange(
35
- new, old_records_map.get(new.pk), changed_fields=list(update_kwargs.keys())
36
+ new, old_records_map.get(new.pk), changed_fields=list(update_kwargs.keys()),
36
37
  )
37
38
  for new in instances
38
39
  ]
@@ -28,7 +28,7 @@ class HookModelMixin(models.Model):
28
28
  # Delegate to coordinator (consistent with save/delete)
29
29
  is_create = self.pk is None
30
30
  self.__class__.objects.get_queryset().coordinator.clean(
31
- [self], is_create=is_create
31
+ [self], is_create=is_create,
32
32
  )
33
33
 
34
34
  def save(self, *args, bypass_hooks=False, **kwargs):
@@ -48,18 +48,17 @@ class HookModelMixin(models.Model):
48
48
  # Delegate to bulk_create which handles all hook logic
49
49
  result = self.__class__.objects.bulk_create([self])
50
50
  return result[0] if result else self
51
- else:
52
- # Delegate to bulk_update which handles all hook logic
53
- update_fields = kwargs.get("update_fields")
54
- if update_fields is None:
55
- # Update all non-auto fields
56
- update_fields = [
57
- f.name
58
- for f in self.__class__._meta.fields
59
- if not f.auto_created and f.name != "id"
60
- ]
61
- self.__class__.objects.bulk_update([self], update_fields)
62
- return self
51
+ # Delegate to bulk_update which handles all hook logic
52
+ update_fields = kwargs.get("update_fields")
53
+ if update_fields is None:
54
+ # Update all non-auto fields
55
+ update_fields = [
56
+ f.name
57
+ for f in self.__class__._meta.fields
58
+ if not f.auto_created and f.name != "id"
59
+ ]
60
+ self.__class__.objects.bulk_update([self], update_fields)
61
+ return self
63
62
 
64
63
  def delete(self, *args, bypass_hooks=False, **kwargs):
65
64
  """