django-bulk-hooks 0.1.238__tar.gz → 0.1.239__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.238 → django_bulk_hooks-0.1.239}/PKG-INFO +3 -3
  2. django_bulk_hooks-0.1.239/django_bulk_hooks/__init__.py +4 -0
  3. {django_bulk_hooks-0.1.238 → django_bulk_hooks-0.1.239}/django_bulk_hooks/decorators.py +34 -10
  4. {django_bulk_hooks-0.1.238 → django_bulk_hooks-0.1.239}/django_bulk_hooks/handler.py +188 -167
  5. {django_bulk_hooks-0.1.238 → django_bulk_hooks-0.1.239}/django_bulk_hooks/queryset.py +63 -3
  6. {django_bulk_hooks-0.1.238 → django_bulk_hooks-0.1.239}/django_bulk_hooks/registry.py +8 -1
  7. {django_bulk_hooks-0.1.238 → django_bulk_hooks-0.1.239}/pyproject.toml +6 -1
  8. django_bulk_hooks-0.1.238/django_bulk_hooks/__init__.py +0 -4
  9. {django_bulk_hooks-0.1.238 → django_bulk_hooks-0.1.239}/LICENSE +0 -0
  10. {django_bulk_hooks-0.1.238 → django_bulk_hooks-0.1.239}/README.md +0 -0
  11. {django_bulk_hooks-0.1.238 → django_bulk_hooks-0.1.239}/django_bulk_hooks/conditions.py +0 -0
  12. {django_bulk_hooks-0.1.238 → django_bulk_hooks-0.1.239}/django_bulk_hooks/constants.py +0 -0
  13. {django_bulk_hooks-0.1.238 → django_bulk_hooks-0.1.239}/django_bulk_hooks/context.py +0 -0
  14. {django_bulk_hooks-0.1.238 → django_bulk_hooks-0.1.239}/django_bulk_hooks/engine.py +0 -0
  15. {django_bulk_hooks-0.1.238 → django_bulk_hooks-0.1.239}/django_bulk_hooks/enums.py +0 -0
  16. {django_bulk_hooks-0.1.238 → django_bulk_hooks-0.1.239}/django_bulk_hooks/manager.py +0 -0
  17. {django_bulk_hooks-0.1.238 → django_bulk_hooks-0.1.239}/django_bulk_hooks/models.py +0 -0
  18. {django_bulk_hooks-0.1.238 → django_bulk_hooks-0.1.239}/django_bulk_hooks/priority.py +0 -0
@@ -1,8 +1,7 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.238
3
+ Version: 0.1.239
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
- Home-page: https://github.com/AugendLimited/django-bulk-hooks
6
5
  License: MIT
7
6
  Keywords: django,bulk,hooks
8
7
  Author: Konrad Beck
@@ -14,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.11
14
13
  Classifier: Programming Language :: Python :: 3.12
15
14
  Classifier: Programming Language :: Python :: 3.13
16
15
  Requires-Dist: Django (>=4.0)
16
+ Project-URL: Homepage, https://github.com/AugendLimited/django-bulk-hooks
17
17
  Project-URL: Repository, https://github.com/AugendLimited/django-bulk-hooks
18
18
  Description-Content-Type: text/markdown
19
19
 
@@ -0,0 +1,4 @@
1
+ from django_bulk_hooks.handler import Hook as HookClass
2
+ from django_bulk_hooks.manager import BulkHookManager
3
+
4
+ __all__ = ["BulkHookManager", "HookClass"]
@@ -2,6 +2,7 @@ import inspect
2
2
  from functools import wraps
3
3
 
4
4
  from django.core.exceptions import FieldDoesNotExist
5
+
5
6
  from django_bulk_hooks.enums import DEFAULT_PRIORITY
6
7
  from django_bulk_hooks.registry import register_hook
7
8
 
@@ -61,13 +62,35 @@ def select_related(*related_fields):
61
62
  continue
62
63
  # if any related field is not already cached on the instance,
63
64
  # mark it for fetching
64
- if any(field not in obj._state.fields_cache for field in related_fields):
65
+ if any(
66
+ field not in obj._state.fields_cache for field in related_fields
67
+ ):
65
68
  ids_to_fetch.append(obj.pk)
66
69
 
67
70
  fetched = {}
68
71
  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)
