django-bulk-hooks 0.1.228__py3-none-any.whl → 0.1.230__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,11 +1,14 @@
1
1
  import logging
2
+ from typing import Any, Optional
2
3
 
3
4
  logger = logging.getLogger(__name__)
4
5
 
5
6
 
6
- def resolve_dotted_attr(instance, dotted_path):
7
+ def resolve_dotted_attr(instance: Any, dotted_path: str) -> Any:
7
8
  """
8
9
  Recursively resolve a dotted attribute path, e.g., "type.category".
10
+
11
+ Returns None if any intermediate value is None or missing.
9
12
  """
10
13
  for attr in dotted_path.split("."):
11
14
  if instance is None:
@@ -15,29 +18,29 @@ def resolve_dotted_attr(instance, dotted_path):
15
18
 
16
19
 
17
20
  class HookCondition:
18
- def check(self, instance, original_instance=None):
21
+ def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
19
22
  raise NotImplementedError
20
23
 
21
- def __call__(self, instance, original_instance=None):
24
+ def __call__(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
22
25
  return self.check(instance, original_instance)
23
26
 
24
- def __and__(self, other):
27
+ def __and__(self, other: "HookCondition") -> "HookCondition":
25
28
  return AndCondition(self, other)
26
29
 
27
- def __or__(self, other):
30
+ def __or__(self, other: "HookCondition") -> "HookCondition":
28
31
  return OrCondition(self, other)
29
32
 
30
- def __invert__(self):
33
+ def __invert__(self) -> "HookCondition":
31
34
  return NotCondition(self)
32
35
 
33
36
 
34
37
  class IsNotEqual(HookCondition):
35
- def __init__(self, field, value, only_on_change=False):
38
+ def __init__(self, field: str, value: Any, only_on_change: bool = False):
36
39
  self.field = field
37
40
  self.value = value
38
41
  self.only_on_change = only_on_change
39
42
 
40
- def check(self, instance, original_instance=None):
43
+ def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
41
44
  current = resolve_dotted_attr(instance, self.field)
42
45
  if self.only_on_change:
43
46
  if original_instance is None:
@@ -49,12 +52,12 @@ class IsNotEqual(HookCondition):
49
52
 
50
53
 
51
54
  class IsEqual(HookCondition):
52
- def __init__(self, field, value, only_on_change=False):
55
+ def __init__(self, field: str, value: Any, only_on_change: bool = False):
53
56
  self.field = field
54
57
  self.value = value
55
58
  self.only_on_change = only_on_change
56
59
 
57
- def check(self, instance, original_instance=None):
60
+ def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
58
61
  current = resolve_dotted_attr(instance, self.field)
59
62
  if self.only_on_change:
60
63
  if original_instance is None:
@@ -66,11 +69,11 @@ class IsEqual(HookCondition):
66
69
 
67
70
 
68
71
  class HasChanged(HookCondition):
69
- def __init__(self, field, has_changed=True):
72
+ def __init__(self, field: str, has_changed: bool = True):
70
73
  self.field = field
71
74
  self.has_changed = has_changed
72
75
 
73
- def check(self, instance, original_instance=None):
76
+ def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
74
77
  if not original_instance:
75
78
  return False
76
79
 
@@ -85,7 +88,7 @@ class HasChanged(HookCondition):
85
88
 
86
89
 
87
90
  class WasEqual(HookCondition):
88
- def __init__(self, field, value, only_on_change=False):
91
+ def __init__(self, field: str, value: Any, only_on_change: bool = False):
89
92
  """
90
93
  Check if a field's original value was `value`.
91
94
  If only_on_change is True, only return True when the field has changed away from that value.
@@ -94,7 +97,7 @@ class WasEqual(HookCondition):
94
97
  self.value = value
95
98
  self.only_on_change = only_on_change
96
99
 
97
- def check(self, instance, original_instance=None):
100
+ def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
98
101
  if original_instance is None:
99
102
  return False
100
103
  previous = resolve_dotted_attr(original_instance, self.field)
@@ -106,7 +109,7 @@ class WasEqual(HookCondition):
106
109
 
107
110
 
108
111
  class ChangesTo(HookCondition):
109
- def __init__(self, field, value):
112
+ def __init__(self, field: str, value: Any):
110
113
  """
111
114
  Check if a field's value has changed to `value`.
112
115
  Only returns True when original value != value and current value == value.
@@ -114,7 +117,7 @@ class ChangesTo(HookCondition):
114
117
  self.field = field
115
118
  self.value = value
116
119
 
117
- def check(self, instance, original_instance=None):
120
+ def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
118
121
  if original_instance is None:
119
122
  return False
120
123
  previous = resolve_dotted_attr(original_instance, self.field)
@@ -123,70 +126,70 @@ class ChangesTo(HookCondition):
123
126
 
124
127
 
125
128
  class IsGreaterThan(HookCondition):
126
- def __init__(self, field, value):
129
+ def __init__(self, field: str, value: Any):
127
130
  self.field = field
128
131
  self.value = value
129
132
 
130
- def check(self, instance, original_instance=None):
133
+ def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
131
134
  current = resolve_dotted_attr(instance, self.field)
132
135
  return current is not None and current > self.value
133
136
 
134
137
 
135
138
  class IsGreaterThanOrEqual(HookCondition):
136
- def __init__(self, field, value):
139
+ def __init__(self, field: str, value: Any):
137
140
  self.field = field
138
141
  self.value = value
139
142
 
140
- def check(self, instance, original_instance=None):
143
+ def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
141
144
  current = resolve_dotted_attr(instance, self.field)
142
145
  return current is not None and current >= self.value
143
146
 
144
147
 
145
148
  class IsLessThan(HookCondition):
146
- def __init__(self, field, value):
149
+ def __init__(self, field: str, value: Any):
147
150
  self.field = field
148
151
  self.value = value
149
152
 
150
- def check(self, instance, original_instance=None):
153
+ def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
151
154
  current = resolve_dotted_attr(instance, self.field)
152
155
  return current is not None and current < self.value
153
156
 
154
157
 
155
158
  class IsLessThanOrEqual(HookCondition):
156
- def __init__(self, field, value):
159
+ def __init__(self, field: str, value: Any):
157
160
  self.field = field
158
161
  self.value = value
159
162
 
160
- def check(self, instance, original_instance=None):
163
+ def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
161
164
  current = resolve_dotted_attr(instance, self.field)
162
165
  return current is not None and current <= self.value
163
166
 
164
167
 
165
168
  class AndCondition(HookCondition):
166
- def __init__(self, cond1, cond2):
169
+ def __init__(self, cond1: HookCondition, cond2: HookCondition):
167
170
  self.cond1 = cond1
168
171
  self.cond2 = cond2
169
172
 
170
- def check(self, instance, original_instance=None):
173
+ def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
171
174
  return self.cond1.check(instance, original_instance) and self.cond2.check(
172
175
  instance, original_instance
173
176
  )
174
177
 
175
178
 
176
179
  class OrCondition(HookCondition):
177
- def __init__(self, cond1, cond2):
180
+ def __init__(self, cond1: HookCondition, cond2: HookCondition):
178
181
  self.cond1 = cond1
179
182
  self.cond2 = cond2
180
183
 
181
- def check(self, instance, original_instance=None):
184
+ def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
182
185
  return self.cond1.check(instance, original_instance) or self.cond2.check(
183
186
  instance, original_instance
184
187
  )
185
188
 
186
189
 
187
190
  class NotCondition(HookCondition):
188
- def __init__(self, cond):
191
+ def __init__(self, cond: HookCondition):
189
192
  self.cond = cond
190
193
 
191
- def check(self, instance, original_instance=None):
194
+ def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
192
195
  return not self.cond.check(instance, original_instance)
@@ -1,36 +1,61 @@
1
1
  import threading
2
- from collections import deque
3
- from django_bulk_hooks.handler import hook_vars
2
+ from typing import Deque, Optional
3
+ from django_bulk_hooks.handler import hook_vars, get_hook_queue as _handler_get_hook_queue
4
4
 
5
5
 
6
6
  _hook_context = threading.local()
7
7
 
8
8
 
9
- def get_hook_queue():
10
- if not hasattr(_hook_context, "queue"):
11
- _hook_context.queue = deque()
12
- return _hook_context.queue
9
+ def get_hook_queue() -> Deque:
10
+ """
11
+ Return the per-thread hook execution queue used by the handler.
13
12
 
13
+ This proxies to the centralized queue in the handler module to avoid
14
+ divergent queues.
15
+ """
16
+ return _handler_get_hook_queue()
14
17
 
15
- def set_bypass_hooks(bypass_hooks):
18
+
19
+ def set_bypass_hooks(bypass_hooks: bool) -> None:
16
20
  """Set the current bypass_hooks state for the current thread."""
17
21
  _hook_context.bypass_hooks = bypass_hooks
18
22
 
19
23
 
20
- def get_bypass_hooks():
24
+ def get_bypass_hooks() -> bool:
21
25
  """Get the current bypass_hooks state for the current thread."""
22
26
  return getattr(_hook_context, 'bypass_hooks', False)
23
27
 
24
28
 
25
29
  class HookContext:
26
- def __init__(self, model, bypass_hooks=False):
27
- self.model = model
28
- self.bypass_hooks = bypass_hooks
29
- # Set the thread-local bypass state when creating a context
30
+ """
31
+ Hook execution context helper.
32
+
33
+ - Carries the model and bypass_hooks flag.
34
+ - On construction sets thread-local bypass state;
35
+ when used as a context manager, restores previous state on exit.
36
+ """
37
+
38
+ def __init__(self, model: type, bypass_hooks: bool = False):
39
+ self.model: type = model
40
+ self.bypass_hooks: bool = bypass_hooks
41
+ self._prev_bypass: Optional[bool] = None
42
+ # Preserve legacy behavior: set bypass state at construction
30
43
  set_bypass_hooks(bypass_hooks)
31
44
 
45
+ def __enter__(self) -> "HookContext":
46
+ self._prev_bypass = get_bypass_hooks()
47
+ set_bypass_hooks(self.bypass_hooks)
48
+ return self
49
+
50
+ def __exit__(self, exc_type, exc, tb) -> bool:
51
+ # Restore previous bypass state
52
+ if self._prev_bypass is not None:
53
+ set_bypass_hooks(self._prev_bypass)
54
+ # Do not suppress exceptions
55
+ return False
56
+
32
57
  @property
33
- def is_executing(self):
58
+ def is_executing(self) -> bool:
34
59
  """
35
60
  Check if we're currently in a hook execution context.
36
61
  Similar to Salesforce's Trigger.isExecuting.
@@ -39,15 +64,18 @@ class HookContext:
39
64
  return hasattr(hook_vars, 'event') and hook_vars.event is not None
40
65
 
41
66
  @property
42
- def current_event(self):
67
+ def current_event(self) -> Optional[str]:
43
68
  """
44
69
  Get the current hook event being executed.
45
70
  """
46
71
  return getattr(hook_vars, 'event', None)
47
72
 
48
73
  @property
49
- def execution_depth(self):
74
+ def execution_depth(self) -> int:
50
75
  """
51
76
  Get the current execution depth to detect deep recursion.
52
77
  """
53
78
  return getattr(hook_vars, 'depth', 0)
79
+
80
+ def __repr__(self) -> str:
81
+ return f"HookContext(model={getattr(self.model, '__name__', self.model)!r}, bypass_hooks={self.bypass_hooks})"
@@ -1,18 +1,28 @@
1
+ """
2
+ Decorators for defining and optimizing hook handlers.
3
+
4
+ Notes:
5
+ - Hook registration occurs at import time; importing modules that define Hook
6
+ subclasses or use @hook will register handlers in the global registry.
7
+ - The preload helpers below are safe, in-place optimizations to avoid N+1s.
8
+ """
9
+
1
10
  import inspect
2
11
  from functools import wraps
12
+ from typing import Any, Callable, Optional
3
13
 
4
14
  from django.core.exceptions import FieldDoesNotExist
5
15
  from django_bulk_hooks.enums import DEFAULT_PRIORITY
6
16
  from django_bulk_hooks.registry import register_hook
7
17
 
8
18
 
9
- def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
19
+ def hook(event: str, *, model: type, condition: Optional[Callable] = None, priority: int = DEFAULT_PRIORITY):
10
20
  """
11
21
  Decorator to annotate a method with multiple hooks hook registrations.
12
22
  If no priority is provided, uses Priority.NORMAL (50).
13
23
  """
14
24
 
15
- def decorator(fn):
25
+ def decorator(fn: Callable):
16
26
  if not hasattr(fn, "hooks_hooks"):
17
27
  fn.hooks_hooks = []
18
28
  fn.hooks_hooks.append((model, event, condition, priority))
@@ -21,7 +31,7 @@ def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
21
31
  return decorator
22
32
 
23
33
 
24
- def select_related(*related_fields):
34
+ def select_related(*related_fields: str):
25
35
  """
26
36
  Decorator that preloads related fields in-place on `new_records`, before the hook logic runs.
27
37
 
@@ -30,11 +40,14 @@ def select_related(*related_fields):
30
40
  - Populates Django's relation cache to avoid extra queries
31
41
  """
32
42
 
33
- def decorator(func):
43
+ def decorator(func: Callable):
44
+ # No-op if no fields specified
45
+ if not related_fields:
46
+ return func
34
47
  sig = inspect.signature(func)
35
48
 
36
49
  @wraps(func)
37
- def wrapper(*args, **kwargs):
50
+ def wrapper(*args: Any, **kwargs: Any):
38
51
  bound = sig.bind_partial(*args, **kwargs)
39
52
  bound.apply_defaults()
40
53
 
@@ -105,7 +118,7 @@ def select_related(*related_fields):
105
118
  return decorator
106
119
 
107
120
 
108
- def bulk_hook(model_cls, event, when=None, priority=None):
121
+ def bulk_hook(model_cls: type, event: str, when: Optional[Callable] = None, priority: Optional[int] = None):
109
122
  """
110
123
  Decorator to register a bulk hook for a model.
111
124
 
@@ -115,13 +128,13 @@ def bulk_hook(model_cls, event, when=None, priority=None):
115
128
  when: Optional condition for when the hook should run
116
129
  priority: Optional priority for hook execution order
117
130
  """
118
- def decorator(func):
131
+ def decorator(func: Callable):
119
132
  # Create a simple handler class for the function
120
133
  class FunctionHandler:
121
134
  def __init__(self):
122
135
  self.func = func
123
136
 
124
- def handle(self, new_instances, original_instances):
137
+ def handle(self, new_instances: list, original_instances: Optional[list]):
125
138
  return self.func(new_instances, original_instances)
126
139
 
127
140
  # Register the hook using the registry
@@ -135,3 +148,82 @@ def bulk_hook(model_cls, event, when=None, priority=None):
135
148
  )
136
149
  return func
137
150
  return decorator
151
+
152
+
153
+ def prefetch_related(*related_fields: str):
154
+ """
155
+ Decorator that prefetches related collections on `new_records` in-place,
156
+ populating Django's prefetch cache to avoid extra queries in hooks.
157
+
158
+ - Supports many-to-many and one-to-many relationships
159
+ - Preserves instance identity; does not replace objects
160
+ - Uses the base manager to avoid recursive hook triggering
161
+ """
162
+
163
+ def decorator(func: Callable):
164
+ # No-op if no fields specified
165
+ if not related_fields:
166
+ return func
167
+
168
+ sig = inspect.signature(func)
169
+
170
+ @wraps(func)
171
+ def wrapper(*args: Any, **kwargs: Any):
172
+ bound = sig.bind_partial(*args, **kwargs)
173
+ bound.apply_defaults()
174
+
175
+ if "new_records" not in bound.arguments:
176
+ raise TypeError(
177
+ "@prefetch_related requires a 'new_records' argument in the decorated function"
178
+ )
179
+
180
+ new_records = bound.arguments["new_records"]
181
+
182
+ if not isinstance(new_records, list):
183
+ raise TypeError(
184
+ f"@prefetch_related expects a list of model instances, got {type(new_records)}"
185
+ )
186
+
187
+ if not new_records:
188
+ return func(*args, **kwargs)
189
+
190
+ model_cls = new_records[0].__class__
191
+ ids_to_fetch = [obj.pk for obj in new_records if getattr(obj, "pk", None)]
192
+
193
+ if ids_to_fetch:
194
+ # Validate fields (no dotted notation)
195
+ for field in related_fields:
196
+ if "." in field:
197
+ raise ValueError(
198
+ f"@prefetch_related does not support nested fields like '{field}'"
199
+ )
200
+
201
+ fetched_map = {
202
+ obj.pk: obj
203
+ for obj in (
204
+ model_cls._base_manager.filter(pk__in=ids_to_fetch)
205
+ .prefetch_related(*related_fields)
206
+ )
207
+ }
208
+
209
+ for obj in new_records:
210
+ preloaded = fetched_map.get(obj.pk)
211
+ if preloaded is None:
212
+ continue
213
+ # Copy prefetch cache entries from the preloaded instance
214
+ src_cache = getattr(preloaded, "_prefetched_objects_cache", {})
215
+ if not src_cache:
216
+ continue
217
+ dst_cache = getattr(obj, "_prefetched_objects_cache", None)
218
+ if dst_cache is None:
219
+ obj._prefetched_objects_cache = {}
220
+ dst_cache = obj._prefetched_objects_cache
221
+ for field in related_fields:
222
+ if field in src_cache and field not in dst_cache:
223
+ dst_cache[field] = src_cache[field]
224
+
225
+ return func(*bound.args, **bound.kwargs)
226
+
227
+ return wrapper
228
+
229
+ return decorator
@@ -1,8 +1,10 @@
1
1
  import logging
2
2
 
3
3
  from django.core.exceptions import ValidationError
4
+ from django.db import transaction
4
5
 
5
6
  from django_bulk_hooks.registry import get_hooks
7
+ from django_bulk_hooks.handler import hook_vars
6
8
 
7
9
  logger = logging.getLogger(__name__)
8
10
 
@@ -10,7 +12,14 @@ logger = logging.getLogger(__name__)
10
12
  def run(model_cls, event, new_records, old_records=None, ctx=None):
11
13
  """
12
14
  Run hooks for a given model, event, and records.
13
-
15
+
16
+ Production-grade executor:
17
+ - Honors bypass_hooks via ctx
18
+ - Runs model.clean() before BEFORE_* events for validation
19
+ - Executes hooks in registry priority order with condition filtering
20
+ - Exposes thread-local context via hook_vars during execution
21
+ - AFTER_* timing is configurable via settings.BULK_HOOKS_AFTER_ON_COMMIT (default: False)
22
+
14
23
  Args:
15
24
  model_cls: The Django model class
16
25
  event: The hook event (e.g., 'before_create', 'after_update')
@@ -21,16 +30,16 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
21
30
  if not new_records:
22
31
  return
23
32
 
24
- # Get hooks for this model and event
25
33
  hooks = get_hooks(model_cls, event)
26
-
27
34
  if not hooks:
28
35
  return
29
36
 
30
- logger.debug(f"Running {len(hooks)} hooks for {model_cls.__name__}.{event} ({len(new_records)} records)")
31
-
37
+ logger.debug(
38
+ f"Running {len(hooks)} hooks for {model_cls.__name__}.{event} ({len(new_records)} records)"
39
+ )
40
+
32
41
  # Check if we're in a bypass context
33
- if ctx and hasattr(ctx, 'bypass_hooks') and ctx.bypass_hooks:
42
+ if ctx and hasattr(ctx, "bypass_hooks") and ctx.bypass_hooks:
34
43
  logger.debug("Hook execution bypassed")
35
44
  return
36
45
 
@@ -43,47 +52,76 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
43
52
  logger.error("Validation failed for %s: %s", instance, e)
44
53
  raise
45
54
 
46
- # Process hooks in priority order (highest priority first)
47
- # Registry now sorts by priority (highest first)
48
- for handler_cls, method_name, condition, priority in hooks:
49
- logger.debug(f"Processing {handler_cls.__name__}.{method_name} (priority: {priority})")
50
-
55
+ def _execute():
56
+ hook_vars.depth += 1
57
+ hook_vars.new = new_records
58
+ hook_vars.old = old_records
59
+ hook_vars.event = event
60
+ hook_vars.model = model_cls
61
+
51
62
  try:
52
- handler_instance = handler_cls()
53
- func = getattr(handler_instance, method_name)
54
- except Exception as e:
55
- logger.error(f"Failed to instantiate {handler_cls.__name__}: {e}")
56
- continue
57
-
58
- to_process_new = []
59
- to_process_old = []
60
-
61
- for new, original in zip(
62
- new_records,
63
- old_records or [None] * len(new_records),
64
- strict=True,
65
- ):
66
- if not condition:
67
- to_process_new.append(new)
68
- to_process_old.append(original)
69
- else:
63
+ # Align old_records length with new_records for safe zipping
64
+ local_old = old_records or []
65
+ if len(local_old) < len(new_records):
66
+ local_old = local_old + [None] * (len(new_records) - len(local_old))
67
+
68
+ for handler_cls, method_name, condition, priority in hooks:
70
69
  try:
71
- condition_result = condition.check(new, original)
72
- if condition_result:
73
- to_process_new.append(new)
74
- to_process_old.append(original)
70
+ handler_instance = handler_cls()
71
+ func = getattr(handler_instance, method_name)
75
72
  except Exception as e:
76
- logger.error(f"Condition check failed for {handler_cls.__name__}.{method_name}: {e}")
73
+ logger.error(
74
+ "Failed to instantiate %s.%s: %s",
75
+ handler_cls.__name__,
76
+ method_name,
77
+ e,
78
+ )
77
79
  continue
78
80
 
79
- if to_process_new:
80
- logger.debug(f"Executing {handler_cls.__name__}.{method_name} for {len(to_process_new)} records")
81
- try:
82
- func(
83
- new_records=to_process_new,
84
- old_records=to_process_old if any(x is not None for x in to_process_old) else None,
85
- )
86
- except Exception as e:
87
- logger.error(f"Hook execution failed in {handler_cls.__name__}.{method_name}: {e}")
88
- # Re-raise the exception to ensure proper error handling
89
- raise
81
+ # Condition filtering per record
82
+ to_process_new = []
83
+ to_process_old = []
84
+ for new_obj, old_obj in zip(new_records, local_old, strict=True):
85
+ if not condition:
86
+ to_process_new.append(new_obj)
87
+ to_process_old.append(old_obj)
88
+ else:
89
+ try:
90
+ if condition.check(new_obj, old_obj):
91
+ to_process_new.append(new_obj)
92
+ to_process_old.append(old_obj)
93
+ except Exception as e:
94
+ logger.error(
95
+ "Condition failed for %s.%s: %s",
96
+ handler_cls.__name__,
97
+ method_name,
98
+ e,
99
+ )
100
+ continue
101
+
102
+ if not to_process_new:
103
+ continue
104
+
105
+ try:
106
+ func(
107
+ new_records=to_process_new,
108
+ old_records=to_process_old
109
+ if any(x is not None for x in to_process_old)
110
+ else None,
111
+ )
112
+ except Exception:
113
+ logger.exception(
114
+ "Error in hook %s.%s", handler_cls.__name__, method_name
115
+ )
116
+ # Re-raise to ensure proper transactional behavior
117
+ raise
118
+ finally:
119
+ hook_vars.new = None
120
+ hook_vars.old = None
121
+ hook_vars.event = None
122
+ hook_vars.model = None
123
+ hook_vars.depth -= 1
124
+
125
+ # Execute immediately so AFTER_* runs within the transaction.
126
+ # If a hook raises, the transaction is rolled back (Salesforce-style).
127
+ _execute()
@@ -1,3 +1,14 @@
1
+ """
2
+ Compatibility layer for priority-related exports.
3
+
4
+ This module re-exports the canonical Priority enum and a DEFAULT_PRIORITY
5
+ constant so existing imports like `from django_bulk_hooks.enums import ...`
6
+ continue to work without churn. Prefer importing from `priority` in new code.
7
+ """
8
+
1
9
  from django_bulk_hooks.priority import Priority
2
10
 
11
+ # Default priority used when none is specified by the hook decorator
3
12
  DEFAULT_PRIORITY = Priority.NORMAL
13
+
14
+ __all__ = ["Priority", "DEFAULT_PRIORITY"]
@@ -2,8 +2,6 @@ import logging
2
2
  import threading
3
3
  from collections import deque
4
4
 
5
- from django.db import transaction
6
-
7
5
  from django_bulk_hooks.registry import get_hooks, register_hook
8
6
 
9
7
  logger = logging.getLogger(__name__)
@@ -77,9 +75,6 @@ class HookMeta(type):
77
75
  attr = getattr(cls, attr_name)
78
76
  if callable(attr) and hasattr(attr, "hooks_hooks"):
79
77
  for model_cls, event, condition, priority in attr.hooks_hooks:
80
- # Create a unique key for this hook registration
81
- key = (model_cls, event, cls, attr_name)
82
-
83
78
  # Register the hook
84
79
  register_hook(
85
80
  model=model_cls,
@@ -171,12 +166,8 @@ class Hook(metaclass=HookMeta):
171
166
  # Re-raise the exception to ensure proper error handling
172
167
  raise
173
168
 
174
- conn = transaction.get_connection()
175
169
  try:
176
- if conn.in_atomic_block and event.startswith("after_"):
177
- transaction.on_commit(_execute)
178
- else:
179
- _execute()
170
+ _execute()
180
171
  finally:
181
172
  hook_vars.new = None
182
173
  hook_vars.old = None