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

@@ -32,73 +32,82 @@ 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}'")
35
42
 
36
43
  @wraps(func)
37
44
  def wrapper(*args, **kwargs):
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"]
45
+ # Fast retrieval of new_records without full signature binding
46
+ new_records = kwargs.get("new_records")
47
+ if new_records is None and new_records_pos is not None and len(args) > new_records_pos:
48
+ new_records = args[new_records_pos]
49
+ if new_records is None:
50
+ # Fallback for uncommon signatures
51
+ bound = sig.bind_partial(*args, **kwargs)
52
+ bound.apply_defaults()
53
+ if "new_records" not in bound.arguments:
54
+ raise TypeError("@select_related requires a 'new_records' argument in the decorated function")
55
+ new_records = bound.arguments["new_records"]
47
56
 
48
57
  if not isinstance(new_records, list):
49
- raise TypeError(
50
- f"@preload_related expects a list of model instances, got {type(new_records)}"
51
- )
58
+ raise TypeError(f"@select_related expects a list of model instances, got {type(new_records)}")
52
59
 
53
60
  if not new_records:
54
61
  return func(*args, **kwargs)
55
62
 
56
63
  # Determine which instances actually need preloading
57
64
  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
+
58
79
  ids_to_fetch = []
59
80
  for obj in new_records:
60
81
  if obj.pk is None:
61
82
  continue
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):
83
+ # If any valid related field is not cached, fetch this object
84
+ if any(field not in obj._state.fields_cache for field in valid_fields):
65
85
  ids_to_fetch.append(obj.pk)
66
86
 
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)
87
+ if not ids_to_fetch:
88
+ return func(*args, **kwargs)
89
+
90
+ # Deduplicate while preserving order
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)
71
96
 
72
97
  for obj in new_records:
73
- preloaded = fetched.get(obj.pk)
74
- if not preloaded:
98
+ if obj.pk not in fetched:
75
99
  continue
76
- for field in related_fields:
100
+ preloaded = fetched[obj.pk]
101
+ for field in valid_fields:
77
102
  if field in obj._state.fields_cache:
78
- # don't override values that were explicitly set or already loaded
79
103
  continue
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:
104
+ rel_obj = getattr(preloaded, field, None)
105
+ if rel_obj is None:
92
106
  continue
107
+ setattr(obj, field, rel_obj)
108
+ obj._state.fields_cache[field] = rel_obj
93
109
 
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)
110
+ return func(*args, **kwargs)
102
111
 
103
112
  return wrapper
104
113
 
@@ -1,6 +1,7 @@
1
1
  import logging
2
2
  import os
3
3
  import time
4
+ from itertools import repeat
4
5
 
5
6
  from django.core.exceptions import ValidationError
6
7
 
@@ -12,11 +13,15 @@ logger = logging.getLogger(__name__)
12
13
  _PROFILE_ENABLED = bool(
13
14
  int(os.getenv("DJANGO_BULK_HOOKS_PROFILE", os.getenv("BULK_HOOKS_PROFILE", "0")))
14
15
  )
16
+ _PROFILE_MIN_MS = float(os.getenv("DJANGO_BULK_HOOKS_PROFILE_MIN_MS", "0"))
15
17
 
16
18
 
17
- def _log_profile(message: str) -> None:
18
- if _PROFILE_ENABLED:
19
- print(f"[bulk_hooks.profile] {message}", flush=True)
19
+ def _log_profile(message: str, duration_ms: float | None = None) -> None:
20
+ if not _PROFILE_ENABLED:
21
+ return
22
+ if duration_ms is not None and duration_ms < _PROFILE_MIN_MS:
23
+ return
24
+ print(f"[bulk_hooks.profile] {message}", flush=True)
20
25
 
21
26
 
22
27
  def run(model_cls, event, new_records, old_records=None, ctx=None):
@@ -30,15 +35,17 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
30
35
  t0 = time.perf_counter() if _PROFILE_ENABLED else None
31
36
  hooks = get_hooks(model_cls, event)
32
37
  if _PROFILE_ENABLED:
38
+ dt = (time.perf_counter() - t0) * 1000 if t0 is not None else 0.0
33
39
  _log_profile(
34
- f"engine.get_hooks model={model_cls.__name__} event={event} took {(time.perf_counter()-t0)*1000:.2f}ms"
40
+ f"engine.get_hooks model={model_cls.__name__} event={event} took {dt:.2f}ms",
41
+ dt,
35
42
  )
36
43
 
37
44
  if not hooks:
38
45
  return
39
46
 
40
47
  # For BEFORE_* events, run model.clean() first for validation
41
- if event.startswith("before_"):
48
+ if event.startswith("before_") and not getattr(ctx, "skip_model_clean", False):
42
49
  t_clean = time.perf_counter() if _PROFILE_ENABLED else None
