django-bulk-hooks 0.1.231__py3-none-any.whl → 0.1.232__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,59 +1,33 @@
1
1
  import logging
2
2
 
3
3
  from django.core.exceptions import ValidationError
4
- from django.db import transaction
5
- from django.conf import settings
6
4
 
7
5
  from django_bulk_hooks.registry import get_hooks
8
- from django_bulk_hooks.handler import hook_vars
9
6
 
10
7
  logger = logging.getLogger(__name__)
11
8
 
12
9
 
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
-
25
10
  def run(model_cls, event, new_records, old_records=None, ctx=None):
26
11
  """
27
12
  Run hooks for a given model, event, and records.
28
-
29
- Production-grade executor:
30
- - Honors bypass_hooks via ctx
31
- - Runs model.clean() before BEFORE_* events for validation
32
- - Executes hooks in registry priority order with condition filtering
33
- - Exposes thread-local context via hook_vars during execution
34
- - AFTER_* timing is configurable via settings.BULK_HOOKS_AFTER_ON_COMMIT (default: False)
35
-
36
- Args:
37
- model_cls: The Django model class
38
- event: The hook event (e.g., 'before_create', 'after_update')
39
- new_records: List of new/updated records
40
- old_records: List of original records (for comparison)
41
- ctx: Optional hook context
42
13
  """
43
14
  if not new_records:
44
15
  return
45
16
 
17
+ # Get hooks for this model and event
46
18
  hooks = get_hooks(model_cls, event)
19
+
47
20
  if not hooks:
48
21
  return
49
22
 
50
- logger.debug(
51
- f"Running {len(hooks)} hooks for {model_cls.__name__}.{event} ({len(new_records)} records)"
52
- )
23
+ import traceback
53
24
 
25
+ stack = traceback.format_stack()
26
+ logger.debug(f"engine.run {model_cls.__name__}.{event} {len(new_records)} records")
27
+
54
28
  # Check if we're in a bypass context
55
- if ctx and hasattr(ctx, "bypass_hooks") and ctx.bypass_hooks:
56
- logger.debug("Hook execution bypassed")
29
+ if ctx and hasattr(ctx, 'bypass_hooks') and ctx.bypass_hooks:
30
+ logger.debug("engine.run bypassed")
57
31
  return
58
32
 
59
33
  # For BEFORE_* events, run model.clean() first for validation
@@ -65,96 +39,36 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
65
39
  logger.error("Validation failed for %s: %s", instance, e)
66
40
  raise
67
41
 
68
- def _execute():
69
- hook_vars.depth += 1
70
- hook_vars.event = event
71
-
72
- try:
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 = []
99
-
100
- for handler_cls, method_name, condition, priority in hooks:
101
- try:
102
- handler_instance = handler_cls()
103
- func = getattr(handler_instance, method_name)
104
- except Exception as e:
105
- logger.error(
106
- "Failed to instantiate %s.%s: %s",
107
- handler_cls.__name__,
108
- method_name,
109
- e,
110
- )
111
- continue
112
-
113
- # Condition filtering per record using the deterministic pairs
114
- to_process_new = []
115
- to_process_old = []
116
- for new_obj, old_obj in pairs:
117
- if not condition:
118
- to_process_new.append(new_obj)
119
- to_process_old.append(old_obj)
120
- else:
121
- try:
122
- if condition.check(new_obj, old_obj):
123
- to_process_new.append(new_obj)
124
- to_process_old.append(old_obj)
125
- except Exception as e:
126
- logger.error(
127
- "Condition failed for %s.%s: %s",
128
- handler_cls.__name__,
129
- method_name,
130
- e,
131
- )
132
- continue
133
-
134
- if not to_process_new:
135
- continue
136
-
137
- try:
138
- func(
139
- new_records=to_process_new,
140
- old_records=to_process_old if any(x is not None for x in to_process_old) else None,
141
- )
142
- except Exception as e:
143
- logger.exception(
144
- "Error in hook %s.%s", handler_cls.__name__, method_name
145
- )
146
- if failure_policy == "best_effort":
147
- collected_errors.append((f"{handler_cls.__name__}.{method_name}", e))
148
- continue
149
- # fail_fast
150
- raise
151
-
152
- if collected_errors:
153
- raise AggregatedHookError(collected_errors)
154
- finally:
155
- hook_vars.event = None
156
- hook_vars.depth -= 1
157
-
158
- # Execute immediately so AFTER_* runs within the transaction.
159
- # If a hook raises, the transaction is rolled back (Salesforce-style).
160
- _execute()
42
+ # Process hooks
43
+ for handler_cls, method_name, condition, priority in hooks:
44
+ logger.debug(f"Processing {handler_cls.__name__}.{method_name}")
45
+ handler_instance = handler_cls()
46
+ func = getattr(handler_instance, method_name)
47
+
48
+ to_process_new = []
49
+ to_process_old = []
50
+
51
+ for new, original in zip(
52
+ new_records,
53
+ old_records or [None] * len(new_records),
54
+ strict=True,
55
+ ):
56
+ if not condition:
57
+ to_process_new.append(new)
58
+ to_process_old.append(original)
59
+ else:
60
+ condition_result = condition.check(new, original)
61
+ if condition_result:
62
+ to_process_new.append(new)
63
+ to_process_old.append(original)
64
+
65
+ if to_process_new:
66
+ logger.debug(f"Executing {handler_cls.__name__}.{method_name} for {len(to_process_new)} records")
67
+ try:
68
+ func(
69
+ new_records=to_process_new,
70
+ old_records=to_process_old if any(to_process_old) else None,
71
+ )
72
+ except Exception as e:
73
+ logger.debug(f"Hook execution failed: {e}")
74
+ raise
@@ -1,14 +1,17 @@
1
- """
2
- Compatibility layer for priority-related exports.
1
+ from enum import IntEnum
3
2
 
4
- This module re-exports the canonical Priority enum and a DEFAULT_PRIORITY
5
- constant so existing imports like `from django_bulk_hooks.enums import ...`
6
- continue to work without churn. Prefer importing from `priority` in new code.
7
- """
8
3
 
