django-bulk-hooks 0.1.230__tar.gz → 0.1.231__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 (18) hide show
  1. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.231}/PKG-INFO +1 -1
  2. django_bulk_hooks-0.1.231/django_bulk_hooks/__init__.py +15 -0
  3. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.231}/django_bulk_hooks/decorators.py +11 -0
  4. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.231}/django_bulk_hooks/engine.py +50 -17
  5. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.231}/django_bulk_hooks/handler.py +11 -57
  6. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.231}/django_bulk_hooks/models.py +6 -4
  7. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.231}/django_bulk_hooks/registry.py +67 -2
  8. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.231}/pyproject.toml +1 -1
  9. django_bulk_hooks-0.1.230/django_bulk_hooks/__init__.py +0 -4
  10. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.231}/LICENSE +0 -0
  11. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.231}/README.md +0 -0
  12. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.231}/django_bulk_hooks/conditions.py +0 -0
  13. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.231}/django_bulk_hooks/constants.py +0 -0
  14. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.231}/django_bulk_hooks/context.py +0 -0
  15. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.231}/django_bulk_hooks/enums.py +0 -0
  16. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.231}/django_bulk_hooks/manager.py +0 -0
  17. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.231}/django_bulk_hooks/priority.py +0 -0
  18. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.231}/django_bulk_hooks/queryset.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.230
3
+ Version: 0.1.231
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
@@ -0,0 +1,15 @@
1
+ from django_bulk_hooks.handler import Hook
2
+ from django_bulk_hooks.manager import BulkHookManager
3
+ from django_bulk_hooks.decorators import hook
4
+ from django_bulk_hooks.priority import Priority
5
+ from django_bulk_hooks.context import HookContext
6
+ from django_bulk_hooks.models import HookModelMixin
7
+
8
+ __all__ = [
9
+ "Hook",
10
+ "hook",
11
+ "Priority",
12
+ "HookContext",
13
+ "HookModelMixin",
14
+ "BulkHookManager",
15
+ ]
@@ -129,6 +129,17 @@ def bulk_hook(model_cls: type, event: str, when: Optional[Callable] = None, prio
129
129
  priority: Optional priority for hook execution order
130
130
  """
131
131
  def decorator(func: Callable):
132
+ # Deprecation: prefer @hook(event, model=...) on a Hook subclass
133
+ try:
134
+ import warnings
135
+ warnings.warn(
136
+ "bulk_hook is deprecated; use @hook(event, model=..., condition=..., priority=...) on a Hook subclass",
137
+ DeprecationWarning,
138
+ stacklevel=2,
139
+ )
140
+ except Exception:
141
+ pass
142
+
132
143
  # Create a simple handler class for the function
133
144
  class FunctionHandler:
134
145
  def __init__(self):
@@ -2,6 +2,7 @@ import logging
2
2
 
3
3
  from django.core.exceptions import ValidationError
4
4
  from django.db import transaction
5
+ from django.conf import settings
5
6
 
6
7
  from django_bulk_hooks.registry import get_hooks
7
8
  from django_bulk_hooks.handler import hook_vars
@@ -9,6 +10,18 @@ from django_bulk_hooks.handler import hook_vars
9
10
  logger = logging.getLogger(__name__)
10
11
 
11
12
 
13
+ class AggregatedHookError(Exception):
14
+ """Raised when multiple hook handlers fail under best-effort policy."""
15
+
16
+ def __init__(self, errors):
17
+ self.errors = errors
18
+ message_lines = [
19
+ "Multiple hook errors occurred:",
20
+ *(f"- {ctx}: {exc}" for ctx, exc in errors),
21
+ ]
22
+ super().__init__("\n".join(message_lines))
23
+
24
+
12
25
  def run(model_cls, event, new_records, old_records=None, ctx=None):
13
26
  """
14
27
  Run hooks for a given model, event, and records.
@@ -54,16 +67,35 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
54
67
 
55
68
  def _execute():
56
69
  hook_vars.depth += 1
57
- hook_vars.new = new_records
58
- hook_vars.old = old_records
59
70
  hook_vars.event = event
60
- hook_vars.model = model_cls
61
71
 
62
72
  try:
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))
73
+ # Build deterministic pairing of new and old by primary key when possible
74
+ def pair_records(new_list, old_list):
75
+ if not old_list:
76
+ # No old records; pair each new with None
77
+ return [(n, None) for n in new_list]
78
+
79
+ # If all new have PKs, align by PK preserving new order
80
+ if all(getattr(n, "pk", None) is not None for n in new_list):
81
+ old_by_pk = {getattr(o, "pk", None): o for o in old_list if o is not None}
82
+ pairs = []
83
+ for n in new_list:
84
+ pk = getattr(n, "pk", None)
85
+ pairs.append((n, old_by_pk.get(pk)))
86
+ return pairs
87
+
88
+ # Fallback: best-effort positional pairing (create flows etc.)
89
+ pairs = []
90
+ for idx, n in enumerate(new_list):
91
+ o = old_list[idx] if idx < len(old_list) else None
92
+ pairs.append((n, o))
93
+ return pairs
94
+
95
+ pairs = pair_records(new_records, old_records or [])
96
+
97
+ failure_policy = getattr(settings, "BULK_HOOKS_FAILURE_POLICY", "fail_fast")
98
+ collected_errors = []
67
99
 