43
50
  for instance in new_records:
44
51
  try:
@@ -47,8 +54,10 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
47
54
  logger.error("Validation failed for %s: %s", instance, e)
48
55
  raise
49
56
  if _PROFILE_ENABLED:
57
+ dt = (time.perf_counter() - t_clean) * 1000 if t_clean is not None else 0.0
50
58
  _log_profile(
51
- f"engine.model_clean model={model_cls.__name__} event={event} n={len(new_records)} took {(time.perf_counter()-t_clean)*1000:.2f}ms"
59
+ f"engine.model_clean model={model_cls.__name__} event={event} n={len(new_records)} took {dt:.2f}ms",
60
+ dt,
52
61
  )
53
62
 
54
63
  # Process hooks
@@ -57,21 +66,42 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
57
66
  handler_instance = handler_cls()
58
67
  func = getattr(handler_instance, method_name)
59
68
 
69
+ # Fast path: if no condition, pass-through all records
70
+ if not condition:
71
+ try:
72
+ t_handler = time.perf_counter() if _PROFILE_ENABLED else None
73
+ func(
74
+ new_records=new_records,
75
+ old_records=old_records if old_records and any(old_records) else None,
76
+ )
77
+ if _PROFILE_ENABLED:
78
+ dt = (time.perf_counter() - t_handler) * 1000 if t_handler is not None else 0.0
79
+ _log_profile(
80
+ f"engine.handler handler={handler_cls.__name__}.{method_name} event={event} n={len(new_records)} took {dt:.2f}ms",
81
+ dt,
82
+ )
83
+ except Exception:
84
+ raise
85
+ continue
86
+
87
+ # Conditional path: select matching records
60
88
  to_process_new = []
61
89
  to_process_old = []
62
90
 
63
91
  t_select = time.perf_counter() if _PROFILE_ENABLED else None
64
92
  for new, original in zip(
65
93
  new_records,
66
- old_records or [None] * len(new_records),
94
+ old_records if old_records is not None else repeat(None),
67
95
  strict=True,
68
96
  ):
69
- if not condition or condition.check(new, original):
97
+ if condition.check(new, original):
70
98
  to_process_new.append(new)
71
99
  to_process_old.append(original)
72
100
  if _PROFILE_ENABLED:
101
+ dt = (time.perf_counter() - t_select) * 1000 if t_select is not None else 0.0
73
102
  _log_profile(
74
- f"engine.select_records handler={handler_cls.__name__}.{method_name} event={event} n={len(new_records)} selected={len(to_process_new)} took {(time.perf_counter()-t_select)*1000:.2f}ms"
103
+ f"engine.select_records handler={handler_cls.__name__}.{method_name} event={event} n={len(new_records)} selected={len(to_process_new)} took {dt:.2f}ms",
104
+ dt,
75
105
  )
76
106
 
77
107
  if to_process_new:
@@ -82,13 +112,17 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
82
112
  old_records=to_process_old if any(to_process_old) else None,
83
113
  )
84
114
  if _PROFILE_ENABLED:
115
+ dt = (time.perf_counter() - t_handler) * 1000 if t_handler is not None else 0.0
85
116
  _log_profile(
86
- f"engine.handler handler={handler_cls.__name__}.{method_name} event={event} n={len(to_process_new)} took {(time.perf_counter()-t_handler)*1000:.2f}ms"
117
+ f"engine.handler handler={handler_cls.__name__}.{method_name} event={event} n={len(to_process_new)} took {dt:.2f}ms",
118
+ dt,
87
119
  )
88
- except Exception as e:
120
+ except Exception:
89
121
  raise
90
122
 
91
123
  if _PROFILE_ENABLED:
124
+ dt = (time.perf_counter() - t_hooks_total) * 1000 if t_hooks_total is not None else 0.0
92
125
  _log_profile(
93
- f"engine.run model={model_cls.__name__} event={event} n={len(new_records)} took {(time.perf_counter()-t_hooks_total)*1000:.2f}ms (handlers only)"
126
+ f"engine.run model={model_cls.__name__} event={event} n={len(new_records)} took {dt:.2f}ms (handlers only)",
127
+ dt,
94
128
  )
@@ -1,6 +1,7 @@
1
1
  import logging
2
2
  import threading
3
3
  from collections import deque
4
+ from itertools import zip_longest
4
5
 
5
6
  from django.db import transaction
6
7
 
@@ -31,6 +32,13 @@ def get_hook_queue():
31
32
  return _hook_context.queue
32
33
 
33
34
 
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
+
34
42
  class HookContextState:
35
43
  @property
36
44
  def is_before(self):
@@ -104,6 +112,8 @@ class Hook(metaclass=HookMeta):
104
112
  return # nested call, will be processed by outermost