9
- from django_bulk_hooks.priority import Priority
4
+ class Priority(IntEnum):
5
+ """
6
+ Named priorities for django-bulk-hooks hooks.
7
+ Replaces module-level constants with a clean IntEnum.
8
+ """
9
+
10
+ HIGHEST = 0 # runs first
11
+ HIGH = 25 # runs early
12
+ NORMAL = 50 # default ordering
13
+ LOW = 75 # runs late
14
+ LOWEST = 100 # runs last
10
15
 
11
- # Default priority used when none is specified by the hook decorator
12
- DEFAULT_PRIORITY = Priority.NORMAL
13
16
 
14
- __all__ = ["Priority", "DEFAULT_PRIORITY"]
17
+ DEFAULT_PRIORITY = Priority.NORMAL
@@ -2,7 +2,9 @@ import logging
2
2
  import threading
3
3
  from collections import deque
4
4
 
5
- from django_bulk_hooks.registry import register_hook
5
+ from django.db import transaction
6
+
7
+ from django_bulk_hooks.registry import get_hooks, register_hook
6
8
 
7
9
  logger = logging.getLogger(__name__)
8
10
 
@@ -59,41 +61,28 @@ class HookContextState:
59
61
  return hook_vars.model
60
62
 
61
63
 
64
+ Hook = HookContextState()
65
+
66
+
62
67
  class HookMeta(type):
63
- """Metaclass that automatically registers hooks when Hook classes are defined."""
64
-
68
+ _registered = set()
69
+
65
70
  def __new__(mcs, name, bases, namespace):
66
71
  cls = super().__new__(mcs, name, bases, namespace)
67
-
68
- # Register hooks for this class, including inherited methods
69
- # We need to check all methods in the MRO to handle inheritance
70
- for attr_name in dir(cls):
71
- if attr_name.startswith('_'):
72
- continue
73
-
74
- try:
75
- attr = getattr(cls, attr_name)
76
- if callable(attr) and hasattr(attr, "hooks_hooks"):
77
- for model_cls, event, condition, priority in attr.hooks_hooks:
78
- # Register the hook
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:
79
77
  register_hook(
80
78
  model=model_cls,
81
79
  event=event,
82
80
  handler_cls=cls,
83
- method_name=attr_name,
81
+ method_name=method_name,
84
82
  condition=condition,
85
83
  priority=priority,
86
84
  )
87
-
88
- logger.debug(
89
- f"Registered hook {cls.__name__}.{attr_name} "
90
- f"for {model_cls.__name__}.{event} with priority {priority}"
91
- )
92
- except Exception as e:
93
- # Skip attributes that can't be accessed
94
- logger.debug(f"Skipping attribute {attr_name}: {e}")
95
- continue
96
-
85
+ HookMeta._registered.add(key)
97
86
  return cls
98
87
 
99
88
 
@@ -108,11 +97,16 @@ class Hook(metaclass=HookMeta):
108
97
  old_records: list = None,