68
100
  for handler_cls, method_name, condition, priority in hooks:
69
101
  try:
@@ -78,10 +110,10 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
78
110
  )
79
111
  continue
80
112
 
81
- # Condition filtering per record
113
+ # Condition filtering per record using the deterministic pairs
82
114
  to_process_new = []
83
115
  to_process_old = []
84
- for new_obj, old_obj in zip(new_records, local_old, strict=True):
116
+ for new_obj, old_obj in pairs:
85
117
  if not condition:
86
118
  to_process_new.append(new_obj)
87
119
  to_process_old.append(old_obj)
@@ -105,21 +137,22 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
105
137
  try:
106
138
  func(
107
139
  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,
140
+ old_records=to_process_old if any(x is not None for x in to_process_old) else None,
111
141
  )
112
- except Exception:
142
+ except Exception as e:
113
143
  logger.exception(
114
144
  "Error in hook %s.%s", handler_cls.__name__, method_name
115
145
  )
116
- # Re-raise to ensure proper transactional behavior
146
+ if failure_policy == "best_effort":
147
+ collected_errors.append((f"{handler_cls.__name__}.{method_name}", e))
148
+ continue
149
+ # fail_fast
117
150
  raise
151
+
152
+ if collected_errors:
153
+ raise AggregatedHookError(collected_errors)
118
154
  finally:
119
- hook_vars.new = None
120
- hook_vars.old = None
121
155
  hook_vars.event = None
122
- hook_vars.model = None
123
156
  hook_vars.depth -= 1
124
157
 
125
158
  # Execute immediately so AFTER_* runs within the transaction.
@@ -2,7 +2,7 @@ import logging
2
2
  import threading
3
3
  from collections import deque
4
4
 
5
- from django_bulk_hooks.registry import get_hooks, register_hook
5
+ from django_bulk_hooks.registry import register_hook
6
6
 
7
7
  logger = logging.getLogger(__name__)
8
8
 
@@ -108,16 +108,11 @@ class Hook(metaclass=HookMeta):
108
108
  old_records: list = None,
109
109
  **kwargs,
110
110
  ) -> None:
111
- queue = get_hook_queue()
112
- queue.append((cls, event, model, new_records, old_records, kwargs))
113
-
114
- if len(queue) > 1:
115
- return # nested call, will be processed by outermost
116
-
117
- # only outermost handle will process the queue
118
- while queue:
119
- cls_, event_, model_, new_, old_, kw_ = queue.popleft()
120
- cls_._process(event_, model_, new_, old_, **kw_)
111
+ """
112
+ Legacy entrypoint; delegate to the unified executor in engine.run.
113
+ """
114
+ from django_bulk_hooks.engine import run as run_engine
115
+ run_engine(model, event, new_records or [], old_records or None, ctx=kwargs.get("ctx"))
121
116
 