105
113
 
106
114
  # only outermost handle will process the queue
115
+ # initialize a fresh handler cache for this run
116
+ _hook_context.handler_cache = {}
107
117
  while queue:
108
118
  cls_, event_, model_, new_, old_, kw_ = queue.popleft()
109
119
  cls_._process(event_, model_, new_, old_, **kw_)
@@ -123,29 +133,56 @@ class Hook(metaclass=HookMeta):
123
133
  hook_vars.event = event
124
134
  hook_vars.model = model
125
135
 
126
- hooks = sorted(get_hooks(model, event), key=lambda x: x[3])
136
+ # Hooks are already kept sorted by priority in the registry
137
+ hooks = get_hooks(model, event)
127
138
 
128
139
  def _execute():
129
140
  new_local = new_records or []
130
141
  old_local = old_records or []
131
- if len(old_local) < len(new_local):
132
- old_local += [None] * (len(new_local) - len(old_local))
142
+ cache = get_handler_cache()
133
143
 
134
144
  for handler_cls, method_name, condition, priority in hooks:
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):
145
+ # If there's no condition, pass through all records fast
146
+ if condition is None:
147
+ handler = cache.get(handler_cls)
148
+ if handler is None:
149
+ handler = handler_cls()
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:
140
169
  continue
170
+ if condition.check(n, o):
171
+ to_process_new.append(n)
172
+ to_process_old.append(o)
141
173
 
142
- handler = handler_cls()
143
- method = getattr(handler, method_name)
174
+ if not to_process_new:
175
+ continue
144
176
 
177
+ handler = cache.get(handler_cls)
178
+ if handler is None:
179
+ handler = handler_cls()
180
+ cache[handler_cls] = handler
181
+ method = getattr(handler, method_name)
145
182
  try:
146
183
  method(
147
- new_records=new_local,
148
- old_records=old_local,
184
+ new_records=to_process_new,
185
+ old_records=to_process_old,
149
186
  **kwargs,
150
187
  )
151
188
  except Exception:
@@ -165,3 +202,7 @@ class Hook(metaclass=HookMeta):
165
202
  hook_vars.event = None
166
203
  hook_vars.model = None
167
204
  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()
@@ -30,6 +30,8 @@ class HookQuerySetMixin:
30
30
  _PROFILE_ENABLED = bool(
31
31
  int(os.getenv("DJANGO_BULK_HOOKS_PROFILE", os.getenv("BULK_HOOKS_PROFILE", "0")))
32
32
  )
33
+ _PROFILE_MIN_MS = float(os.getenv("DJANGO_BULK_HOOKS_PROFILE_MIN_MS", "0"))
34
+ _DEFAULT_BATCH_SIZE = int(os.getenv("DJANGO_BULK_HOOKS_DEFAULT_BATCH_SIZE", "0")) or None
33
35
 
34
36
  @classmethod
35
37
  def _profile_enabled(cls):
@@ -54,7 +56,8 @@ class HookQuerySetMixin:
54
56
  elapsed_ms = (time.perf_counter() - start) * 1000.0
55
57
  model_str = f" model={model_cls.__name__}" if model_cls is not None else ""
56
58
  extra_str = f" {extra}" if extra else ""
57
- cls._profile_log(f"{label}{model_str}{extra_str} took {elapsed_ms:.2f}ms")
59
+ if elapsed_ms >= cls._PROFILE_MIN_MS:
60
+ cls._profile_log(f"{label}{model_str}{extra_str} took {elapsed_ms:.2f}ms")
58
61
 
59
62
  @transaction.atomic
60
63
  def delete(self):
@@ -156,6 +159,10 @@ class HookQuerySetMixin:
156
159
  # PostgreSQL via the RETURNING ID clause. It should be possible for
157
160
  # Oracle as well, but the semantics for extracting the primary keys is
158
161
  # trickier so it's not done yet.
162
+ # Apply default batch size from env if not provided
163
+ if batch_size is None and self._DEFAULT_BATCH_SIZE:
164
+ batch_size = self._DEFAULT_BATCH_SIZE
165
+
159
166
  if batch_size is not None and batch_size <= 0:
160
167
  raise ValueError("Batch size must be a positive integer.")
161
168
 
@@ -186,6 +193,8 @@ class HookQuerySetMixin:
186
193
  if not bypass_validation:
187
194
  with self._profile_step("hooks.validate_create", model_cls, extra=f"n={len(objs)}"):
188
195
  engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
196
+ # Skip model.clean in BEFORE_* since we just validated above
197
+ setattr(ctx, "skip_model_clean", True)
189
198
  with self._profile_step("hooks.before_create", model_cls, extra=f"n={len(objs)}"):
190
199
  engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
191
200
 