109
98
  **kwargs,
110
99
  ) -> None:
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"))
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_)
116
110
 
117
111
  @classmethod
118
112
  def _process(
@@ -123,12 +117,51 @@ class Hook(metaclass=HookMeta):
123
117
  old_records,
124
118
  **kwargs,
125
119
  ):
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"))
131
-
132
-
133
- # Create a global Hook instance for context access
134
- HookContext = HookContextState()
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,135 +1,113 @@
1
- from typing import Iterable, Sequence, Any
2
1
  from django.db import models
3
2
 
4
3
  from django_bulk_hooks.queryset import HookQuerySet, HookQuerySetMixin
5
4
 
6
5
 
7
6
  class BulkHookManager(models.Manager):
8
- """Manager that ensures all queryset operations are hook-aware.
7
+ def get_queryset(self):
8
+ # Use super().get_queryset() to let Django and MRO build the queryset
9
+ # This ensures cooperation with other managers
10
+ base_queryset = super().get_queryset()
11
+
12
+ # If the base queryset already has hook functionality, return it as-is
13
+ if isinstance(base_queryset, HookQuerySetMixin):
14
+ return base_queryset
15
+
16
+ # Otherwise, create a new HookQuerySet with the same parameters
17
+ # This is much simpler and avoids dynamic class creation issues
18
+ return HookQuerySet(
19
+ model=base_queryset.model,
20
+ query=base_queryset.query,
21
+ using=base_queryset._db,
22
+ hints=base_queryset._hints
23
+ )
9
24
 
10
- Delegates operations to a hook-enabled queryset while preserving any
11
- customizations from other managers in the MRO by starting with
12
- ``super().get_queryset()``.
13
- """
25
+ def bulk_create(
26
+ self,
27
+ objs,
28
+ batch_size=None,
29
+ ignore_conflicts=False,
30
+ update_conflicts=False,
31
+ update_fields=None,
32
+ unique_fields=None,
33
+ bypass_hooks=False,
34
+ bypass_validation=False,
35
+ **kwargs,
36
+ ):
37
+ """
38
+ Delegate to QuerySet's bulk_create implementation.
39
+ This follows Django's pattern where Manager methods call QuerySet methods.
40
+ """
41
+ return self.get_queryset().bulk_create(
42
+ objs,
43
+ bypass_hooks=bypass_hooks,
44
+ bypass_validation=bypass_validation,
45
+ batch_size=batch_size,
46
+ ignore_conflicts=ignore_conflicts,
47
+ update_conflicts=update_conflicts,
48
+ update_fields=update_fields,
49
+ unique_fields=unique_fields,
50
+ **kwargs,
51
+ )
14
52
 
15
- # Cache for composed queryset classes to preserve custom queryset APIs
16
- _qs_compose_cache = {}
53
+ def bulk_update(
54
+ self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
55
+ ):
56
+ """
57
+ Delegate to QuerySet's bulk_update implementation.
58
+ This follows Django's pattern where Manager methods call QuerySet methods.
59
+ """
60
+ return self.get_queryset().bulk_update(
61
+ objs,
62
+ fields,
63
+ bypass_hooks=bypass_hooks,
64
+ bypass_validation=bypass_validation,
65
+ **kwargs,
66
+ )
17
67
 
18
- def get_queryset(self) -> HookQuerySet:
19
- # Use super().get_queryset() to let Django and MRO build the queryset
20
- base_queryset = super().get_queryset()
68
+ def bulk_delete(
69
+ self,
70
+ objs,
71
+ batch_size=None,
72
+ bypass_hooks=False,
73
+ bypass_validation=False,
74
+ **kwargs,
75
+ ):
76
+ """
77
+ Delegate to QuerySet's bulk_delete implementation.
78
+ This follows Django's pattern where Manager methods call QuerySet methods.
79
+ """
80
+ return self.get_queryset().bulk_delete(
81
+ objs,
82
+ bypass_hooks=bypass_hooks,
83
+ bypass_validation=bypass_validation,
84
+ batch_size=batch_size,
85
+ **kwargs,
86
+ )
21
87
 
22
- # If the base queryset already has hook functionality, return it as-is
23
- if isinstance(base_queryset, HookQuerySetMixin):
24
- return base_queryset # type: ignore[return-value]
88
+ def delete(self):
89
+ """
90
+ Delegate to QuerySet's delete implementation.
91
+ This follows Django's pattern where Manager methods call QuerySet methods.
92
+ """
93
+ return self.get_queryset().delete()
25
94
 
