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

@@ -1,15 +1,4 @@
1
1
  from django_bulk_hooks.handler import Hook
2
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
3
 
8
- __all__ = [
9
- "Hook",
10
- "hook",
11
- "Priority",
12
- "HookContext",
13
- "HookModelMixin",
14
- "BulkHookManager",
15
- ]
4
+ __all__ = ["BulkHookManager", "Hook"]
@@ -1,14 +1,11 @@
1
1
  import logging
2
- from typing import Any, Optional
3
2
 
4
3
  logger = logging.getLogger(__name__)
5
4
 
6
5
 
7
- def resolve_dotted_attr(instance: Any, dotted_path: str) -> Any:
6
+ def resolve_dotted_attr(instance, dotted_path):
8
7
  """
9
8
  Recursively resolve a dotted attribute path, e.g., "type.category".
10
-
11
- Returns None if any intermediate value is None or missing.
12
9
  """
13
10
  for attr in dotted_path.split("."):
14
11
  if instance is None:
@@ -18,29 +15,29 @@ def resolve_dotted_attr(instance: Any, dotted_path: str) -> Any:
18
15
 
19
16
 
20
17
  class HookCondition:
21
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
18
+ def check(self, instance, original_instance=None):
22
19
  raise NotImplementedError
23
20
 