@@ -270,6 +279,8 @@ class HookQuerySetMixin:
270
279
  if not bypass_validation:
271
280
  with self._profile_step("hooks.validate_update", model_cls, extra=f"n={len(objs)}"):
272
281
  engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
282
+ # Skip model.clean in BEFORE_* since we just validated above
283
+ setattr(ctx, "skip_model_clean", True)
273
284
 
274
285
  # Then run business logic hooks
275
286
  with self._profile_step("hooks.before_update", model_cls, extra=f"n={len(objs)}"):
@@ -308,6 +319,9 @@ class HookQuerySetMixin:
308
319
  for k, v in kwargs.items()
309
320
  if k not in ["bypass_hooks", "bypass_validation"]
310
321
  }
322
+ # Apply default batch size if not provided
323
+ if "batch_size" not in django_kwargs and self._DEFAULT_BATCH_SIZE:
324
+ django_kwargs["batch_size"] = self._DEFAULT_BATCH_SIZE
311
325
  with self._profile_step("bulk_update.django", model_cls, extra=f"n={len(objs)} fields={fields}"):
312
326
  result = super().bulk_update(objs, fields, **django_kwargs)
313
327
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.202
3
+ Version: 0.1.203
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
@@ -2,16 +2,16 @@ django_bulk_hooks/__init__.py,sha256=uUgpnb9AWjIAcWNpCMqBcOewSnpJjJYH6cjPbQkzoNU
2
2
  django_bulk_hooks/conditions.py,sha256=mTvlLcttixbXRkTSNZU5VewkPUavbXRuD2BkJbVWMkw,6041
3
3
  django_bulk_hooks/constants.py,sha256=3x1H1fSUUNo0DZONN7GUVDuySZctTR-jtByBHmAIX5w,303
4
4
  django_bulk_hooks/context.py,sha256=4IPuOX8TBAYBEMzN0RNHWgE6Giy2ZnR5uRXfd1cpIwk,1051
5
- django_bulk_hooks/decorators.py,sha256=WD7Jn7QAvY8F4wOsYlIpjoM9-FdHXSKB7hH9ot-lkYQ,4896
6
- django_bulk_hooks/engine.py,sha256=Pt3499yLSDdNpgebcjkOa1L0phiu-5KeEnpGxprl6Zg,3436
5
+ django_bulk_hooks/decorators.py,sha256=pMzl49-3b9AbCTFk-nvYwd954ER8UVFebo0a4zbC-jY,5633
6
+ django_bulk_hooks/engine.py,sha256=VNPJ0CDsjaIsV4mHID8Z3PG7CUVr_OYQvvC7D5pWt-4,4982
7
7
  django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
8
- django_bulk_hooks/handler.py,sha256=xZt8iNdYF-ACz-MnKMY0co6scWINU5V5wC1lyDn844k,4854
8
+ django_bulk_hooks/handler.py,sha256=0_VWdMUVTW7IrfiTeTsT2CST8yAVWROmc2Z-kCZ4Ibo,6663
9
9
  django_bulk_hooks/manager.py,sha256=nfWiwU5-yAoxdnQsUMohxtyCpkV0MBv6X3wmipr9eQY,3697
10
10
  django_bulk_hooks/models.py,sha256=7fnx5xd4HWXfLVlFhhiRzR92JRWFEuxgk6aSWLEsyJg,3996
11
11
  django_bulk_hooks/priority.py,sha256=HG_2D35nga68lBCZmSXTcplXrjFoRgZFRDOy4ROKonY,376
12
- django_bulk_hooks/queryset.py,sha256=NRbkJL9D_V97W76UxodMye1zaB0nJZGjMmv0SR0QLzs,36628
12
+ django_bulk_hooks/queryset.py,sha256=UmhU6GlbfD6u90caarj_kuZNvnYsKyEN1pgKNaqMisM,37512
13
13
  django_bulk_hooks/registry.py,sha256=-mQBizJ06nz_tajZBinViKx_uP2Tbc1tIpTEMv7lwKA,705
14
- django_bulk_hooks-0.1.202.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
15
- django_bulk_hooks-0.1.202.dist-info/METADATA,sha256=sJY6iID8owgHJErIN8ZUhXNjJ7Znl1F8HedZiroE8_0,7418
16
- django_bulk_hooks-0.1.202.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
17
- django_bulk_hooks-0.1.202.dist-info/RECORD,,
14
+ django_bulk_hooks-0.1.203.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
15
+ django_bulk_hooks-0.1.203.dist-info/METADATA,sha256=GNPtZ4-TGLfhrVTzCqQtbLUoSlQDLVpUcCB3k_UY3y0,7418
16
+ django_bulk_hooks-0.1.203.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
17
+ django_bulk_hooks-0.1.203.dist-info/RECORD,,