django-bulk-hooks 0.2.39__tar.gz → 0.2.41__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 (26) hide show
  1. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/PKG-INFO +1 -1
  2. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/dispatcher.py +255 -255
  3. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/operations/analyzer.py +315 -277
  4. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/operations/bulk_executor.py +6 -5
  5. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/operations/coordinator.py +670 -670
  6. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/pyproject.toml +1 -1
  7. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/LICENSE +0 -0
  8. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/README.md +0 -0
  9. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/__init__.py +0 -0
  10. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/changeset.py +0 -0
  11. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/conditions.py +0 -0
  12. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/constants.py +0 -0
  13. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/context.py +0 -0
  14. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/decorators.py +0 -0
  15. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/enums.py +0 -0
  16. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/factory.py +0 -0
  17. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/handler.py +0 -0
  18. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/helpers.py +0 -0
  19. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/manager.py +0 -0
  20. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/models.py +0 -0
  21. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/operations/__init__.py +0 -0
  22. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/operations/mti_handler.py +0 -0
  23. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/operations/mti_plans.py +0 -0
  24. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/operations/record_classifier.py +0 -0
  25. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/queryset.py +0 -0
  26. {django_bulk_hooks-0.2.39 → django_bulk_hooks-0.2.41}/django_bulk_hooks/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.2.39