72
+ # Validate fields before passing to select_related
73
+ validated_fields = []
74
+ for field in related_fields:
75
+ if "." in field:
76
+ raise ValueError(
77
+ f"@select_related does not support nested fields like '{field}'"
78
+ )
79
+ try:
80
+ f = model_cls._meta.get_field(field)
81
+ if not (
82
+ f.is_relation and not f.many_to_many and not f.one_to_many
83
+ ):
84
+ continue
85
+ validated_fields.append(field)
86
+ except FieldDoesNotExist:
87
+ continue
88
+
89
+ if validated_fields:
90
+ # Use the base manager to avoid recursion
91
+ fetched = model_cls._base_manager.select_related(
92
+ *validated_fields
93
+ ).in_bulk(ids_to_fetch)
71
94
 
72
95
  for obj in new_records:
73
96
  preloaded = fetched.get(obj.pk)
@@ -78,9 +101,8 @@ def select_related(*related_fields):
78
101
  # don't override values that were explicitly set or already loaded
79
102
  continue
80
103
  if "." in field:
81
- raise ValueError(
82
- f"@preload_related does not support nested fields like '{field}'"
83
- )
104
+ # This should have been caught earlier, but just in case
105
+ continue
84
106
 
85
107
  try:
86
108
  f = model_cls._meta.get_field(field)
@@ -108,30 +130,32 @@ def select_related(*related_fields):
108
130
  def bulk_hook(model_cls, event, when=None, priority=None):
109
131
  """
110
132
  Decorator to register a bulk hook for a model.
111
-
133
+
112
134
  Args:
113
135
  model_cls: The model class to hook into
114
136
  event: The event to hook into (e.g., BEFORE_UPDATE, AFTER_UPDATE)
115
137
  when: Optional condition for when the hook should run
116
138
  priority: Optional priority for hook execution order
117
139
  """
140
+
118
141
  def decorator(func):
119
142
  # Create a simple handler class for the function
120
143
  class FunctionHandler:
121
144
  def __init__(self):
122
145
  self.func = func
123
-
146
+
124
147
  def handle(self, new_instances, original_instances):
125
148
  return self.func(new_instances, original_instances)
126
-
149
+
127
150
  # Register the hook using the registry
128
151
  register_hook(
129
152
  model=model_cls,
130
153
  event=event,
131
154
  handler_cls=FunctionHandler,
132
- method_name='handle',
155
+ method_name="handle",
133
156
  condition=when,
134
157
  priority=priority or DEFAULT_PRIORITY,
135
158
  )
136
159
  return func
160
+
137
161
  return decorator
