django-bulk-hooks 0.2.16__py3-none-any.whl → 0.2.17__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.
- django_bulk_hooks/__init__.py +20 -24
- django_bulk_hooks/changeset.py +1 -1
- django_bulk_hooks/conditions.py +8 -12
- django_bulk_hooks/decorators.py +16 -18
- django_bulk_hooks/dispatcher.py +9 -10
- django_bulk_hooks/factory.py +36 -38
- django_bulk_hooks/handler.py +5 -6
- django_bulk_hooks/helpers.py +4 -3
- django_bulk_hooks/models.py +12 -13
- django_bulk_hooks/operations/__init__.py +5 -5
- django_bulk_hooks/operations/analyzer.py +14 -14
- django_bulk_hooks/operations/bulk_executor.py +79 -71
- django_bulk_hooks/operations/coordinator.py +61 -61
- django_bulk_hooks/operations/mti_handler.py +67 -65
- django_bulk_hooks/operations/mti_plans.py +17 -16
- django_bulk_hooks/operations/record_classifier.py +22 -21
- django_bulk_hooks/queryset.py +5 -3
- django_bulk_hooks/registry.py +40 -45
- {django_bulk_hooks-0.2.16.dist-info → django_bulk_hooks-0.2.17.dist-info}/METADATA +1 -1
- django_bulk_hooks-0.2.17.dist-info/RECORD +26 -0
- django_bulk_hooks-0.2.16.dist-info/RECORD +0 -26
- {django_bulk_hooks-0.2.16.dist-info → django_bulk_hooks-0.2.17.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.16.dist-info → django_bulk_hooks-0.2.17.dist-info}/WHEEL +0 -0
django_bulk_hooks/__init__.py
CHANGED
|
@@ -1,33 +1,29 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
|
|
3
|
+
from django_bulk_hooks.changeset import ChangeSet
|
|
4
|
+
from django_bulk_hooks.changeset import RecordChange
|
|
5
|
+
from django_bulk_hooks.constants import DEFAULT_BULK_UPDATE_BATCH_SIZE
|
|
6
|
+
from django_bulk_hooks.dispatcher import HookDispatcher
|
|
7
|
+
from django_bulk_hooks.dispatcher import get_dispatcher
|
|
8
|
+
from django_bulk_hooks.factory import clear_hook_factories
|
|
9
|
+
from django_bulk_hooks.factory import configure_hook_container
|
|
10
|
+
from django_bulk_hooks.factory import configure_nested_container
|
|
11
|
+
from django_bulk_hooks.factory import create_hook_instance
|
|
12
|
+
from django_bulk_hooks.factory import is_container_configured
|
|
13
|
+
from django_bulk_hooks.factory import set_default_hook_factory
|
|
14
|
+
from django_bulk_hooks.factory import set_hook_factory
|
|
3
15
|
from django_bulk_hooks.handler import Hook as HookClass
|
|
16
|
+
from django_bulk_hooks.helpers import build_changeset_for_create
|
|
17
|
+
from django_bulk_hooks.helpers import build_changeset_for_delete
|
|
18
|
+
from django_bulk_hooks.helpers import build_changeset_for_update
|
|
19
|
+
from django_bulk_hooks.helpers import dispatch_hooks_for_operation
|
|
4
20
|
from django_bulk_hooks.manager import BulkHookManager
|
|
5
|
-
from django_bulk_hooks.
|
|
6
|
-
set_hook_factory,
|
|
7
|
-
set_default_hook_factory,
|
|
8
|
-
configure_hook_container,
|
|
9
|
-
configure_nested_container,
|
|
10
|
-
clear_hook_factories,
|
|
11
|
-
create_hook_instance,
|
|
12
|
-
is_container_configured,
|
|
13
|
-
)
|
|
14
|
-
from django_bulk_hooks.constants import DEFAULT_BULK_UPDATE_BATCH_SIZE
|
|
15
|
-
from django_bulk_hooks.changeset import ChangeSet, RecordChange
|
|
16
|
-
from django_bulk_hooks.dispatcher import get_dispatcher, HookDispatcher
|
|
17
|
-
from django_bulk_hooks.helpers import (
|
|
18
|
-
build_changeset_for_create,
|
|
19
|
-
build_changeset_for_update,
|
|
20
|
-
build_changeset_for_delete,
|
|
21
|
-
dispatch_hooks_for_operation,
|
|
22
|
-
)
|
|
21
|
+
from django_bulk_hooks.operations import BulkExecutor
|
|
23
22
|
|
|
24
23
|
# Service layer (NEW architecture)
|
|
25
|
-
from django_bulk_hooks.operations import
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
BulkExecutor,
|
|
29
|
-
MTIHandler,
|
|
30
|
-
)
|
|
24
|
+
from django_bulk_hooks.operations import BulkOperationCoordinator
|
|
25
|
+
from django_bulk_hooks.operations import ModelAnalyzer
|
|
26
|
+
from django_bulk_hooks.operations import MTIHandler
|
|
31
27
|
|
|
32
28
|
# Add NullHandler to prevent logging messages if the application doesn't configure logging
|
|
33
29
|
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
django_bulk_hooks/changeset.py
CHANGED
|
@@ -226,5 +226,5 @@ class ChangeSet:
|
|
|
226
226
|
for i in range(0, len(self.changes), chunk_size):
|
|
227
227
|
chunk_changes = self.changes[i : i + chunk_size]
|
|
228
228
|
yield ChangeSet(
|
|
229
|
-
self.model_cls, chunk_changes, self.operation_type, self.operation_meta
|
|
229
|
+
self.model_cls, chunk_changes, self.operation_type, self.operation_meta,
|
|
230
230
|
)
|
django_bulk_hooks/conditions.py
CHANGED
|
@@ -19,9 +19,8 @@ def resolve_field_path(instance, field_path):
|
|
|
19
19
|
# For foreign key fields, use attname to get the ID directly
|
|
20
20
|
# This avoids hooking Django's descriptor protocol
|
|
21
21
|
return getattr(instance, field.attname, None)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return getattr(instance, field_path, None)
|
|
22
|
+
# For regular fields, use normal getattr
|
|
23
|
+
return getattr(instance, field_path, None)
|
|
25
24
|
except Exception:
|
|
26
25
|
# If field lookup fails, fall back to normal getattr
|
|
27
26
|
return getattr(instance, field_path, None)
|
|
@@ -41,7 +40,7 @@ def resolve_field_path(instance, field_path):
|
|
|
41
40
|
if field.is_relation and not field.many_to_many:
|
|
42
41
|
# Use attname for the final FK field access
|
|
43
42
|
current_instance = getattr(
|
|
44
|
-
current_instance, field.attname, None
|
|
43
|
+
current_instance, field.attname, None,
|
|
45
44
|
)
|
|
46
45
|
continue
|
|
47
46
|
except:
|
|
@@ -85,8 +84,7 @@ class IsNotEqual(HookCondition):
|
|
|
85
84
|
return False
|
|
86
85
|
previous = resolve_field_path(original_instance, self.field)
|
|
87
86
|
return previous == self.value and current != self.value
|
|
88
|
-
|
|
89
|
-
return current != self.value
|
|
87
|
+
return current != self.value
|
|
90
88
|
|
|
91
89
|
|
|
92
90
|
class IsEqual(HookCondition):
|
|
@@ -103,8 +101,7 @@ class IsEqual(HookCondition):
|
|
|
103
101
|
return False
|
|
104
102
|
previous = resolve_field_path(original_instance, self.field)
|
|
105
103
|
return previous != self.value and current == self.value
|
|
106
|
-
|
|
107
|
-
return current == self.value
|
|
104
|
+
return current == self.value
|
|
108
105
|
|
|
109
106
|
|
|
110
107
|
class HasChanged(HookCondition):
|
|
@@ -139,8 +136,7 @@ class WasEqual(HookCondition):
|
|
|
139
136
|
if self.only_on_change:
|
|
140
137
|
current = resolve_field_path(instance, self.field)
|
|
141
138
|
return previous == self.value and current != self.value
|
|
142
|
-
|
|
143
|
-
return previous == self.value
|
|
139
|
+
return previous == self.value
|
|
144
140
|
|
|
145
141
|
|
|
146
142
|
class ChangesTo(HookCondition):
|
|
@@ -207,7 +203,7 @@ class AndCondition(HookCondition):
|
|
|
207
203
|
|
|
208
204
|
def check(self, instance, original_instance=None):
|
|
209
205
|
return self.cond1.check(instance, original_instance) and self.cond2.check(
|
|
210
|
-
instance, original_instance
|
|
206
|
+
instance, original_instance,
|
|
211
207
|
)
|
|
212
208
|
|
|
213
209
|
|
|
@@ -218,7 +214,7 @@ class OrCondition(HookCondition):
|
|
|
218
214
|
|
|
219
215
|
def check(self, instance, original_instance=None):
|
|
220
216
|
return self.cond1.check(instance, original_instance) or self.cond2.check(
|
|
221
|
-
instance, original_instance
|
|
217
|
+
instance, original_instance,
|
|
222
218
|
)
|
|
223
219
|
|
|
224
220
|
|
django_bulk_hooks/decorators.py
CHANGED
|
@@ -41,7 +41,7 @@ def select_related(*related_fields):
|
|
|
41
41
|
def preload_related(records, *, model_cls=None, skip_fields=None):
|
|
42
42
|
if not isinstance(records, list):
|
|
43
43
|
raise TypeError(
|
|
44
|
-
f"@select_related expects a list of model instances, got {type(records)}"
|
|
44
|
+
f"@select_related expects a list of model instances, got {type(records)}",
|
|
45
45
|
)
|
|
46
46
|
|
|
47
47
|
if not records:
|
|
@@ -57,7 +57,7 @@ def select_related(*related_fields):
|
|
|
57
57
|
for field in related_fields:
|
|
58
58
|
if "." in field:
|
|
59
59
|
raise ValueError(
|
|
60
|
-
f"Invalid field notation '{field}'. Use Django ORM __ notation (e.g., 'parent__field')"
|
|
60
|
+
f"Invalid field notation '{field}'. Use Django ORM __ notation (e.g., 'parent__field')",
|
|
61
61
|
)
|
|
62
62
|
|
|
63
63
|
direct_relation_fields = {}
|
|
@@ -85,7 +85,7 @@ def select_related(*related_fields):
|
|
|
85
85
|
direct_relation_fields[field] = relation_field
|
|
86
86
|
|
|
87
87
|
unsaved_related_ids_by_field = {
|
|
88
|
-
field: set() for field in direct_relation_fields
|
|
88
|
+
field: set() for field in direct_relation_fields
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
saved_ids_to_fetch = []
|
|
@@ -129,13 +129,13 @@ def select_related(*related_fields):
|
|
|
129
129
|
if base_manager is not None:
|
|
130
130
|
try:
|
|
131
131
|
fetched_saved = base_manager.select_related(
|
|
132
|
-
*validated_fields
|
|
132
|
+
*validated_fields,
|
|
133
133
|
).in_bulk(saved_ids_to_fetch)
|
|
134
134
|
except Exception:
|
|
135
135
|
fetched_saved = {}
|
|
136
136
|
|
|
137
137
|
fetched_unsaved_by_field = {
|
|
138
|
-
field: {} for field in direct_relation_fields
|
|
138
|
+
field: {} for field in direct_relation_fields
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
for field_name, relation_field in direct_relation_fields.items():
|
|
@@ -225,14 +225,14 @@ def select_related(*related_fields):
|
|
|
225
225
|
|
|
226
226
|
if "new_records" not in bound.arguments:
|
|
227
227
|
raise TypeError(
|
|
228
|
-
"@preload_related requires a 'new_records' argument in the decorated function"
|
|
228
|
+
"@preload_related requires a 'new_records' argument in the decorated function",
|
|
229
229
|
)
|
|
230
230
|
|
|
231
231
|
new_records = bound.arguments["new_records"]
|
|
232
232
|
|
|
233
233
|
if not isinstance(new_records, list):
|
|
234
234
|
raise TypeError(
|
|
235
|
-
f"@select_related expects a list of model instances, got {type(new_records)}"
|
|
235
|
+
f"@select_related expects a list of model instances, got {type(new_records)}",
|
|
236
236
|
)
|
|
237
237
|
|
|
238
238
|
if not new_records:
|
|
@@ -243,7 +243,7 @@ def select_related(*related_fields):
|
|
|
243
243
|
for field in related_fields:
|
|
244
244
|
if "." in field:
|
|
245
245
|
raise ValueError(
|
|
246
|
-
f"Invalid field notation '{field}'. Use Django ORM __ notation (e.g., 'parent__field')"
|
|
246
|
+
f"Invalid field notation '{field}'. Use Django ORM __ notation (e.g., 'parent__field')",
|
|
247
247
|
)
|
|
248
248
|
|
|
249
249
|
# Don't preload here - let the dispatcher handle it
|
|
@@ -285,18 +285,16 @@ def bulk_hook(model_cls, event, when=None, priority=None):
|
|
|
285
285
|
sig = inspect.signature(func)
|
|
286
286
|
params = list(sig.parameters.keys())
|
|
287
287
|
|
|
288
|
-
if
|
|
288
|
+
if "changeset" in params:
|
|
289
289
|
# New signature with changeset
|
|
290
290
|
return self.func(changeset, new_records, old_records, **kwargs)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
# Function doesn't accept **kwargs, just call with positional args
|
|
299
|
-
return self.func(new_records, old_records)
|
|
291
|
+
# Old signature without changeset
|
|
292
|
+
# Only pass changeset in kwargs if the function accepts **kwargs
|
|
293
|
+
if "kwargs" in params or any(param.startswith("**") for param in sig.parameters):
|
|
294
|
+
kwargs["changeset"] = changeset
|
|
295
|
+
return self.func(new_records, old_records, **kwargs)
|
|
296
|
+
# Function doesn't accept **kwargs, just call with positional args
|
|
297
|
+
return self.func(new_records, old_records)
|
|
300
298
|
|
|
301
299
|
# Register the hook using the registry
|
|
302
300
|
register_hook(
|
django_bulk_hooks/dispatcher.py
CHANGED
|
@@ -6,7 +6,6 @@ similar to Salesforce's hook framework.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
|
-
from typing import Optional
|
|
10
9
|
|
|
11
10
|
logger = logging.getLogger(__name__)
|
|
12
11
|
|
|
@@ -162,30 +161,30 @@ class HookDispatcher:
|
|
|
162
161
|
model_cls_override = getattr(handler, "model_cls", None)
|
|
163
162
|
|
|
164
163
|
# Get FK fields being updated to avoid preloading conflicting relationships
|
|
165
|
-
skip_fields = changeset.operation_meta.get(
|
|
164
|
+
skip_fields = changeset.operation_meta.get("fk_fields_being_updated", set())
|
|
166
165
|
|
|
167
166
|
# Preload for new_records
|
|
168
167
|
if filtered_changeset.new_records:
|
|
169
168
|
logger.debug(
|
|
170
169
|
f"Preloading relationships for {len(filtered_changeset.new_records)} "
|
|
171
|
-
f"new_records for {handler_cls.__name__}.{method_name}"
|
|
170
|
+
f"new_records for {handler_cls.__name__}.{method_name}",
|
|
172
171
|
)
|
|
173
172
|
preload_func(
|
|
174
173
|
filtered_changeset.new_records,
|
|
175
174
|
model_cls=model_cls_override,
|
|
176
|
-
skip_fields=skip_fields
|
|
175
|
+
skip_fields=skip_fields,
|
|
177
176
|
)
|
|
178
177
|
|
|
179
178
|
# Also preload for old_records (for conditions that check previous values)
|
|
180
179
|
if filtered_changeset.old_records:
|
|
181
180
|
logger.debug(
|
|
182
181
|
f"Preloading relationships for {len(filtered_changeset.old_records)} "
|
|
183
|
-
f"old_records for {handler_cls.__name__}.{method_name}"
|
|
182
|
+
f"old_records for {handler_cls.__name__}.{method_name}",
|
|
184
183
|
)
|
|
185
184
|
preload_func(
|
|
186
185
|
filtered_changeset.old_records,
|
|
187
186
|
model_cls=model_cls_override,
|
|
188
|
-
skip_fields=skip_fields
|
|
187
|
+
skip_fields=skip_fields,
|
|
189
188
|
)
|
|
190
189
|
except Exception:
|
|
191
190
|
logger.debug(
|
|
@@ -196,15 +195,15 @@ class HookDispatcher:
|
|
|
196
195
|
)
|
|
197
196
|
|
|
198
197
|
# Execute hook with ChangeSet
|
|
199
|
-
#
|
|
198
|
+
#
|
|
200
199
|
# ARCHITECTURE NOTE: Hook Contract
|
|
201
200
|
# ====================================
|
|
202
201
|
# All hooks must accept **kwargs for forward compatibility.
|
|
203
202
|
# We pass: changeset, new_records, old_records
|
|
204
|
-
#
|
|
203
|
+
#
|
|
205
204
|
# Old hooks that don't use changeset: def hook(self, new_records, old_records, **kwargs)
|
|
206
205
|
# New hooks that do use changeset: def hook(self, changeset, new_records, old_records, **kwargs)
|
|
207
|
-
#
|
|
206
|
+
#
|
|
208
207
|
# This is standard Python framework design (see Django signals, Flask hooks, etc.)
|
|
209
208
|
logger.info(f" 🚀 Executing: {handler_cls.__name__}.{method_name}")
|
|
210
209
|
try:
|
|
@@ -224,7 +223,7 @@ class HookDispatcher:
|
|
|
224
223
|
|
|
225
224
|
|
|
226
225
|
# Global dispatcher instance
|
|
227
|
-
_dispatcher:
|
|
226
|
+
_dispatcher: HookDispatcher | None = None
|
|
228
227
|
|
|
229
228
|
|
|
230
229
|
def get_dispatcher():
|
django_bulk_hooks/factory.py
CHANGED
|
@@ -52,7 +52,8 @@ Usage Pattern 3 - Custom Resolver:
|
|
|
52
52
|
import logging
|
|
53
53
|
import re
|
|
54
54
|
import threading
|
|
55
|
-
from
|
|
55
|
+
from collections.abc import Callable
|
|
56
|
+
from typing import Any
|
|
56
57
|
|
|
57
58
|
logger = logging.getLogger(__name__)
|
|
58
59
|
|
|
@@ -69,11 +70,11 @@ class HookFactory:
|
|
|
69
70
|
|
|
70
71
|
def __init__(self):
|
|
71
72
|
"""Initialize an empty factory."""
|
|
72
|
-
self._specific_factories: dict[
|
|
73
|
-
self._container_resolver:
|
|
73
|
+
self._specific_factories: dict[type, Callable[[], Any]] = {}
|
|
74
|
+
self._container_resolver: Callable[[type], Any] | None = None
|
|
74
75
|
self._lock = threading.RLock()
|
|
75
76
|
|
|
76
|
-
def register_factory(self, hook_cls:
|
|
77
|
+
def register_factory(self, hook_cls: type, factory: Callable[[], Any]) -> None:
|
|
77
78
|
"""
|
|
78
79
|
Register a factory function for a specific hook class.
|
|
79
80
|
|
|
@@ -98,8 +99,8 @@ class HookFactory:
|
|
|
98
99
|
def configure_container(
|
|
99
100
|
self,
|
|
100
101
|
container: Any,
|
|
101
|
-
provider_name_resolver:
|
|
102
|
-
provider_resolver:
|
|
102
|
+
provider_name_resolver: Callable[[type], str] | None = None,
|
|
103
|
+
provider_resolver: Callable[[Any, type, str], Any] | None = None,
|
|
103
104
|
fallback_to_direct: bool = True,
|
|
104
105
|
) -> None:
|
|
105
106
|
"""
|
|
@@ -142,7 +143,7 @@ class HookFactory:
|
|
|
142
143
|
"""
|
|
143
144
|
name_resolver = provider_name_resolver or self._default_name_resolver
|
|
144
145
|
|
|
145
|
-
def resolver(hook_cls:
|
|
146
|
+
def resolver(hook_cls: type) -> Any:
|
|
146
147
|
"""Resolve hook instance from the container."""
|
|
147
148
|
provider_name = name_resolver(hook_cls)
|
|
148
149
|
name = getattr(hook_cls, "__name__", str(hook_cls))
|
|
@@ -155,8 +156,7 @@ class HookFactory:
|
|
|
155
156
|
except Exception as e:
|
|
156
157
|
if fallback_to_direct:
|
|
157
158
|
logger.debug(
|
|
158
|
-
f"Custom provider resolver failed for {name} ({e}), "
|
|
159
|
-
f"falling back to direct instantiation"
|
|
159
|
+
f"Custom provider resolver failed for {name} ({e}), falling back to direct instantiation",
|
|
160
160
|
)
|
|
161
161
|
return hook_cls()
|
|
162
162
|
raise
|
|
@@ -165,34 +165,35 @@ class HookFactory:
|
|
|
165
165
|
if hasattr(container, provider_name):
|
|
166
166
|
provider = getattr(container, provider_name)
|
|
167
167
|
logger.debug(
|
|
168
|
-
f"Resolving {name} from container provider '{provider_name}'"
|
|
168
|
+
f"Resolving {name} from container provider '{provider_name}'",
|
|
169
169
|
)
|
|
170
170
|
# Call the provider to get the instance
|
|
171
171
|
return provider()
|
|
172
172
|
|
|
173
173
|
if fallback_to_direct:
|
|
174
174
|
logger.debug(
|
|
175
|
-
f"Provider '{provider_name}' not found in container for {name}, "
|
|
176
|
-
f"falling back to direct instantiation"
|
|
175
|
+
f"Provider '{provider_name}' not found in container for {name}, falling back to direct instantiation",
|
|
177
176
|
)
|
|
178
177
|
return hook_cls()
|
|
179
178
|
|
|
180
179
|
raise ValueError(
|
|
181
180
|
f"Hook {name} not found in container. "
|
|
182
181
|
f"Expected provider name: '{provider_name}'. "
|
|
183
|
-
f"Available providers: {[p for p in dir(container) if not p.startswith('_')]}"
|
|
182
|
+
f"Available providers: {[p for p in dir(container) if not p.startswith('_')]}",
|
|
184
183
|
)
|
|
185
184
|
|
|
186
185
|
with self._lock:
|
|
187
186
|
self._container_resolver = resolver
|
|
188
187
|
container_name = getattr(
|
|
189
|
-
container.__class__,
|
|
188
|
+
container.__class__,
|
|
189
|
+
"__name__",
|
|
190
|
+
str(container.__class__),
|
|
190
191
|
)
|
|
191
192
|
logger.info(
|
|
192
|
-
f"Configured hook factory to use container: {container_name}"
|
|
193
|
+
f"Configured hook factory to use container: {container_name}",
|
|
193
194
|
)
|
|
194
195
|
|
|
195
|
-
def create(self, hook_cls:
|
|
196
|
+
def create(self, hook_cls: type) -> Any:
|
|
196
197
|
"""
|
|
197
198
|
Create a hook instance using the configured resolution strategy.
|
|
198
199
|
|
|
@@ -249,7 +250,7 @@ class HookFactory:
|
|
|
249
250
|
with self._lock:
|
|
250
251
|
return self._container_resolver is not None
|
|
251
252
|
|
|
252
|
-
def has_factory(self, hook_cls:
|
|
253
|
+
def has_factory(self, hook_cls: type) -> bool:
|
|
253
254
|
"""
|
|
254
255
|
Check if a hook class has a registered factory.
|
|
255
256
|
|
|
@@ -262,7 +263,7 @@ class HookFactory:
|
|
|
262
263
|
with self._lock:
|
|
263
264
|
return hook_cls in self._specific_factories
|
|
264
265
|
|
|
265
|
-
def get_factory(self, hook_cls:
|
|
266
|
+
def get_factory(self, hook_cls: type) -> Callable[[], Any] | None:
|
|
266
267
|
"""
|
|
267
268
|
Get the registered factory for a specific hook class.
|
|
268
269
|
|
|
@@ -275,7 +276,7 @@ class HookFactory:
|
|
|
275
276
|
with self._lock:
|
|
276
277
|
return self._specific_factories.get(hook_cls)
|
|
277
278
|
|
|
278
|
-
def list_factories(self) -> dict[
|
|
279
|
+
def list_factories(self) -> dict[type, Callable]:
|
|
279
280
|
"""
|
|
280
281
|
Get a copy of all registered hook factories.
|
|
281
282
|
|
|
@@ -286,7 +287,7 @@ class HookFactory:
|
|
|
286
287
|
return self._specific_factories.copy()
|
|
287
288
|
|
|
288
289
|
@staticmethod
|
|
289
|
-
def _default_name_resolver(hook_cls:
|
|
290
|
+
def _default_name_resolver(hook_cls: type) -> str:
|
|
290
291
|
"""
|
|
291
292
|
Default naming convention: LoanAccountHook -> loan_account_hook
|
|
292
293
|
|
|
@@ -303,7 +304,7 @@ class HookFactory:
|
|
|
303
304
|
|
|
304
305
|
|
|
305
306
|
# Global singleton factory
|
|
306
|
-
_factory:
|
|
307
|
+
_factory: HookFactory | None = None
|
|
307
308
|
_factory_lock = threading.Lock()
|
|
308
309
|
|
|
309
310
|
|
|
@@ -329,7 +330,7 @@ def get_factory() -> HookFactory:
|
|
|
329
330
|
|
|
330
331
|
|
|
331
332
|
# Backward-compatible module-level functions
|
|
332
|
-
def set_hook_factory(hook_cls:
|
|
333
|
+
def set_hook_factory(hook_cls: type, factory: Callable[[], Any]) -> None:
|
|
333
334
|
"""
|
|
334
335
|
Register a factory function for a specific hook class.
|
|
335
336
|
|
|
@@ -350,7 +351,7 @@ def set_hook_factory(hook_cls: Type, factory: Callable[[], Any]) -> None:
|
|
|
350
351
|
hook_factory.register_factory(hook_cls, factory)
|
|
351
352
|
|
|
352
353
|
|
|
353
|
-
def set_default_hook_factory(factory: Callable[[
|
|
354
|
+
def set_default_hook_factory(factory: Callable[[type], Any]) -> None:
|
|
354
355
|
"""
|
|
355
356
|
DEPRECATED: Use configure_hook_container with provider_resolver instead.
|
|
356
357
|
|
|
@@ -363,8 +364,7 @@ def set_default_hook_factory(factory: Callable[[Type], Any]) -> None:
|
|
|
363
364
|
import warnings
|
|
364
365
|
|
|
365
366
|
warnings.warn(
|
|
366
|
-
"set_default_hook_factory is deprecated. "
|
|
367
|
-
"Use configure_hook_container with provider_resolver instead.",
|
|
367
|
+
"set_default_hook_factory is deprecated. Use configure_hook_container with provider_resolver instead.",
|
|
368
368
|
DeprecationWarning,
|
|
369
369
|
stacklevel=2,
|
|
370
370
|
)
|
|
@@ -379,8 +379,8 @@ def set_default_hook_factory(factory: Callable[[Type], Any]) -> None:
|
|
|
379
379
|
|
|
380
380
|
def configure_hook_container(
|
|
381
381
|
container: Any,
|
|
382
|
-
provider_name_resolver:
|
|
383
|
-
provider_resolver:
|
|
382
|
+
provider_name_resolver: Callable[[type], str] | None = None,
|
|
383
|
+
provider_resolver: Callable[[Any, type, str], Any] | None = None,
|
|
384
384
|
fallback_to_direct: bool = True,
|
|
385
385
|
) -> None:
|
|
386
386
|
"""
|
|
@@ -415,7 +415,7 @@ def configure_hook_container(
|
|
|
415
415
|
def configure_nested_container(
|
|
416
416
|
container: Any,
|
|
417
417
|
container_path: str,
|
|
418
|
-
provider_name_resolver:
|
|
418
|
+
provider_name_resolver: Callable[[type], str] | None = None,
|
|
419
419
|
fallback_to_direct: bool = True,
|
|
420
420
|
) -> None:
|
|
421
421
|
"""
|
|
@@ -444,8 +444,7 @@ def configure_nested_container(
|
|
|
444
444
|
import warnings
|
|
445
445
|
|
|
446
446
|
warnings.warn(
|
|
447
|
-
"configure_nested_container is deprecated. "
|
|
448
|
-
"Use configure_hook_container with provider_resolver instead.",
|
|
447
|
+
"configure_nested_container is deprecated. Use configure_hook_container with provider_resolver instead.",
|
|
449
448
|
DeprecationWarning,
|
|
450
449
|
stacklevel=2,
|
|
451
450
|
)
|
|
@@ -457,7 +456,7 @@ def configure_nested_container(
|
|
|
457
456
|
for part in container_path.split("."):
|
|
458
457
|
if not hasattr(current, part):
|
|
459
458
|
raise ValueError(
|
|
460
|
-
f"Container path '{container_path}' not found. Missing: {part}"
|
|
459
|
+
f"Container path '{container_path}' not found. Missing: {part}",
|
|
461
460
|
)
|
|
462
461
|
provider = getattr(current, part)
|
|
463
462
|
# Call provider to get next level
|
|
@@ -466,13 +465,12 @@ def configure_nested_container(
|
|
|
466
465
|
# Get the hook provider from sub-container
|
|
467
466
|
if not hasattr(current, provider_name):
|
|
468
467
|
raise ValueError(
|
|
469
|
-
f"Provider '{provider_name}' not found in sub-container. "
|
|
470
|
-
f"Available: {[p for p in dir(current) if not p.startswith('_')]}"
|
|
468
|
+
f"Provider '{provider_name}' not found in sub-container. Available: {[p for p in dir(current) if not p.startswith('_')]}",
|
|
471
469
|
)
|
|
472
470
|
|
|
473
471
|
hook_provider = getattr(current, provider_name)
|
|
474
472
|
logger.debug(
|
|
475
|
-
f"Resolved {hook_cls.__name__} from {container_path}.{provider_name}"
|
|
473
|
+
f"Resolved {hook_cls.__name__} from {container_path}.{provider_name}",
|
|
476
474
|
)
|
|
477
475
|
return hook_provider()
|
|
478
476
|
|
|
@@ -493,7 +491,7 @@ def clear_hook_factories() -> None:
|
|
|
493
491
|
hook_factory.clear()
|
|
494
492
|
|
|
495
493
|
|
|
496
|
-
def create_hook_instance(hook_cls:
|
|
494
|
+
def create_hook_instance(hook_cls: type) -> Any:
|
|
497
495
|
"""
|
|
498
496
|
Create a hook instance using the configured resolution strategy.
|
|
499
497
|
|
|
@@ -515,7 +513,7 @@ def create_hook_instance(hook_cls: Type) -> Any:
|
|
|
515
513
|
return hook_factory.create(hook_cls)
|
|
516
514
|
|
|
517
515
|
|
|
518
|
-
def get_hook_factory(hook_cls:
|
|
516
|
+
def get_hook_factory(hook_cls: type) -> Callable[[], Any] | None:
|
|
519
517
|
"""
|
|
520
518
|
Get the registered factory for a specific hook class.
|
|
521
519
|
|
|
@@ -529,7 +527,7 @@ def get_hook_factory(hook_cls: Type) -> Optional[Callable[[], Any]]:
|
|
|
529
527
|
return hook_factory.get_factory(hook_cls)
|
|
530
528
|
|
|
531
529
|
|
|
532
|
-
def has_hook_factory(hook_cls:
|
|
530
|
+
def has_hook_factory(hook_cls: type) -> bool:
|
|
533
531
|
"""
|
|
534
532
|
Check if a hook class has a registered factory.
|
|
535
533
|
|
|
@@ -554,7 +552,7 @@ def is_container_configured() -> bool:
|
|
|
554
552
|
return hook_factory.is_container_configured()
|
|
555
553
|
|
|
556
554
|
|
|
557
|
-
def list_registered_factories() -> dict[
|
|
555
|
+
def list_registered_factories() -> dict[type, Callable]:
|
|
558
556
|
"""
|
|
559
557
|
Get a copy of all registered hook factories.
|
|
560
558
|
|
django_bulk_hooks/handler.py
CHANGED
|
@@ -8,7 +8,7 @@ logger = logging.getLogger(__name__)
|
|
|
8
8
|
class HookMeta(type):
|
|
9
9
|
_registered = set()
|
|
10
10
|
_class_hook_map: dict[
|
|
11
|
-
type, set[tuple]
|
|
11
|
+
type, set[tuple],
|
|
12
12
|
] = {} # Track which hooks belong to which class
|
|
13
13
|
|
|
14
14
|
def __new__(mcs, name, bases, namespace):
|
|
@@ -25,7 +25,7 @@ class HookMeta(type):
|
|
|
25
25
|
- Child overrides replace parent implementations (not add to them)
|
|
26
26
|
- Child can add new hook methods
|
|
27
27
|
"""
|
|
28
|
-
from django_bulk_hooks.registry import
|
|
28
|
+
from django_bulk_hooks.registry import unregister_hook
|
|
29
29
|
|
|
30
30
|
# Step 1: Unregister ALL hooks from parent classes in the MRO
|
|
31
31
|
# This ensures only the most-derived class owns the active hooks,
|
|
@@ -36,7 +36,7 @@ class HookMeta(type):
|
|
|
36
36
|
|
|
37
37
|
if base in mcs._class_hook_map:
|
|
38
38
|
for model_cls, event, base_cls, method_name in list(
|
|
39
|
-
mcs._class_hook_map[base]
|
|
39
|
+
mcs._class_hook_map[base],
|
|
40
40
|
):
|
|
41
41
|
key = (model_cls, event, base_cls, method_name)
|
|
42
42
|
if key in HookMeta._registered:
|
|
@@ -44,7 +44,7 @@ class HookMeta(type):
|
|
|
44
44
|
HookMeta._registered.discard(key)
|
|
45
45
|
logger.debug(
|
|
46
46
|
f"Unregistered base hook: {base_cls.__name__}.{method_name} "
|
|
47
|
-
f"(superseded by {cls.__name__})"
|
|
47
|
+
f"(superseded by {cls.__name__})",
|
|
48
48
|
)
|
|
49
49
|
|
|
50
50
|
# Step 2: Register all hook methods on this class (including inherited ones)
|
|
@@ -79,7 +79,7 @@ class HookMeta(type):
|
|
|
79
79
|
mcs._class_hook_map[cls].add(key)
|
|
80
80
|
logger.debug(
|
|
81
81
|
f"Registered hook: {cls.__name__}.{method_name} "
|
|
82
|
-
f"for {model_cls.__name__}.{event}"
|
|
82
|
+
f"for {model_cls.__name__}.{event}",
|
|
83
83
|
)
|
|
84
84
|
|
|
85
85
|
@classmethod
|
|
@@ -112,4 +112,3 @@ class Hook(metaclass=HookMeta):
|
|
|
112
112
|
a single, consistent execution path.
|
|
113
113
|
"""
|
|
114
114
|
|
|
115
|
-
pass
|
django_bulk_hooks/helpers.py
CHANGED
|
@@ -8,11 +8,12 @@ NOTE: These helpers are pure changeset builders - they don't fetch data.
|
|
|
8
8
|
Data fetching is the responsibility of ModelAnalyzer.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
from django_bulk_hooks.changeset import ChangeSet
|
|
11
|
+
from django_bulk_hooks.changeset import ChangeSet
|
|
12
|
+
from django_bulk_hooks.changeset import RecordChange
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
def build_changeset_for_update(
|
|
15
|
-
model_cls, instances, update_kwargs, old_records_map=None, **meta
|
|
16
|
+
model_cls, instances, update_kwargs, old_records_map=None, **meta,
|
|
16
17
|
):
|
|
17
18
|
"""
|
|
18
19
|
Build ChangeSet for update operations.
|
|
@@ -32,7 +33,7 @@ def build_changeset_for_update(
|
|
|
32
33
|
|
|
33
34
|
changes = [
|
|
34
35
|
RecordChange(
|
|
35
|
-
new, old_records_map.get(new.pk), changed_fields=list(update_kwargs.keys())
|
|
36
|
+
new, old_records_map.get(new.pk), changed_fields=list(update_kwargs.keys()),
|
|
36
37
|
)
|
|
37
38
|
for new in instances
|
|
38
39
|
]
|
django_bulk_hooks/models.py
CHANGED
|
@@ -28,7 +28,7 @@ class HookModelMixin(models.Model):
|
|
|
28
28
|
# Delegate to coordinator (consistent with save/delete)
|
|
29
29
|
is_create = self.pk is None
|
|
30
30
|
self.__class__.objects.get_queryset().coordinator.clean(
|
|
31
|
-
[self], is_create=is_create
|
|
31
|
+
[self], is_create=is_create,
|
|
32
32
|
)
|
|
33
33
|
|
|
34
34
|
def save(self, *args, bypass_hooks=False, **kwargs):
|
|
@@ -48,18 +48,17 @@ class HookModelMixin(models.Model):
|
|
|
48
48
|
# Delegate to bulk_create which handles all hook logic
|
|
49
49
|
result = self.__class__.objects.bulk_create([self])
|
|
50
50
|
return result[0] if result else self
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return self
|
|
51
|
+
# Delegate to bulk_update which handles all hook logic
|
|
52
|
+
update_fields = kwargs.get("update_fields")
|
|
53
|
+
if update_fields is None:
|
|
54
|
+
# Update all non-auto fields
|
|
55
|
+
update_fields = [
|
|
56
|
+
f.name
|
|
57
|
+
for f in self.__class__._meta.fields
|
|
58
|
+
if not f.auto_created and f.name != "id"
|
|
59
|
+
]
|
|
60
|
+
self.__class__.objects.bulk_update([self], update_fields)
|
|
61
|
+
return self
|
|
63
62
|
|
|
64
63
|
def delete(self, *args, bypass_hooks=False, **kwargs):
|
|
65
64
|
"""
|