3
+ Version: 0.2.41
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
@@ -1,255 +1,255 @@
1
- """
2
- HookDispatcher: Single execution path for all hooks.
3
-
4
- Provides deterministic, priority-ordered hook execution,
5
- similar to Salesforce's hook framework.
6
- """
7
-
8
- import logging
9
-
10
- logger = logging.getLogger(__name__)
11
-
12
-
13
- class HookDispatcher:
14
- """
15
- Single execution path for all hooks.
16
-
17
- Responsibilities:
18
- - Execute hooks in priority order
19
- - Filter records based on conditions
20
- - Provide ChangeSet context to hooks
21
- - Fail-fast error propagation
22
- - Manage complete operation lifecycle (VALIDATE, BEFORE, AFTER)
23
- """
24
-
25
- def __init__(self, registry):
26
- """
27
- Initialize the dispatcher.
28
-
29
- Args:
30
- registry: The hook registry (provides get_hooks method)
31
- """
32
- self.registry = registry
33
-
34
- def execute_operation_with_hooks(
35
- self,
36
- changeset,
37
- operation,
38
- event_prefix,
39
- bypass_hooks=False,
40
- bypass_validation=False,
41
- ):
42
- """
43
- Execute operation with full hook lifecycle.
44
-
45
- This is the high-level method that coordinates the complete lifecycle:
46
- 1. VALIDATE_{event}
47
- 2. BEFORE_{event}
48
- 3. Actual operation
49
- 4. AFTER_{event}
50
-
51
- Args:
52
- changeset: ChangeSet for the operation
53
- operation: Callable that performs the actual DB operation
54
- event_prefix: 'create', 'update', or 'delete'
55
- bypass_hooks: Skip all hooks if True
56
- bypass_validation: Skip validation hooks if True
57
-
58
- Returns:
59
- Result of operation
60
- """
61
- if bypass_hooks:
62
- return operation()
63
-
64
- # VALIDATE phase
65
- if not bypass_validation:
66
- self.dispatch(changeset, f"validate_{event_prefix}", bypass_hooks=False)
67
-
68
- # BEFORE phase
69
- self.dispatch(changeset, f"before_{event_prefix}", bypass_hooks=False)
70
-
71
- # Execute the actual operation
72
- result = operation()
73
-
74
- # AFTER phase - use result if operation returns modified data
75
- if result and isinstance(result, list) and event_prefix == "create":
76
- # For create, rebuild changeset with assigned PKs
77
- from django_bulk_hooks.helpers import build_changeset_for_create
78
-
79
- changeset = build_changeset_for_create(changeset.model_cls, result)
80
-
81
- self.dispatch(changeset, f"after_{event_prefix}", bypass_hooks=False)
82
-
83
- return result
84
-
85
- def dispatch(self, changeset, event, bypass_hooks=False):
86
- """
87
- Dispatch hooks for a changeset with deterministic ordering.
88
-
89
- This is the single execution path for ALL hooks in the system.
90
-
91
- Args:
92
- changeset: ChangeSet instance with record changes
93
- event: Event name (e.g., 'after_update', 'before_create')
94
- bypass_hooks: If True, skip all hook execution
95
-
96
- Raises:
97
- Exception: Any exception raised by a hook (fails fast)
98
- RecursionError: If hooks create an infinite loop (Python's built-in limit)
99
- """
100
- if bypass_hooks:
101
- return
102
-
103
- # Get hooks sorted by priority (deterministic order)
104
- hooks = self.registry.get_hooks(changeset.model_cls, event)
105
-
106
- if not hooks:
107
- return
108
-
109
- # Execute hooks in priority order
110
- logger.info(f"🔥 HOOKS: Executing {len(hooks)} hooks for {changeset.model_cls.__name__}.{event}")
111
- for handler_cls, method_name, condition, priority in hooks:
112
- logger.info(f" → {handler_cls.__name__}.{method_name} (priority={priority})")
113
- self._execute_hook(handler_cls, method_name, condition, changeset)
114
-
115
- def _execute_hook(self, handler_cls, method_name, condition, changeset):
116
- """
117
- Execute a single hook with condition checking.
118
-
119
- Args:
120
- handler_cls: The hook handler class
121
- method_name: Name of the method to call
122
- condition: Optional condition to filter records
123
- changeset: ChangeSet with all record changes
124
- """
125
- # Filter records based on condition
126
- if condition:
127
- filtered_changes = [
128
- change
129
- for change in changeset.changes
130
- if condition.check(change.new_record, change.old_record)
131
- ]
132
-
133
- if not filtered_changes:
134
- # No records match condition, skip this hook
135
- return
136
-
137
- # Create filtered changeset
138
- from django_bulk_hooks.changeset import ChangeSet
139
-
140
- filtered_changeset = ChangeSet(
141
- changeset.model_cls,
142
- filtered_changes,
143
- changeset.operation_type,
144
- changeset.operation_meta,
145
- )
146
- else:
147
- # No condition, use full changeset
148
- filtered_changeset = changeset
149
-
150
- # Use DI factory to create handler instance
151
- from django_bulk_hooks.factory import create_hook_instance
152
-
153
- handler = create_hook_instance(handler_cls)
154
- method = getattr(handler, method_name)
155
-
156
- # Check if method has @select_related decorator
157
- preload_func = getattr(method, "_select_related_preload", None)
158
- if preload_func:
159
- # Preload relationships to prevent N+1 queries
160
- try:
161
- model_cls_override = getattr(handler, "model_cls", None)
162
-
163
- # Get FK fields being updated to avoid preloading conflicting relationships
164
- skip_fields = changeset.operation_meta.get("fk_fields_being_updated", set())
165
-
166
- # Preload for new_records
167
- if filtered_changeset.new_records:
168
- logger.debug(
169
- f"Preloading relationships for {len(filtered_changeset.new_records)} "
170
- f"new_records for {handler_cls.__name__}.{method_name}",
171
- )
172
- preload_func(
173
- filtered_changeset.new_records,
174
- model_cls=model_cls_override,
175
- skip_fields=skip_fields,
176
- )
177
-
178
- # Also preload for old_records (for conditions that check previous values)
179
- if filtered_changeset.old_records:
180
- logger.debug(
181
- f"Preloading relationships for {len(filtered_changeset.old_records)} "
182
- f"old_records for {handler_cls.__name__}.{method_name}",
183
- )
184
- preload_func(
185
- filtered_changeset.old_records,
186
- model_cls=model_cls_override,
187
- skip_fields=skip_fields,
188
- )
189
- except Exception:
190
- logger.debug(
191
- "select_related preload failed for %s.%s",
192
- handler_cls.__name__,
193
- method_name,
194
- exc_info=True,
195
- )
196
-
197
- # Execute hook with ChangeSet
198
- #
199
- # ARCHITECTURE NOTE: Hook Contract
200
- # ====================================
201
- # All hooks must accept **kwargs for forward compatibility.
202
- # We pass: changeset, new_records, old_records
203
- #
204
- # Old hooks that don't use changeset: def hook(self, new_records, old_records, **kwargs)
205
- # New hooks that do use changeset: def hook(self, changeset, new_records, old_records, **kwargs)
206
- #
207
- # This is standard Python framework design (see Django signals, Flask hooks, etc.)
208
- logger.info(f" 🚀 Executing: {handler_cls.__name__}.{method_name}")
209
- try:
210
- method(
211
- changeset=filtered_changeset,
212
- new_records=filtered_changeset.new_records,
213
- old_records=filtered_changeset.old_records,
214
- )
215
- logger.info(f" ✅ Completed: {handler_cls.__name__}.{method_name}")
216
- except Exception as e:
217
- # Fail-fast: re-raise to rollback transaction
218
- logger.error(
219
- f"Hook {handler_cls.__name__}.{method_name} failed: {e}",
220
- exc_info=True,
221
- )
222
- raise
223
-
224
-
225
- # Global dispatcher instance
226
- _dispatcher: HookDispatcher | None = None
227
-
228
-
229
- def get_dispatcher():
230
- """
231
- Get the global dispatcher instance.
232
-
233
- Creates the dispatcher on first access (singleton pattern).
234
-
235
- Returns:
236
- HookDispatcher instance
237
- """
238
- global _dispatcher
239
- if _dispatcher is None:
240
- # Import here to avoid circular dependency
241
- from django_bulk_hooks.registry import get_registry
242
-
243
- # Create dispatcher with the registry instance
244
- _dispatcher = HookDispatcher(get_registry())
245
- return _dispatcher
246
-
247
-
248
- def reset_dispatcher():
249
- """
250
- Reset the global dispatcher instance.
251
-
252
- Useful for testing to ensure clean state between tests.
253
- """
254
- global _dispatcher
255
- _dispatcher = None
1
+ """
2
+ HookDispatcher: Single execution path for all hooks.
3
+
4
+ Provides deterministic, priority-ordered hook execution,
5
+ similar to Salesforce's hook framework.
6
+ """
7
+
8
+ import logging
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class HookDispatcher:
14
+ """
15
+ Single execution path for all hooks.
16
+
17
+ Responsibilities:
18
+ - Execute hooks in priority order
19
+ - Filter records based on conditions
20
+ - Provide ChangeSet context to hooks
21
+ - Fail-fast error propagation
22
+ - Manage complete operation lifecycle (VALIDATE, BEFORE, AFTER)
23
+ """
24
+
25
+ def __init__(self, registry):
26
+ """
27
+ Initialize the dispatcher.
28
+
29
+ Args:
30
+ registry: The hook registry (provides get_hooks method)
31
+ """
32
+ self.registry = registry
33
+
34
+ def execute_operation_with_hooks(
35
+ self,
36
+ changeset,
37
+ operation,
38
+ event_prefix,
39
+ bypass_hooks=False,
40
+ bypass_validation=False,
41
+ ):
42
+ """
43
+ Execute operation with full hook lifecycle.
44
+
45
+ This is the high-level method that coordinates the complete lifecycle:
46
+ 1. VALIDATE_{event}
47
+ 2. BEFORE_{event}
48
+ 3. Actual operation
49
+ 4. AFTER_{event}
50
+
51
+ Args:
52
+ changeset: ChangeSet for the operation
53
+ operation: Callable that performs the actual DB operation
54
+ event_prefix: 'create', 'update', or 'delete'
55
+ bypass_hooks: Skip all hooks if True
56
+ bypass_validation: Skip validation hooks if True
57
+
58
+ Returns:
59
+ Result of operation
60
+ """
61
+ if bypass_hooks:
62
+ return operation()
63
+
64
+ # VALIDATE phase
65
+ if not bypass_validation:
66
+ self.dispatch(changeset, f"validate_{event_prefix}", bypass_hooks=False)
67
+
68
+ # BEFORE phase
69
+ self.dispatch(changeset, f"before_{event_prefix}", bypass_hooks=False)
70
+
71
+ # Execute the actual operation
72
+ result = operation()
73
+
74
+ # AFTER phase - use result if operation returns modified data
75
+ if result and isinstance(result, list) and event_prefix == "create":
76
+ # For create, rebuild changeset with assigned PKs
77
+ from django_bulk_hooks.helpers import build_changeset_for_create
78
+
79
+ changeset = build_changeset_for_create(changeset.model_cls, result)
80
+
81
+ self.dispatch(changeset, f"after_{event_prefix}", bypass_hooks=False)
82
+
83
+ return result
84
+
85
+ def dispatch(self, changeset, event, bypass_hooks=False):
86
+ """
87
+ Dispatch hooks for a changeset with deterministic ordering.
88
+
89
+ This is the single execution path for ALL hooks in the system.
90
+
91
+ Args:
92
+ changeset: ChangeSet instance with record changes
93
+ event: Event name (e.g., 'after_update', 'before_create')
94
+ bypass_hooks: If True, skip all hook execution
95
+
96
+ Raises:
97
+ Exception: Any exception raised by a hook (fails fast)
98
+ RecursionError: If hooks create an infinite loop (Python's built-in limit)
99
+ """
100
+ if bypass_hooks:
101
+ return
102
+
103
+ # Get hooks sorted by priority (deterministic order)
104
+ hooks = self.registry.get_hooks(changeset.model_cls, event)
105
+
106
+ if not hooks:
107
+ return
108
+
109
+ # Execute hooks in priority order
110
+ logger.info(f"🔥 HOOKS: Executing {len(hooks)} hooks for {changeset.model_cls.__name__}.{event}")
111
+ for handler_cls, method_name, condition, priority in hooks:
112
+ logger.info(f" → {handler_cls.__name__}.{method_name} (priority={priority})")
113
+ self._execute_hook(handler_cls, method_name, condition, changeset)
114
+
115
+ def _execute_hook(self, handler_cls, method_name, condition, changeset):
116
+ """
117
+ Execute a single hook with condition checking.
118
+
119
+ Args:
120
+ handler_cls: The hook handler class
121
+ method_name: Name of the method to call
122
+ condition: Optional condition to filter records
123
+ changeset: ChangeSet with all record changes
124
+ """
125
+ # Filter records based on condition
126
+ if condition:
127
+ filtered_changes = [
128
+ change
129
+ for change in changeset.changes
130
+ if condition.check(change.new_record, change.old_record)
131
+ ]
132
+
133
+ if not filtered_changes:
134
+ # No records match condition, skip this hook
135
+ return
136
+
137
+ # Create filtered changeset
138
+ from django_bulk_hooks.changeset import ChangeSet
139
+
140
+ filtered_changeset = ChangeSet(
141
+ changeset.model_cls,
142
+ filtered_changes,
143
+ changeset.operation_type,
144
+ changeset.operation_meta,
145
+ )
146
+ else:
147
+ # No condition, use full changeset
148
+ filtered_changeset = changeset
149
+
150
+ # Use DI factory to create handler instance
151
+ from django_bulk_hooks.factory import create_hook_instance
152
+
153
+ handler = create_hook_instance(handler_cls)
154
+ method = getattr(handler, method_name)
155
+
156
+ # Check if method has @select_related decorator
157
+ preload_func = getattr(method, "_select_related_preload", None)
158
+ if preload_func:
159
+ # Preload relationships to prevent N+1 queries
160
+ try:
161
+ model_cls_override = getattr(handler, "model_cls", None)
162
+
163
+ # Get FK fields being updated to avoid preloading conflicting relationships
164
+ skip_fields = changeset.operation_meta.get("fk_fields_being_updated", set())
165
+
166
+ # Preload for new_records
167
+ if filtered_changeset.new_records:
168
+ logger.debug(
169
+ f"Preloading relationships for {len(filtered_changeset.new_records)} "
170
+ f"new_records for {handler_cls.__name__}.{method_name}",
171
+ )
172
+ preload_func(
173
+ filtered_changeset.new_records,
174
+ model_cls=model_cls_override,
175
+ skip_fields=skip_fields,
176
+ )
177
+
178
+ # Also preload for old_records (for conditions that check previous values)
179
+ if filtered_changeset.old_records:
180
+ logger.debug(
181
+ f"Preloading relationships for {len(filtered_changeset.old_records)} "
182
+ f"old_records for {handler_cls.__name__}.{method_name}",
183
+ )
184
+ preload_func(
185
+ filtered_changeset.old_records,
186
+ model_cls=model_cls_override,
187
+ skip_fields=skip_fields,
188
+ )
189
+ except Exception:
190
+ logger.debug(
191
+ "select_related preload failed for %s.%s",
192
+ handler_cls.__name__,
193
+ method_name,
194
+ exc_info=True,
195
+ )
196
+
197
+ # Execute hook with ChangeSet
198
+ #
199
+ # ARCHITECTURE NOTE: Hook Contract
200
+ # ====================================
201
+ # All hooks must accept **kwargs for forward compatibility.
202
+ # We pass: changeset, new_records, old_records
203
+ #
204
+ # Old hooks that don't use changeset: def hook(self, new_records, old_records, **kwargs)
205
+ # New hooks that do use changeset: def hook(self, changeset, new_records, old_records, **kwargs)
206
+ #
207
+ # This is standard Python framework design (see Django signals, Flask hooks, etc.)
208
+ logger.info(f" 🚀 Executing: {handler_cls.__name__}.{method_name}")
209
+ try:
210
+ method(
211
+ changeset=filtered_changeset,
212
+ new_records=filtered_changeset.new_records,
213
+ old_records=filtered_changeset.old_records,
214
+ )
215
+ logger.info(f" ✅ Completed: {handler_cls.__name__}.{method_name}")
216
+ except Exception as e:
217
+ # Fail-fast: re-raise to rollback transaction
218
+ logger.error(
219
+ f"Hook {handler_cls.__name__}.{method_name} failed: {e}",
220
+ exc_info=True,
221
+ )
222
+ raise
223
+
224
+
225
+ # Global dispatcher instance
226
+ _dispatcher: HookDispatcher | None = None
227
+
228
+
229
+ def get_dispatcher():
230
+ """
231
+ Get the global dispatcher instance.
232
+
233
+ Creates the dispatcher on first access (singleton pattern).
234
+
235
+ Returns:
236
+ HookDispatcher instance
237
+ """
238
+ global _dispatcher
239
+ if _dispatcher is None:
240
+ # Import here to avoid circular dependency
241
+ from django_bulk_hooks.registry import get_registry
242
+
243
+ # Create dispatcher with the registry instance
244
+ _dispatcher = HookDispatcher(get_registry())
245
+ return _dispatcher
246
+
247
+
248
+ def reset_dispatcher():
249
+ """
250
+ Reset the global dispatcher instance.
251
+
252
+ Useful for testing to ensure clean state between tests.
253
+ """
254
+ global _dispatcher
255
+ _dispatcher = None