@@ -1,167 +1,188 @@
1
- import logging
2
- import threading
3
- from collections import deque
4
-
5
- from django.db import transaction
6
-
7
- from django_bulk_hooks.registry import get_hooks, register_hook
8
-
9
- logger = logging.getLogger(__name__)
10
-
11
-
12
- # Thread-local hook context and hook state
13
- class HookVars(threading.local):
14
- def __init__(self):
15
- self.new = None
16
- self.old = None
17
- self.event = None
18
- self.model = None
19
- self.depth = 0
20
-
21
-
22
- hook_vars = HookVars()
23
-
24
- # Hook queue per thread
25
- _hook_context = threading.local()
26
-
27
-
28
- def get_hook_queue():
29
- if not hasattr(_hook_context, "queue"):
30
- _hook_context.queue = deque()
31
- return _hook_context.queue
32
-
33
-
34
- class HookContextState:
35
- @property
36
- def is_before(self):
37
- return hook_vars.event.startswith("before_") if hook_vars.event else False
38
-
39
- @property
40
- def is_after(self):
41
- return hook_vars.event.startswith("after_") if hook_vars.event else False
42
-
43
- @property
44
- def is_create(self):
45
- return "create" in hook_vars.event if hook_vars.event else False
46
-
47
- @property
48
- def is_update(self):
49
- return "update" in hook_vars.event if hook_vars.event else False
50
-
51
- @property
52
- def new(self):
53
- return hook_vars.new
54
-
55
- @property
56
- def old(self):
57
- return hook_vars.old
58
-
59
- @property
60
- def model(self):
61
- return hook_vars.model
62
-
63
-
64
- Hook = HookContextState()
65
-
66
-
67
- class HookMeta(type):
68
- _registered = set()
69
-
70
- def __new__(mcs, name, bases, namespace):
71
- cls = super().__new__(mcs, name, bases, namespace)
72
- for method_name, method in namespace.items():
73
- if hasattr(method, "hooks_hooks"):
74
- for model_cls, event, condition, priority in method.hooks_hooks:
75
- key = (model_cls, event, cls, method_name)
76
- if key not in HookMeta._registered:
77
- register_hook(
78
- model=model_cls,
79
- event=event,
80
- handler_cls=cls,
81
- method_name=method_name,
82
- condition=condition,
83
- priority=priority,
84
- )
85
- HookMeta._registered.add(key)
86
- return cls
87
-
88
-
89
- class Hook(metaclass=HookMeta):
90
- @classmethod
91
- def handle(
92
- cls,
93
- event: str,
94
- model: type,
95
- *,
96
- new_records: list = None,
97
- old_records: list = None,
98
- **kwargs,
99
- ) -> None:
100
- queue = get_hook_queue()
101
- queue.append((cls, event, model, new_records, old_records, kwargs))
102
-
103
- if len(queue) > 1:
104
- return # nested call, will be processed by outermost
105
-
106
- # only outermost handle will process the queue
107
- while queue:
108
- cls_, event_, model_, new_, old_, kw_ = queue.popleft()
109
- cls_._process(event_, model_, new_, old_, **kw_)
110
-
111
- @classmethod
112
- def _process(
113
- cls,
114
- event,
115
- model,
116
- new_records,
117
- old_records,
118
- **kwargs,
119
- ):
120
- hook_vars.depth += 1
121
- hook_vars.new = new_records
122
- hook_vars.old = old_records
123
- hook_vars.event = event
124
- hook_vars.model = model
125
-
126
- hooks = sorted(get_hooks(model, event), key=lambda x: x[3])
127
-
128
- def _execute():
129
- new_local = new_records or []
130
- old_local = old_records or []
131
- if len(old_local) < len(new_local):
132
- old_local += [None] * (len(new_local) - len(old_local))
133
-
134
- 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):
140
- continue
141
-
142
- handler = handler_cls()
143
- method = getattr(handler, method_name)
144
-
145
- try:
146
- method(
147
- new_records=new_local,
148
- old_records=old_local,
149
- **kwargs,
150
- )
151
- except Exception:
152
- logger.exception(
153
- "Error in hook %s.%s", handler_cls.__name__, method_name
154
- )
155
-
156
- conn = transaction.get_connection()
157
- try:
158
- if conn.in_atomic_block and event.startswith("after_"):
159
- transaction.on_commit(_execute)
160
- else:
161
- _execute()
162
- finally:
163
- hook_vars.new = None
164
- hook_vars.old = None
165
- hook_vars.event = None
166
- hook_vars.model = None
167
- hook_vars.depth -= 1
1
+ import logging
2
+ import threading
3
+ from collections import deque
4
+
5
+ from django.db import transaction
6
+
7
+ from django_bulk_hooks.registry import get_hooks, register_hook
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ # Thread-local hook context and hook state
13
+ class HookVars(threading.local):
14
+ def __init__(self):
15
+ self.new = None
16
+ self.old = None
17
+ self.event = None
18
+ self.model = None
19
+ self.depth = 0
20
+
21
+
22
+ hook_vars = HookVars()
23
+
24
+ # Hook queue per thread
25
+ _hook_context = threading.local()
26
+
27
+
28
+ def get_hook_queue():
29
+ if not hasattr(_hook_context, "queue"):
30
+ _hook_context.queue = deque()
31
+ return _hook_context.queue
32
+
33
+
34
+ class HookContextState:
35
+ @property
36
+ def is_before(self):
37
+ return hook_vars.event.startswith("before_") if hook_vars.event else False
38
+
39
+ @property
40
+ def is_after(self):
41
+ return hook_vars.event.startswith("after_") if hook_vars.event else False
42
+
43
+ @property
44
+ def is_create(self):
45
+ return "create" in hook_vars.event if hook_vars.event else False
46
+
47
+ @property
48
+ def is_update(self):
49
+ return "update" in hook_vars.event if hook_vars.event else False
50
+
51
+ @property
52
+ def new(self):
53
+ return hook_vars.new
54
+
55
+ @property
56
+ def old(self):
57
+ return hook_vars.old
58
+
59
+ @property
60
+ def model(self):
61
+ return hook_vars.model
62
+
63
+
64
+ HookContext = HookContextState()
65
+
66
+
67
+ class HookMeta(type):
68
+ _registered = set()
69
+
70
+ def __new__(mcs, name, bases, namespace):
71
+ cls = super().__new__(mcs, name, bases, namespace)
72
+ for method_name, method in namespace.items():
73
+ if hasattr(method, "hooks_hooks"):
74
+ for model_cls, event, condition, priority in method.hooks_hooks:
75
+ key = (model_cls, event, cls, method_name)
76
+ if key not in HookMeta._registered:
77
+ register_hook(
78
+ model=model_cls,
79
+ event=event,
80
+ handler_cls=cls,
81
+ method_name=method_name,
82
+ condition=condition,
83
+ priority=priority,
84
+ )
85
+ HookMeta._registered.add(key)
86
+ return cls
87
+
88
+
89
+ class Hook(metaclass=HookMeta):
90
+ @classmethod
91
+ def handle(
92
+ cls,
93
+ event: str,
94
+ model: type,
95
+ *,
96
+ new_records: list = None,
97
+ old_records: list = None,
98
+ **kwargs,
99
+ ) -> None:
100
+ queue = get_hook_queue()
101
+ queue.append((cls, event, model, new_records, old_records, kwargs))
102
+ logger.debug(f"Added item to queue: {event}, depth: {hook_vars.depth}")
103
+
104
+ # If we're already processing hooks (depth > 0), don't process the queue
105
+ # The outermost call will process the entire queue
106
+ if hook_vars.depth > 0:
107
+ logger.debug(f"Depth > 0, returning without processing queue")
108
+ return
109
+
110
+ # Process the entire queue
111
+ logger.debug(f"Processing queue with {len(queue)} items")
112
+ while queue:
113
+ item = queue.popleft()
114
+ if len(item) == 6:
115
+ cls_, event_, model_, new_, old_, kw_ = item
116
+ logger.debug(f"Processing queue item: {event_}")
117
+ # Call _process on the Hook class, not the calling class
118
+ Hook._process(event_, model_, new_, old_, **kw_)
119
+ else:
120
+ logger.warning(f"Invalid queue item format: {item}")
121
+ continue
122
+
123
+ @classmethod
124
+ def _process(
125
+ cls,
126
+ event,
127
+ model,
128
+ new_records,
129
+ old_records,
130
+ **kwargs,
131
+ ):
132
+ hook_vars.depth += 1
133
+ hook_vars.new = new_records
134
+ hook_vars.old = old_records
135
+ hook_vars.event = event
136
+ hook_vars.model = model
137
+
138
+ hooks = sorted(get_hooks(model, event), key=lambda x: x[3])
139
+ logger.debug(f"Found {len(hooks)} hooks for {event}")
140
+
141
+ def _execute():
142
+ logger.debug(f"Executing {len(hooks)} hooks for {event}")
143
+ new_local = new_records or []
144
+ old_local = old_records or []
145
+ if len(old_local) < len(new_local):
146
+ old_local += [None] * (len(new_local) - len(old_local))
147
+
148
+ for handler_cls, method_name, condition, priority in hooks:
149
+ logger.debug(f"Processing hook {handler_cls.__name__}.{method_name}")
150
+ if condition is not None:
151
+ checks = [
152
+ condition.check(n, o) for n, o in zip(new_local, old_local)
153
+ ]
154
+ if not any(checks):
155
+ logger.debug(f"Condition failed for {handler_cls.__name__}.{method_name}")
156
+ continue
157
+
158
+ handler = handler_cls()
159
+ method = getattr(handler, method_name)
160
+ logger.debug(f"Executing {handler_cls.__name__}.{method_name}")
161
+
162
+ try:
163
+ method(
164
+ new_records=new_local,
165
+ old_records=old_local,
166
+ **kwargs,
167
+ )
168
+ logger.debug(f"Successfully executed {handler_cls.__name__}.{method_name}")
169
+ except Exception:
170
+ logger.exception(
171
+ "Error in hook %s.%s", handler_cls.__name__, method_name
172
+ )
173
+
174
+ conn = transaction.get_connection()
175
+ logger.debug(f"Transaction in_atomic_block: {conn.in_atomic_block}, event: {event}")
176
+ try:
177
+ if conn.in_atomic_block and event.startswith("after_"):
178
+ logger.debug(f"Deferring {event} to on_commit")
179
+ transaction.on_commit(_execute)
180
+ else:
181
+ logger.debug(f"Executing {event} immediately")
182
+ _execute()
183
+ finally:
184
+ hook_vars.new = None
185
+ hook_vars.old = None
186
+ hook_vars.event = None
187
+ hook_vars.model = None
188
+ hook_vars.depth -= 1
@@ -70,10 +70,20 @@ class HookQuerySetMixin:
70
70
  originals = [original_map.get(obj.pk) for obj in instances]