26
- # Otherwise, dynamically compose a queryset class that preserves the
27
- # base queryset's custom API while adding hook behavior
28
- base_cls = base_queryset.__class__
29
- composed_cls = self._qs_compose_cache.get(base_cls)
30
- if composed_cls is None:
31
- composed_name = f"ComposedHookQuerySet_{base_cls.__name__}"
32
- composed_cls = type(composed_name, (HookQuerySetMixin, base_cls), {})
33
- self._qs_compose_cache[base_cls] = composed_cls
95
+ def update(self, **kwargs):
96
+ """
97
+ Delegate to QuerySet's update implementation.
98
+ This follows Django's pattern where Manager methods call QuerySet methods.
99
+ """
100
+ return self.get_queryset().update(**kwargs)
34
101
 
35
- return composed_cls(
36
- model=base_queryset.model,
37
- query=base_queryset.query,
38
- using=base_queryset._db,
39
- hints=base_queryset._hints,
40
- )
41
-
42
- def bulk_create(
43
- self,
44
- objs: Iterable[models.Model],
45
- batch_size: int | None = None,
46
- ignore_conflicts: bool = False,
47
- update_conflicts: bool = False,
48
- update_fields: Sequence[str] | None = None,
49
- unique_fields: Sequence[str] | None = None,
50
- bypass_hooks: bool = False,
51
- bypass_validation: bool = False,
52
- **kwargs: Any,
53
- ) -> list[models.Model]:
54
- """
55
- Delegate to QuerySet's bulk_create implementation.
56
- This follows Django's pattern where Manager methods call QuerySet methods.
57
- """
58
- return self.get_queryset().bulk_create(
59
- objs,
60
- bypass_hooks=bypass_hooks,
61
- bypass_validation=bypass_validation,
62
- batch_size=batch_size,
63
- ignore_conflicts=ignore_conflicts,
64
- update_conflicts=update_conflicts,
65
- update_fields=update_fields,
66
- unique_fields=unique_fields,
67
- **kwargs,
68
- )
69
-
70
- def bulk_update(
71
- self,
72
- objs: Iterable[models.Model],
73
- fields: Sequence[str],
74
- bypass_hooks: bool = False,
75
- bypass_validation: bool = False,
76
- **kwargs: Any,
77
- ) -> int:
78
- """
79
- Delegate to QuerySet's bulk_update implementation.
80
- This follows Django's pattern where Manager methods call QuerySet methods.
81
- """
82
- return self.get_queryset().bulk_update(
83
- objs,
84
- fields,
85
- bypass_hooks=bypass_hooks,
86
- bypass_validation=bypass_validation,
87
- **kwargs,
88
- )
89
-
90
- def bulk_delete(
91
- self,
92
- objs: Iterable[models.Model],
93
- batch_size: int | None = None,
94
- bypass_hooks: bool = False,
95
- bypass_validation: bool = False,
96
- **kwargs: Any,
97
- ) -> int:
98
- """
99
- Delegate to QuerySet's bulk_delete implementation.
100
- This follows Django's pattern where Manager methods call QuerySet methods.
101
- """
102
- return self.get_queryset().bulk_delete(
103
- objs,
104
- bypass_hooks=bypass_hooks,
105
- bypass_validation=bypass_validation,
106
- batch_size=batch_size,
107
- **kwargs,
108
- )
109
-
110
- def delete(self) -> int:
111
- """
112
- Delegate to QuerySet's delete implementation.
113
- This follows Django's pattern where Manager methods call QuerySet methods.
114
- """
115
- return self.get_queryset().delete()
116
-
117
- def update(self, **kwargs: Any) -> int:
118
- """
119
- Delegate to QuerySet's update implementation.
120
- This follows Django's pattern where Manager methods call QuerySet methods.
121
- """
122
- return self.get_queryset().update(**kwargs)
123
-
124
- def save(self, obj: models.Model) -> models.Model:
125
- """
126
- Save a single object using the appropriate bulk operation.
127
- """
128
- if obj.pk:
129
- self.bulk_update(
130
- [obj],
131
- fields=[field.name for field in obj._meta.fields if field.name != "id"],
132
- )
133
- else:
134
- self.bulk_create([obj])
135
- return obj
102
+ def save(self, obj):
103
+ """
104
+ Save a single object using the appropriate bulk operation.
105
+ """
106
+ if obj.pk:
107
+ self.bulk_update(
108
+ [obj],
109
+ fields=[field.name for field in obj._meta.fields if field.name != "id"],
110
+ )
111
+ else:
112
+ self.bulk_create([obj])
113
+ return obj