django-bulk-hooks 0.1.204__tar.gz → 0.1.205__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.204 → django_bulk_hooks-0.1.205}/PKG-INFO +1 -1
- {django_bulk_hooks-0.1.204 → django_bulk_hooks-0.1.205}/django_bulk_hooks/decorators.py +43 -52
- django_bulk_hooks-0.1.205/django_bulk_hooks/engine.py +56 -0
- {django_bulk_hooks-0.1.204 → django_bulk_hooks-0.1.205}/django_bulk_hooks/handler.py +12 -53
- {django_bulk_hooks-0.1.204 → django_bulk_hooks-0.1.205}/django_bulk_hooks/queryset.py +714 -803
- {django_bulk_hooks-0.1.204 → django_bulk_hooks-0.1.205}/pyproject.toml +1 -1
- django_bulk_hooks-0.1.204/django_bulk_hooks/engine.py +0 -127
- {django_bulk_hooks-0.1.204 → django_bulk_hooks-0.1.205}/LICENSE +0 -0
- {django_bulk_hooks-0.1.204 → django_bulk_hooks-0.1.205}/README.md +0 -0
- {django_bulk_hooks-0.1.204 → django_bulk_hooks-0.1.205}/django_bulk_hooks/__init__.py +0 -0
- {django_bulk_hooks-0.1.204 → django_bulk_hooks-0.1.205}/django_bulk_hooks/conditions.py +0 -0
- {django_bulk_hooks-0.1.204 → django_bulk_hooks-0.1.205}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.1.204 → django_bulk_hooks-0.1.205}/django_bulk_hooks/context.py +0 -0
- {django_bulk_hooks-0.1.204 → django_bulk_hooks-0.1.205}/django_bulk_hooks/enums.py +0 -0
- {django_bulk_hooks-0.1.204 → django_bulk_hooks-0.1.205}/django_bulk_hooks/manager.py +0 -0
- {django_bulk_hooks-0.1.204 → django_bulk_hooks-0.1.205}/django_bulk_hooks/models.py +0 -0
- {django_bulk_hooks-0.1.204 → django_bulk_hooks-0.1.205}/django_bulk_hooks/priority.py +0 -0
- {django_bulk_hooks-0.1.204 → django_bulk_hooks-0.1.205}/django_bulk_hooks/registry.py +0 -0
|
@@ -32,82 +32,73 @@ def select_related(*related_fields):
|
|
|
32
32
|
|
|
33
33
|
def decorator(func):
|
|
34
34
|
sig = inspect.signature(func)
|
|
35
|
-
# Precompute the positional index of 'new_records' to avoid per-call binding
|
|
36
|
-
param_names = list(sig.parameters.keys())
|
|
37
|
-
new_records_pos = param_names.index("new_records") if "new_records" in param_names else None
|
|
38
|
-
# Fail fast on nested fields (not supported)
|
|
39
|
-
for f in related_fields:
|
|
40
|
-
if "." in f:
|
|
41
|
-
raise ValueError(f"@select_related does not support nested fields like '{f}'")
|
|
42
35
|
|
|
43
36
|
@wraps(func)
|
|
44
37
|
def wrapper(*args, **kwargs):
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
raise TypeError("@select_related requires a 'new_records' argument in the decorated function")
|
|
55
|
-
new_records = bound.arguments["new_records"]
|
|
38
|
+
bound = sig.bind_partial(*args, **kwargs)
|
|
39
|
+
bound.apply_defaults()
|
|
40
|
+
|
|
41
|
+
if "new_records" not in bound.arguments:
|
|
42
|
+
raise TypeError(
|
|
43
|
+
"@preload_related requires a 'new_records' argument in the decorated function"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
new_records = bound.arguments["new_records"]
|
|
56
47
|
|
|
57
48
|
if not isinstance(new_records, list):
|
|
58
|
-
raise TypeError(
|
|
49
|
+
raise TypeError(
|
|
50
|
+
f"@preload_related expects a list of model instances, got {type(new_records)}"
|
|
51
|
+
)
|
|
59
52
|
|
|
60
53
|
if not new_records:
|
|
61
54
|
return func(*args, **kwargs)
|
|
62
55
|
|
|
63
56
|
# Determine which instances actually need preloading
|
|
64
57
|
model_cls = new_records[0].__class__
|
|
65
|
-
|
|
66
|
-
# Validate fields once per model class for this call
|
|
67
|
-
valid_fields = []
|
|
68
|
-
for field in related_fields:
|
|
69
|
-
try:
|
|
70
|
-
f = model_cls._meta.get_field(field)
|
|
71
|
-
if f.is_relation and not f.many_to_many and not f.one_to_many:
|
|
72
|
-
valid_fields.append(field)
|
|
73
|
-
except FieldDoesNotExist:
|
|
74
|
-
continue
|
|
75
|
-
|
|
76
|
-
if not valid_fields:
|
|
77
|
-
return func(*args, **kwargs)
|
|
78
|
-
|
|
79
58
|
ids_to_fetch = []
|
|
80
59
|
for obj in new_records:
|
|
81
60
|
if obj.pk is None:
|
|
82
61
|
continue
|
|
83
|
-
#
|
|
84
|
-
|
|
62
|
+
# if any related field is not already cached on the instance,
|
|
63
|
+
# mark it for fetching
|
|
64
|
+
if any(field not in obj._state.fields_cache for field in related_fields):
|
|
85
65
|
ids_to_fetch.append(obj.pk)
|
|
86
66
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
seen = set()
|
|
92
|
-
ids_to_fetch = [i for i in ids_to_fetch if not (i in seen or seen.add(i))]
|
|
93
|
-
|
|
94
|
-
# Use the base manager to avoid recursion and preload in one query
|
|
95
|
-
fetched = model_cls._base_manager.select_related(*valid_fields).in_bulk(ids_to_fetch)
|
|
67
|
+
fetched = {}
|
|
68
|
+
if ids_to_fetch:
|
|
69
|
+
# Use the base manager to avoid recursion
|
|
70
|
+
fetched = model_cls._base_manager.select_related(*related_fields).in_bulk(ids_to_fetch)
|
|
96
71
|
|
|
97
72
|
for obj in new_records:
|
|
98
|
-
|
|
73
|
+
preloaded = fetched.get(obj.pk)
|
|
74
|
+
if not preloaded:
|
|
99
75
|
continue
|
|
100
|
-
|
|
101
|
-
for field in valid_fields:
|
|
76
|
+
for field in related_fields:
|
|
102
77
|
if field in obj._state.fields_cache:
|
|
78
|
+
# don't override values that were explicitly set or already loaded
|
|
103
79
|
continue
|
|
104
|
-
|
|
105
|
-
|
|
80
|
+
if "." in field:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"@preload_related does not support nested fields like '{field}'"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
f = model_cls._meta.get_field(field)
|
|
87
|
+
if not (
|
|
88
|
+
f.is_relation and not f.many_to_many and not f.one_to_many
|
|
89
|
+
):
|
|
90
|
+
continue
|
|
91
|
+
except FieldDoesNotExist:
|
|
106
92
|
continue
|
|
107
|
-
setattr(obj, field, rel_obj)
|
|
108
|
-
obj._state.fields_cache[field] = rel_obj
|
|
109
93
|
|
|
110
|
-
|
|
94
|
+
try:
|
|
95
|
+
rel_obj = getattr(preloaded, field)
|
|
96
|
+
setattr(obj, field, rel_obj)
|
|
97
|
+
obj._state.fields_cache[field] = rel_obj
|
|
98
|
+
except AttributeError:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
return func(*bound.args, **bound.kwargs)
|
|
111
102
|
|
|
112
103
|
return wrapper
|
|
113
104
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from django.core.exceptions import ValidationError
|
|
4
|
+
|
|
5
|
+
from django_bulk_hooks.registry import get_hooks
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
11
|
+
"""
|
|
12
|
+
Run hooks for a given model, event, and records.
|
|
13
|
+
"""
|
|
14
|
+
if not new_records:
|
|
15
|
+
return
|
|
16
|
+
|
|
17
|
+
# Get hooks for this model and event
|
|
18
|
+
hooks = get_hooks(model_cls, event)
|
|
19
|
+
|
|
20
|
+
if not hooks:
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
# For BEFORE_* events, run model.clean() first for validation
|
|
24
|
+
if event.startswith("before_"):
|
|
25
|
+
for instance in new_records:
|
|
26
|
+
try:
|
|
27
|
+
instance.clean()
|
|
28
|
+
except ValidationError as e:
|
|
29
|
+
logger.error("Validation failed for %s: %s", instance, e)
|
|
30
|
+
raise
|
|
31
|
+
|
|
32
|
+
# Process hooks
|
|
33
|
+
for handler_cls, method_name, condition, priority in hooks:
|
|
34
|
+
handler_instance = handler_cls()
|
|
35
|
+
func = getattr(handler_instance, method_name)
|
|
36
|
+
|
|
37
|
+
to_process_new = []
|
|
38
|
+
to_process_old = []
|
|
39
|
+
|
|
40
|
+
for new, original in zip(
|
|
41
|
+
new_records,
|
|
42
|
+
old_records or [None] * len(new_records),
|
|
43
|
+
strict=True,
|
|
44
|
+
):
|
|
45
|
+
if not condition or condition.check(new, original):
|
|
46
|
+
to_process_new.append(new)
|
|
47
|
+
to_process_old.append(original)
|
|
48
|
+
|
|
49
|
+
if to_process_new:
|
|
50
|
+
try:
|
|
51
|
+
func(
|
|
52
|
+
new_records=to_process_new,
|
|
53
|
+
old_records=to_process_old if any(to_process_old) else None,
|
|
54
|
+
)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
raise
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import threading
|
|
3
3
|
from collections import deque
|
|
4
|
-
from itertools import zip_longest
|
|
5
4
|
|
|
6
5
|
from django.db import transaction
|
|
7
6
|
|
|
@@ -32,13 +31,6 @@ def get_hook_queue():
|
|
|
32
31
|
return _hook_context.queue
|
|
33
32
|
|
|
34
33
|
|
|
35
|
-
def get_handler_cache():
|
|
36
|
-
"""Thread-local cache for handler instances, scoped per outermost run."""
|
|
37
|
-
if not hasattr(_hook_context, "handler_cache"):
|
|
38
|
-
_hook_context.handler_cache = {}
|
|
39
|
-
return _hook_context.handler_cache
|
|
40
|
-
|
|
41
|
-
|
|
42
34
|
class HookContextState:
|
|
43
35
|
@property
|
|
44
36
|
def is_before(self):
|
|
@@ -112,8 +104,6 @@ class Hook(metaclass=HookMeta):
|
|
|
112
104
|
return # nested call, will be processed by outermost
|
|
113
105
|
|
|
114
106
|
# only outermost handle will process the queue
|
|
115
|
-
# initialize a fresh handler cache for this run
|
|
116
|
-
_hook_context.handler_cache = {}
|
|
117
107
|
while queue:
|
|
118
108
|
cls_, event_, model_, new_, old_, kw_ = queue.popleft()
|
|
119
109
|
cls_._process(event_, model_, new_, old_, **kw_)
|
|
@@ -133,56 +123,29 @@ class Hook(metaclass=HookMeta):
|
|
|
133
123
|
hook_vars.event = event
|
|
134
124
|
hook_vars.model = model
|
|
135
125
|
|
|
136
|
-
|
|
137
|
-
hooks = get_hooks(model, event)
|
|
126
|
+
hooks = sorted(get_hooks(model, event), key=lambda x: x[3])
|
|
138
127
|
|
|
139
128
|
def _execute():
|
|
140
129
|
new_local = new_records or []
|
|
141
130
|
old_local = old_records or []
|
|
142
|
-
|
|
131
|
+
if len(old_local) < len(new_local):
|
|
132
|
+
old_local += [None] * (len(new_local) - len(old_local))
|
|
143
133
|
|
|
144
134
|
for handler_cls, method_name, condition, priority in hooks:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
cache[handler_cls] = handler
|
|
151
|
-
method = getattr(handler, method_name)
|
|
152
|
-
try:
|
|
153
|
-
method(
|
|
154
|
-
new_records=new_local,
|
|
155
|
-
old_records=old_local,
|
|
156
|
-
**kwargs,
|
|
157
|
-
)
|
|
158
|
-
except Exception:
|
|
159
|
-
logger.exception(
|
|
160
|
-
"Error in hook %s.%s", handler_cls.__name__, method_name
|
|
161
|
-
)
|
|
162
|
-
continue
|
|
163
|
-
|
|
164
|
-
# Filter matching records without allocating full boolean list
|
|
165
|
-
to_process_new = []
|
|
166
|
-
to_process_old = []
|
|
167
|
-
for n, o in zip_longest(new_local, old_local, fillvalue=None):
|
|
168
|
-
if n is None:
|
|
135
|
+
if condition is not None:
|
|
136
|
+
checks = [
|
|
137
|
+
condition.check(n, o) for n, o in zip(new_local, old_local)
|
|
138
|
+
]
|
|
139
|
+
if not any(checks):
|
|
169
140
|
continue
|
|
170
|
-
if condition.check(n, o):
|
|
171
|
-
to_process_new.append(n)
|
|
172
|
-
to_process_old.append(o)
|
|
173
|
-
|
|
174
|
-
if not to_process_new:
|
|
175
|
-
continue
|
|
176
141
|
|
|
177
|
-
handler =
|
|
178
|
-
if handler is None:
|
|
179
|
-
handler = handler_cls()
|
|
180
|
-
cache[handler_cls] = handler
|
|
142
|
+
handler = handler_cls()
|
|
181
143
|
method = getattr(handler, method_name)
|
|
144
|
+
|
|
182
145
|
try:
|
|
183
146
|
method(
|
|
184
|
-
new_records=
|
|
185
|
-
old_records=
|
|
147
|
+
new_records=new_local,
|
|
148
|
+
old_records=old_local,
|
|
186
149
|
**kwargs,
|
|
187
150
|
)
|
|
188
151
|
except Exception:
|
|
@@ -202,7 +165,3 @@ class Hook(metaclass=HookMeta):
|
|
|
202
165
|
hook_vars.event = None
|
|
203
166
|
hook_vars.model = None
|
|
204
167
|
hook_vars.depth -= 1
|
|
205
|
-
# Clear cache only when queue is empty (outermost completion)
|
|
206
|
-
if not get_hook_queue():
|
|
207
|
-
if hasattr(_hook_context, "handler_cache"):
|
|
208
|
-
_hook_context.handler_cache.clear()
|