71
71
 
72
72
  # Check if any of the update values are Subquery objects
73
+ from django.db.models import Subquery
73
74
  has_subquery = any(
74
- hasattr(value, "query") and hasattr(value, "resolve_expression")
75
+ isinstance(value, Subquery)
75
76
  for value in kwargs.values()
76
77
  )
78
+
79
+ # Debug logging for Subquery detection
80
+ if has_subquery:
81
+ logger.debug(f"Detected Subquery in update: {[k for k, v in kwargs.items() if isinstance(v, Subquery)]}")
82
+ else:
83
+ # Check if we missed any Subquery objects
84
+ for k, v in kwargs.items():
85
+ if hasattr(v, 'query') and hasattr(v, 'resolve_expression'):
86
+ logger.warning(f"Potential Subquery-like object detected but not recognized: {k}={type(v).__name__}")
77
87
 
78
88
  # Apply field updates to instances
79
89
  # If a per-object value map exists (from bulk_update), prefer it over kwargs
@@ -175,8 +185,15 @@ class HookQuerySetMixin:
175
185
  getattr(refreshed_instance, field.name),
176
186
  )
177
187
 
178
- # Run AFTER_UPDATE hooks only for standalone updates
179
- if not current_bypass_hooks:
188
+ # Run AFTER_UPDATE hooks for standalone updates or subquery operations
189
+ # For subquery operations, we need to run hooks even if we're in a bulk context
190
+ # because subqueries bypass the normal object-level update flow
191
+ should_run_hooks = (
192
+ not current_bypass_hooks or
193
+ has_subquery # Always run hooks for subquery operations
194
+ )
195
+
196
+ if should_run_hooks:
180
197
  logger.debug("update: running AFTER_UPDATE")