24
- def __call__(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
21
+ def __call__(self, instance, original_instance=None):
25
22
  return self.check(instance, original_instance)
26
23
 
27
- def __and__(self, other: "HookCondition") -> "HookCondition":
24
+ def __and__(self, other):
28
25
  return AndCondition(self, other)
29
26
 
30
- def __or__(self, other: "HookCondition") -> "HookCondition":
27
+ def __or__(self, other):
31
28
  return OrCondition(self, other)
32
29
 
33
- def __invert__(self) -> "HookCondition":
30
+ def __invert__(self):
34
31
  return NotCondition(self)
35
32
 
36
33
 
37
34
  class IsNotEqual(HookCondition):
38
- def __init__(self, field: str, value: Any, only_on_change: bool = False):
35
+ def __init__(self, field, value, only_on_change=False):
39
36
  self.field = field
40
37
  self.value = value
41
38
  self.only_on_change = only_on_change
42
39
 
43
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
40
+ def check(self, instance, original_instance=None):
44
41
  current = resolve_dotted_attr(instance, self.field)
45
42
  if self.only_on_change:
46
43
  if original_instance is None:
@@ -52,12 +49,12 @@ class IsNotEqual(HookCondition):
52
49
 
53
50
 
54
51
  class IsEqual(HookCondition):
55
- def __init__(self, field: str, value: Any, only_on_change: bool = False):
52
+ def __init__(self, field, value, only_on_change=False):
56
53
  self.field = field
57
54
  self.value = value
58
55
  self.only_on_change = only_on_change
59
56
 
60
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
57
+ def check(self, instance, original_instance=None):
61
58
  current = resolve_dotted_attr(instance, self.field)
62
59
  if self.only_on_change:
63
60
  if original_instance is None:
@@ -69,11 +66,11 @@ class IsEqual(HookCondition):
69
66
 
70
67
 
71
68
  class HasChanged(HookCondition):
72
- def __init__(self, field: str, has_changed: bool = True):
69
+ def __init__(self, field, has_changed=True):
73
70
  self.field = field
74
71
  self.has_changed = has_changed
75
72
 
76
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
73
+ def check(self, instance, original_instance=None):
77
74
  if not original_instance:
78
75
  return False
79
76
 
@@ -88,7 +85,7 @@ class HasChanged(HookCondition):
88
85
 
89
86
 
90
87
  class WasEqual(HookCondition):
91
- def __init__(self, field: str, value: Any, only_on_change: bool = False):
88
+ def __init__(self, field, value, only_on_change=False):
92
89
  """
93
90
  Check if a field's original value was `value`.
94
91
  If only_on_change is True, only return True when the field has changed away from that value.
@@ -97,7 +94,7 @@ class WasEqual(HookCondition):
97
94
  self.value = value
98
95
  self.only_on_change = only_on_change
99
96
 
100
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
97
+ def check(self, instance, original_instance=None):
101
98
  if original_instance is None:
102
99
  return False
103
100
  previous = resolve_dotted_attr(original_instance, self.field)
@@ -109,7 +106,7 @@ class WasEqual(HookCondition):
109
106
 
110
107
 
111
108
  class ChangesTo(HookCondition):
112
- def __init__(self, field: str, value: Any):
109
+ def __init__(self, field, value):
113
110
  """
114
111
  Check if a field's value has changed to `value`.
115
112
  Only returns True when original value != value and current value == value.
@@ -117,7 +114,7 @@ class ChangesTo(HookCondition):
117
114
  self.field = field
118
115
  self.value = value
119
116
 
120
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
117
+ def check(self, instance, original_instance=None):
121
118
  if original_instance is None:
122
119
  return False
123
120
  previous = resolve_dotted_attr(original_instance, self.field)
@@ -126,70 +123,70 @@ class ChangesTo(HookCondition):
126
123
 
127
124
 
128
125
  class IsGreaterThan(HookCondition):
129
- def __init__(self, field: str, value: Any):
126
+ def __init__(self, field, value):
130
127
  self.field = field
131
128
  self.value = value
132
129
 
133
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
130
+ def check(self, instance, original_instance=None):
134
131
  current = resolve_dotted_attr(instance, self.field)
135
132
  return current is not None and current > self.value
136
133
 
137
134
 
138
135
  class IsGreaterThanOrEqual(HookCondition):
139
- def __init__(self, field: str, value: Any):
136
+ def __init__(self, field, value):
140
137
  self.field = field
141
138
  self.value = value
142
139
 
143
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
140
+ def check(self, instance, original_instance=None):
144
141
  current = resolve_dotted_attr(instance, self.field)
145
142
  return current is not None and current >= self.value
146
143
 
147
144
 
148
145
  class IsLessThan(HookCondition):
149
- def __init__(self, field: str, value: Any):
146
+ def __init__(self, field, value):
150
147
  self.field = field
151
148
  self.value = value
152
149
 
153
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
150
+ def check(self, instance, original_instance=None):
154
151
  current = resolve_dotted_attr(instance, self.field)
155
152
  return current is not None and current < self.value
156
153
 
157
154
 
158
155
  class IsLessThanOrEqual(HookCondition):
159
- def __init__(self, field: str, value: Any):
156
+ def __init__(self, field, value):
160
157
  self.field = field
161
158
  self.value = value
162
159
 
163
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
160
+ def check(self, instance, original_instance=None):
164
161
  current = resolve_dotted_attr(instance, self.field)
165
162
  return current is not None and current <= self.value
166
163
 
167
164
 
168
165
  class AndCondition(HookCondition):
169
- def __init__(self, cond1: HookCondition, cond2: HookCondition):
166
+ def __init__(self, cond1, cond2):
170
167
  self.cond1 = cond1
171
168
  self.cond2 = cond2
172
169
 
173
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
170
+ def check(self, instance, original_instance=None):
174
171
  return self.cond1.check(instance, original_instance) and self.cond2.check(
175
172
  instance, original_instance
176
173
  )
177
174
 
178
175
 
179
176
  class OrCondition(HookCondition):
180
- def __init__(self, cond1: HookCondition, cond2: HookCondition):
177
+ def __init__(self, cond1, cond2):
181
178
  self.cond1 = cond1
182
179
  self.cond2 = cond2
183
180
 
184
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
181
+ def check(self, instance, original_instance=None):
185
182
  return self.cond1.check(instance, original_instance) or self.cond2.check(
186
183
  instance, original_instance
187
184
  )
188
185
 
189
186
 
190
187
  class NotCondition(HookCondition):
191
- def __init__(self, cond: HookCondition):
188
+ def __init__(self, cond):
192
189
  self.cond = cond
193
190
 
194
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
191
+ def check(self, instance, original_instance=None):
195
192
  return not self.cond.check(instance, original_instance)
@@ -1,61 +1,36 @@
1
1
  import threading
2
- from typing import Deque, Optional
3
- from django_bulk_hooks.handler import hook_vars, get_hook_queue as _handler_get_hook_queue
2
+ from collections import deque
3
+ from django_bulk_hooks.handler import hook_vars
4
4
 
5
5
 
6
6
  _hook_context = threading.local()
7
7
 
8
8
 
9
- def get_hook_queue() -> Deque:
10
- """
11
- Return the per-thread hook execution queue used by the handler.
9
+ def get_hook_queue():
10
+ if not hasattr(_hook_context, "queue"):
11
+ _hook_context.queue = deque()
12
+ return _hook_context.queue
12
13
 
13
- This proxies to the centralized queue in the handler module to avoid
14
- divergent queues.
15
- """
16
- return _handler_get_hook_queue()
17
14
 
18
-
19
- def set_bypass_hooks(bypass_hooks: bool) -> None:
15
+ def set_bypass_hooks(bypass_hooks):
20
16
  """Set the current bypass_hooks state for the current thread."""
21
17
  _hook_context.bypass_hooks = bypass_hooks
22
18
 
23
19
 
24
- def get_bypass_hooks() -> bool:
20
+ def get_bypass_hooks():
25
21
  """Get the current bypass_hooks state for the current thread."""
26
22
  return getattr(_hook_context, 'bypass_hooks', False)
27
23
 
28
24
 
29
25
  class HookContext:
30
- """
31
- Hook execution context helper.
32
-
33
- - Carries the model and bypass_hooks flag.
34
- - On construction sets thread-local bypass state;
35
- when used as a context manager, restores previous state on exit.
36
- """
37
-
38
- def __init__(self, model: type, bypass_hooks: bool = False):
39
- self.model: type = model
40
- self.bypass_hooks: bool = bypass_hooks
41
- self._prev_bypass: Optional[bool] = None
42
- # Preserve legacy behavior: set bypass state at construction
26
+ def __init__(self, model, bypass_hooks=False):
27
+ self.model = model
28
+ self.bypass_hooks = bypass_hooks
29
+ # Set the thread-local bypass state when creating a context
43
30
  set_bypass_hooks(bypass_hooks)
44
31
 
45
- def __enter__(self) -> "HookContext":
46
- self._prev_bypass = get_bypass_hooks()
47
- set_bypass_hooks(self.bypass_hooks)
48
- return self
49
-
50
- def __exit__(self, exc_type, exc, tb) -> bool:
51
- # Restore previous bypass state
52
- if self._prev_bypass is not None:
53
- set_bypass_hooks(self._prev_bypass)
54
- # Do not suppress exceptions
55
- return False
56
-
57
32
  @property
58
- def is_executing(self) -> bool:
33
+ def is_executing(self):
59
34
  """
60
35
  Check if we're currently in a hook execution context.
61
36
  Similar to Salesforce's Trigger.isExecuting.
@@ -64,18 +39,15 @@ class HookContext:
64
39
  return hasattr(hook_vars, 'event') and hook_vars.event is not None
65
40
 
66
41
  @property
67
- def current_event(self) -> Optional[str]:
42
+ def current_event(self):
68
43
  """
69
44
  Get the current hook event being executed.
70
45
  """
71
46
  return getattr(hook_vars, 'event', None)
72
47
 
73
48
  @property
74
- def execution_depth(self) -> int:
49
+ def execution_depth(self):
75
50
  """
76
51
  Get the current execution depth to detect deep recursion.
77
52
  """
78
53
  return getattr(hook_vars, 'depth', 0)
79
-
80
- def __repr__(self) -> str:
81
- return f"HookContext(model={getattr(self.model, '__name__', self.model)!r}, bypass_hooks={self.bypass_hooks})"
@@ -1,28 +1,18 @@
1
- """
2
- Decorators for defining and optimizing hook handlers.
3
-
4
- Notes:
5
- - Hook registration occurs at import time; importing modules that define Hook
6
- subclasses or use @hook will register handlers in the global registry.
7
- - The preload helpers below are safe, in-place optimizations to avoid N+1s.
8
- """
9
-
10
1
  import inspect
11
2
  from functools import wraps
12
- from typing import Any, Callable, Optional
13
3
 
14
4
  from django.core.exceptions import FieldDoesNotExist
15
5
  from django_bulk_hooks.enums import DEFAULT_PRIORITY
16
6
  from django_bulk_hooks.registry import register_hook
17
7
 
18
8
 
19
- def hook(event: str, *, model: type, condition: Optional[Callable] = None, priority: int = DEFAULT_PRIORITY):
9
+ def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
20
10
  """
21
11
  Decorator to annotate a method with multiple hooks hook registrations.
22
12
  If no priority is provided, uses Priority.NORMAL (50).
23
13
  """
24
14
 
25
- def decorator(fn: Callable):
15
+ def decorator(fn):
26
16
  if not hasattr(fn, "hooks_hooks"):
27
17
  fn.hooks_hooks = []
28
18
  fn.hooks_hooks.append((model, event, condition, priority))
@@ -31,7 +21,7 @@ def hook(event: str, *, model: type, condition: Optional[Callable] = None, prior
31
21
  return decorator
32
22
 
33
23
 
34
- def select_related(*related_fields: str):
24
+ def select_related(*related_fields):
35
25
  """
36
26
  Decorator that preloads related fields in-place on `new_records`, before the hook logic runs.
37
27
 
@@ -40,14 +30,11 @@ def select_related(*related_fields: str):
40
30
  - Populates Django's relation cache to avoid extra queries
41
31
  """
42
32
 
43
- def decorator(func: Callable):
44
- # No-op if no fields specified
45
- if not related_fields:
46
- return func
33
+ def decorator(func):
47
34
  sig = inspect.signature(func)
48
35
 
49
36
  @wraps(func)
50
- def wrapper(*args: Any, **kwargs: Any):
37
+ def wrapper(*args, **kwargs):
51
38
  bound = sig.bind_partial(*args, **kwargs)
52
39
  bound.apply_defaults()
53
40
 
@@ -118,7 +105,7 @@ def select_related(*related_fields: str):
118
105
  return decorator
119
106
 
120
107
 
121
- def bulk_hook(model_cls: type, event: str, when: Optional[Callable] = None, priority: Optional[int] = None):
108
+ def bulk_hook(model_cls, event, when=None, priority=None):
122
109
  """
123
110
  Decorator to register a bulk hook for a model.
124
111
 
@@ -128,24 +115,13 @@ def bulk_hook(model_cls: type, event: str, when: Optional[Callable] = None, prio
128
115
  when: Optional condition for when the hook should run
129
116
  priority: Optional priority for hook execution order
130
117
  """
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
-
118
+ def decorator(func):
143
119
  # Create a simple handler class for the function
144
120
  class FunctionHandler:
145
121
  def __init__(self):
146
122
  self.func = func
147
123
 
148
- def handle(self, new_instances: list, original_instances: Optional[list]):
124
+ def handle(self, new_instances, original_instances):
149
125
  return self.func(new_instances, original_instances)
150
126
 
151
127
  # Register the hook using the registry
@@ -159,82 +135,3 @@ def bulk_hook(model_cls: type, event: str, when: Optional[Callable] = None, prio
159
135
  )
160
136
  return func
161
137
  return decorator
162
-
163
-
164
- def prefetch_related(*related_fields: str):
165
- """
166
- Decorator that prefetches related collections on `new_records` in-place,
167
- populating Django's prefetch cache to avoid extra queries in hooks.
168
-
169
- - Supports many-to-many and one-to-many relationships
170
- - Preserves instance identity; does not replace objects
171
- - Uses the base manager to avoid recursive hook triggering
172
- """
173
-
174
- def decorator(func: Callable):
175
- # No-op if no fields specified
176
- if not related_fields:
177
- return func
178
-
179
- sig = inspect.signature(func)
180
-
181
- @wraps(func)
182
- def wrapper(*args: Any, **kwargs: Any):
183
- bound = sig.bind_partial(*args, **kwargs)
184
- bound.apply_defaults()
185
-
186
- if "new_records" not in bound.arguments:
187
- raise TypeError(
188
- "@prefetch_related requires a 'new_records' argument in the decorated function"
189
- )
190
-
191
- new_records = bound.arguments["new_records"]
192
-
193
- if not isinstance(new_records, list):
194
- raise TypeError(
195
- f"@prefetch_related expects a list of model instances, got {type(new_records)}"
196
- )
197
-
198
- if not new_records:
199
- return func(*args, **kwargs)
200
-
201
- model_cls = new_records[0].__class__
202
- ids_to_fetch = [obj.pk for obj in new_records if getattr(obj, "pk", None)]
203
-
204
- if ids_to_fetch:
205
- # Validate fields (no dotted notation)
206
- for field in related_fields:
207
- if "." in field:
208
- raise ValueError(
209
- f"@prefetch_related does not support nested fields like '{field}'"
210
- )
211
-
212
- fetched_map = {
213
- obj.pk: obj
214
- for obj in (
215
- model_cls._base_manager.filter(pk__in=ids_to_fetch)
216
- .prefetch_related(*related_fields)
217
- )
218
- }
219
-
220
- for obj in new_records:
221
- preloaded = fetched_map.get(obj.pk)
222
- if preloaded is None:
223
- continue
224
- # Copy prefetch cache entries from the preloaded instance
225
- src_cache = getattr(preloaded, "_prefetched_objects_cache", {})
226
- if not src_cache:
227
- continue
228
- dst_cache = getattr(obj, "_prefetched_objects_cache", None)
229
- if dst_cache is None:
230
- obj._prefetched_objects_cache = {}
231
- dst_cache = obj._prefetched_objects_cache
232
- for field in related_fields:
233
- if field in src_cache and field not in dst_cache:
234
- dst_cache[field] = src_cache[field]
235
-
236
- return func(*bound.args, **bound.kwargs)
237
-
238
- return wrapper
239
-
240
- return decorator