django-bulk-hooks 0.1.229__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.
- {django_bulk_hooks-0.1.229 → django_bulk_hooks-0.1.231}/PKG-INFO +1 -1
- django_bulk_hooks-0.1.231/django_bulk_hooks/__init__.py +15 -0
- {django_bulk_hooks-0.1.229 → django_bulk_hooks-0.1.231}/django_bulk_hooks/decorators.py +11 -0
- {django_bulk_hooks-0.1.229 → django_bulk_hooks-0.1.231}/django_bulk_hooks/engine.py +53 -24
- {django_bulk_hooks-0.1.229 → django_bulk_hooks-0.1.231}/django_bulk_hooks/handler.py +11 -57
- {django_bulk_hooks-0.1.229 → django_bulk_hooks-0.1.231}/django_bulk_hooks/models.py +6 -4
- {django_bulk_hooks-0.1.229 → django_bulk_hooks-0.1.231}/django_bulk_hooks/registry.py +67 -2
- {django_bulk_hooks-0.1.229 → django_bulk_hooks-0.1.231}/pyproject.toml +1 -1
- django_bulk_hooks-0.1.229/django_bulk_hooks/__init__.py +0 -4
- {django_bulk_hooks-0.1.229 → django_bulk_hooks-0.1.231}/LICENSE +0 -0
- {django_bulk_hooks-0.1.229 → django_bulk_hooks-0.1.231}/README.md +0 -0
- {django_bulk_hooks-0.1.229 → django_bulk_hooks-0.1.231}/django_bulk_hooks/conditions.py +0 -0
- {django_bulk_hooks-0.1.229 → django_bulk_hooks-0.1.231}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.1.229 → django_bulk_hooks-0.1.231}/django_bulk_hooks/context.py +0 -0
- {django_bulk_hooks-0.1.229 → django_bulk_hooks-0.1.231}/django_bulk_hooks/enums.py +0 -0
- {django_bulk_hooks-0.1.229 → django_bulk_hooks-0.1.231}/django_bulk_hooks/manager.py +0 -0
- {django_bulk_hooks-0.1.229 → django_bulk_hooks-0.1.231}/django_bulk_hooks/priority.py +0 -0
- {django_bulk_hooks-0.1.229 → django_bulk_hooks-0.1.231}/django_bulk_hooks/queryset.py +0 -0
|
@@ -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):
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
|
|
3
|
-
from django.conf import settings as django_settings
|
|
4
3
|
from django.core.exceptions import ValidationError
|
|
5
4
|
from django.db import transaction
|
|
5
|
+
from django.conf import settings
|
|
6
6
|
|
|
7
7
|
from django_bulk_hooks.registry import get_hooks
|
|
8
8
|
from django_bulk_hooks.handler import hook_vars
|
|
@@ -10,6 +10,18 @@ from django_bulk_hooks.handler import hook_vars
|
|
|
10
10
|
logger = logging.getLogger(__name__)
|
|
11
11
|
|
|
12
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
|
+
|
|
13
25
|
def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
14
26
|
"""
|
|
15
27
|
Run hooks for a given model, event, and records.
|
|
@@ -55,16 +67,35 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
55
67
|
|
|
56
68
|
def _execute():
|
|
57
69
|
hook_vars.depth += 1
|
|
58
|
-
hook_vars.new = new_records
|
|
59
|
-
hook_vars.old = old_records
|
|
60
70
|
hook_vars.event = event
|
|
61
|
-
hook_vars.model = model_cls
|
|
62
71
|
|
|
63
72
|
try:
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 = []
|
|
68
99
|
|
|
69
100
|
for handler_cls, method_name, condition, priority in hooks:
|
|
70
101
|
try:
|
|
@@ -79,10 +110,10 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
79
110
|
)
|
|
80
111
|
continue
|
|
81
112
|
|
|
82
|
-
# Condition filtering per record
|
|
113
|
+
# Condition filtering per record using the deterministic pairs
|
|
83
114
|
to_process_new = []
|
|
84
115
|
to_process_old = []
|
|
85
|
-
for new_obj, old_obj in
|
|
116
|
+
for new_obj, old_obj in pairs:
|
|
86
117
|
if not condition:
|
|
87
118
|
to_process_new.append(new_obj)
|
|
88
119
|
to_process_old.append(old_obj)
|
|
@@ -106,26 +137,24 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
106
137
|
try:
|
|
107
138
|
func(
|
|
108
139
|
new_records=to_process_new,
|
|
109
|
-
old_records=to_process_old
|
|
110
|
-
if any(x is not None for x in to_process_old)
|
|
111
|
-
else None,
|
|
140
|
+
old_records=to_process_old if any(x is not None for x in to_process_old) else None,
|
|
112
141
|
)
|
|
113
|
-
except Exception:
|
|
142
|
+
except Exception as e:
|
|
114
143
|
logger.exception(
|
|
115
144
|
"Error in hook %s.%s", handler_cls.__name__, method_name
|
|
116
145
|
)
|
|
117
|
-
|
|
146
|
+
if failure_policy == "best_effort":
|
|
147
|
+
collected_errors.append((f"{handler_cls.__name__}.{method_name}", e))
|
|
148
|
+
continue
|
|
149
|
+
# fail_fast
|
|
118
150
|
raise
|
|
151
|
+
|
|
152
|
+
if collected_errors:
|
|
153
|
+
raise AggregatedHookError(collected_errors)
|
|
119
154
|
finally:
|
|
120
|
-
hook_vars.new = None
|
|
121
|
-
hook_vars.old = None
|
|
122
155
|
hook_vars.event = None
|
|
123
|
-
hook_vars.model = None
|
|
124
156
|
hook_vars.depth -= 1
|
|
125
157
|
|
|
126
|
-
# AFTER_*
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
transaction.on_commit(_execute)
|
|
130
|
-
else:
|
|
131
|
-
_execute()
|
|
158
|
+
# Execute immediately so AFTER_* runs within the transaction.
|
|
159
|
+
# If a hook raises, the transaction is rolled back (Salesforce-style).
|
|
160
|
+
_execute()
|
|
@@ -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
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
122
|
+
# If bypass_hooks is True, call the parent delete directly within a bypass context
|
|
122
123
|
if bypass_hooks:
|
|
123
|
-
|
|
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
|
|
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.
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|