181
198
  engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
182
199
  else:
@@ -837,6 +854,49 @@ class HookQuerySetMixin:
837
854
 
838
855
  return total_updated
839
856
 
857
+ @transaction.atomic
858
+ def bulk_delete(self, objs, bypass_hooks=False, bypass_validation=False, **kwargs):
859
+ """
860
+ Bulk delete objects in the database.
861
+ """
862
+ model_cls = self.model
863
+
864
+ if not objs:
865
+ return 0
866
+
867
+ if any(not isinstance(obj, model_cls) for obj in objs):
868
+ raise TypeError(
869
+ f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
870
+ )
871
+
872
+ logger.debug(
873
+ f"bulk_delete {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)}"
874
+ )
875
+
876
+ # Fire hooks before DB ops
877
+ if not bypass_hooks:
878
+ ctx = HookContext(model_cls, bypass_hooks=False)
879
+ if not bypass_validation:
880
+ engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
881
+ engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
882
+ else:
883
+ ctx = HookContext(model_cls, bypass_hooks=True)
884
+ logger.debug("bulk_delete bypassed hooks")
885
+
886
+ # Use Django's standard delete() method on the queryset
887
+ pks = [obj.pk for obj in objs if obj.pk is not None]
888
+ if pks:
889
+ # Use the base manager to avoid recursion
890
+ result = self.model._base_manager.filter(pk__in=pks).delete()[0]
891
+ else:
892
+ result = 0
893
+
894
+ # Fire AFTER_DELETE hooks
895
+ if not bypass_hooks:
896
+ engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
897
+
898
+ return result
899
+
840
900
 
841
901
  class HookQuerySet(HookQuerySetMixin, models.QuerySet):
842
902
  """
@@ -15,7 +15,7 @@ def register_hook(
15
15
  key = (model, event)
16
16
  hooks = _hooks.setdefault(key, [])
17
17
  hooks.append((handler_cls, method_name, condition, priority))
18
- # keep sorted by priority
18
+ # Sort by priority (lower values first)
19
19
  hooks.sort(key=lambda x: x[3])
20
20
  logger.debug(f"Registered {handler_cls.__name__}.{method_name} for {model.__name__}.{event}")
21
21
 
@@ -29,6 +29,13 @@ def get_hooks(model, event):
29
29
  return hooks
30
30
 
31
31
 
32
+ def clear_hooks():
33
+ """Clear all registered hooks. Useful for testing."""
34
+ global _hooks
35
+ _hooks.clear()
36
+ logger.debug("Cleared all registered hooks")
37
+
38
+
32
39
  def list_all_hooks():
33
40
  """Debug function to list all registered hooks"""
34
41
  return _hooks
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.1.238"
3
+ version = "0.1.239"
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"
@@ -16,6 +16,11 @@ packages = [
16
16
  python = "^3.11"
17
17
  Django = ">=4.0"
18
18
 
19
+ [tool.poetry.group.dev.dependencies]
20
+ pytest = "^7.4.0"
21
+ pytest-django = "^4.5.0"
22
+ pytest-cov = "^4.1.0"
23
+
19
24
  [build-system]
20
25
  requires = ["poetry-core"]
21
26
  build-backend = "poetry.core.masonry.api"
@@ -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"]