122
117
  @classmethod
123
118
  def _process(
@@ -128,52 +123,11 @@ class Hook(metaclass=HookMeta):
128
123
  old_records,
129
124
  **kwargs,
130
125
  ):
131
- hook_vars.depth += 1
132
- hook_vars.new = new_records
133
- hook_vars.old = old_records
134
- hook_vars.event = event
135
- hook_vars.model = model
136
-
137
- hooks = get_hooks(model, event)
138
-
139
- def _execute():
140
- new_local = new_records or []
141
- old_local = old_records or []
142
- if len(old_local) < len(new_local):
143
- old_local += [None] * (len(new_local) - len(old_local))
144
-
145
- for handler_cls, method_name, condition, priority in hooks:
146
- if condition is not None:
147
- checks = [
148
- condition.check(n, o) for n, o in zip(new_local, old_local)
149
- ]
150
- if not any(checks):
151
- continue
152
-
153
- handler = handler_cls()
154
- method = getattr(handler, method_name)
155
-
156
- try:
157
- method(
158
- new_records=new_local,
159
- old_records=old_local,
160
- **kwargs,
161
- )
162
- except Exception:
163
- logger.exception(
164
- "Error in hook %s.%s", handler_cls.__name__, method_name
165
- )
166
- # Re-raise the exception to ensure proper error handling
167
- raise
168
-
169
- try:
170
- _execute()
171
- finally:
172
- hook_vars.new = None
173
- hook_vars.old = None
174
- hook_vars.event = None
175
- hook_vars.model = None
176
- hook_vars.depth -= 1
126
+ """
127
+ Legacy internal; delegate to engine.run to avoid divergence.
128
+ """
129
+ from django_bulk_hooks.engine import run as run_engine
130
+ run_engine(model, event, new_records or [], old_records or None, ctx=kwargs.get("ctx"))
177
131
 
178
132
 
179
133
  # Create a global Hook instance for context access
@@ -56,10 +56,11 @@ class HookModelMixin(models.Model):
56
56
  run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
57
57
 
58
58
  def save(self, *args, bypass_hooks=False, **kwargs):
59
- # If bypass_hooks is True, use base manager to avoid triggering hooks
59
+ # If bypass_hooks is True, call the parent save directly within a bypass context
60
60
  if bypass_hooks:
61
61
  logger.debug(f"save() called with bypass_hooks=True for {self.__class__.__name__} pk={self.pk}")
62
- return self._base_manager.save(self, *args, **kwargs)
62
+ with HookContext(self.__class__, bypass_hooks=True):
63
+ return super().save(*args, **kwargs)
63
64
 
64
65
  # Only create a new transaction if we're not already in one
65
66
  # This allows for proper nested transaction handling
@@ -118,9 +119,10 @@ class HookModelMixin(models.Model):
118
119
  return self
119
120
 
120
121
  def delete(self, *args, bypass_hooks=False, **kwargs):
121
- # If bypass_hooks is True, use base manager to avoid triggering hooks
122
+ # If bypass_hooks is True, call the parent delete directly within a bypass context
122
123
  if bypass_hooks:
123
- return self._base_manager.delete(self, *args, **kwargs)
124
+ with HookContext(self.__class__, bypass_hooks=True):
125
+ return super().delete(*args, **kwargs)
124
126
 
125
127
  # Only create a new transaction if we're not already in one
126
128
  # This allows for proper nested transaction handling
@@ -1,7 +1,8 @@
1
1
  import logging
2
2
  import threading
3
3
  from collections.abc import Callable
4
- from typing import Dict, List, Optional, Tuple, Union
4
+ from contextlib import contextmanager
5
+ from typing import Dict, Iterable, Iterator, List, Optional, Tuple, Union
5
6
 
6
7
  from django_bulk_hooks.priority import Priority
7
8
 
@@ -10,6 +11,7 @@ logger = logging.getLogger(__name__)
10
11
  # Key: (ModelClass, event)
11
12
  # Value: list of tuples (handler_cls, method_name, condition_callable, priority)
12
13
  _hooks: Dict[Tuple[type, str], List[Tuple[type, str, Callable, int]]] = {}
14
+ _registration_counter = 0 # secondary stable ordering for tie-breaks
13
15
 
14
16
  # Registry lock for thread-safety during registration and clearing
15
17
  _lock = threading.RLock()
@@ -42,6 +44,8 @@ def register_hook(
42
44
  event = str(event)
43
45
 
44
46
  with _lock:
47
+ global _registration_counter
48
+ _registration_counter += 1
45
49
  key = (model, event)
46
50
  hooks = _hooks.setdefault(key, [])
47
51
 
@@ -57,10 +61,11 @@ def register_hook(
57
61
  )
58
62
  return
59
63
 
60
- # Add the hook
64
+ # Add the hook (append preserves registration order)
61
65
  hooks.append((handler_cls, method_name, condition, priority))
62
66
 
63
67
  # Sort by priority (highest numbers execute first)
68
+ # Stable sort by priority (desc) and then by original registration order (stable append)
64
69
  def sort_key(hook_info: Tuple[type, str, Callable, int]) -> int:
65
70
  p = hook_info[3]
66
71
  return p.value if hasattr(p, "value") else int(p)
@@ -134,3 +139,63 @@ def unregister_hook(model: type, event: str, handler_cls: type, method_name: str
134
139
  ]
135
140
  if not _hooks[key]:
136
141
  del _hooks[key]
142
+
143
+
144
+ @contextmanager
145
+ def isolated_registry() -> Iterator[None]:
146
+ """
147
+ Context manager that snapshots the hook registry and restores it on exit.
148
+
149
+ Useful for tests to avoid global cross-test interference without relying on
150
+ private state mutation from the outside.
151
+ """
152
+ with _lock:
153
+ snapshot = {k: list(v) for k, v in _hooks.items()}
154
+ try:
155
+ yield
156
+ finally:
157
+ with _lock:
158
+ _hooks.clear()
159
+ _hooks.update({k: list(v) for k, v in snapshot.items()})
160
+
161
+
162
+ @contextmanager
163
+ def temporary_hook(
164
+ model: type,
165
+ event: str,
166
+ handler_cls: type,
167
+ method_name: str,
168
+ condition: Optional[Callable] = None,
169
+ priority: Union[int, Priority] = Priority.NORMAL,
170
+ ) -> Iterator[None]:
171
+ """
172
+ Temporarily register a single hook for the duration of the context.
173
+
174
+ Ensures the hook is unregistered even if an exception occurs.
175
+ """
176
+ register_hook(model, event, handler_cls, method_name, condition, priority)
177
+ try:
178
+ yield
179
+ finally:
180
+ unregister_hook(model, event, handler_cls, method_name)
181
+
182
+
183
+ @contextmanager
184
+ def temporary_hooks(
185
+ registrations: Iterable[Tuple[type, str, type, str, Optional[Callable], Union[int, Priority]]]
186
+ ) -> Iterator[None]:
187
+ """
188
+ Temporarily register multiple hooks for the duration of the context.
189
+
190
+ Args:
191
+ registrations: Iterable of (model, event, handler_cls, method_name, condition, priority)
192
+ """
193
+ # Register all
194
+ for model, event, handler_cls, method_name, condition, priority in registrations:
195
+ register_hook(model, event, handler_cls, method_name, condition, priority)
196
+ try:
197
+ yield
198
+ finally:
199
+ # Best-effort unregister all in reverse order
200
+ for model, event, handler_cls, method_name, _, _ in reversed(list(registrations)):
201
+ unregister_hook(model, event, handler_cls, method_name)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.1.230"
3
+ version = "0.1.231"
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"
@@ -1,4 +0,0 @@
1
- from django_bulk_hooks.handler import Hook
2
- from django_bulk_hooks.manager import BulkHookManager
3
-
4
- __all__ = ["BulkHookManager", "Hook"]