django-bulk-hooks 0.1.100__py3-none-any.whl → 0.1.102__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.
@@ -1,19 +1,3 @@
1
- from django_bulk_hooks.conditions import (
2
- ChangesTo,
3
- HasChanged,
4
- IsBlank,
5
- IsEqual,
6
- IsGreaterThan,
7
- IsGreaterThanOrEqual,
8
- IsLessThan,
9
- IsLessThanOrEqual,
10
- IsNotEqual,
11
- LambdaCondition,
12
- WasEqual,
13
- is_field_set,
14
- safe_get_related_attr,
15
- safe_get_related_object,
16
- )
17
1
  from django_bulk_hooks.constants import (
18
2
  AFTER_CREATE,
19
3
  AFTER_DELETE,
@@ -25,10 +9,20 @@ from django_bulk_hooks.constants import (
25
9
  VALIDATE_DELETE,
26
10
  VALIDATE_UPDATE,
27
11
  )
12
+ from django_bulk_hooks.conditions import (
13
+ ChangesTo,
14
+ HasChanged,
15
+ IsEqual,
16
+ IsNotEqual,
17
+ WasEqual,
18
+ safe_get_related_object,
19
+ safe_get_related_attr,
20
+ is_field_set,
21
+ )
28
22
  from django_bulk_hooks.decorators import hook, select_related
29
- from django_bulk_hooks.enums import Priority
30
23
  from django_bulk_hooks.handler import HookHandler
31
24
  from django_bulk_hooks.models import HookModelMixin
25
+ from django_bulk_hooks.enums import Priority
32
26
 
33
27
  __all__ = [
34
28
  "HookHandler",
@@ -53,10 +47,4 @@ __all__ = [
53
47
  "IsEqual",
54
48
  "IsNotEqual",
55
49
  "WasEqual",
56
- "IsBlank",
57
- "IsGreaterThan",
58
- "IsLessThan",
59
- "IsGreaterThanOrEqual",
60
- "IsLessThanOrEqual",
61
- "LambdaCondition",
62
50
  ]
@@ -8,7 +8,7 @@ def safe_get_related_object(instance, field_name):
8
8
  """
9
9
  if not hasattr(instance, field_name):
10
10
  return None
11
-
11
+
12
12
  # Get the foreign key field
13
13
  try:
14
14
  field = instance._meta.get_field(field_name)
@@ -16,12 +16,12 @@ def safe_get_related_object(instance, field_name):
16
16
  return getattr(instance, field_name, None)
17
17
  except models.FieldDoesNotExist:
18
18
  return getattr(instance, field_name, None)
19
-
19
+
20
20
  # Check if the foreign key field is None
21
21
  fk_field_name = f"{field_name}_id"
22
22
  if hasattr(instance, fk_field_name) and getattr(instance, fk_field_name) is None:
23
23
  return None
24
-
24
+
25
25
  # Try to get the related object, but catch RelatedObjectDoesNotExist
26
26
  try:
27
27
  return getattr(instance, field_name)
@@ -29,25 +29,49 @@ def safe_get_related_object(instance, field_name):
29
29
  return None
30
30
 
31
31
 
32
+ def is_field_set(instance, field_name):
33
+ """
34
+ Check if a foreign key field is set without raising RelatedObjectDoesNotExist.
35
+
36
+ Args:
37
+ instance: The model instance
38
+ field_name: The foreign key field name
39
+
40
+ Returns:
41
+ True if the field is set, False otherwise
42
+ """
43
+ # Check the foreign key ID field first
44
+ fk_field_name = f"{field_name}_id"
45
+ if hasattr(instance, fk_field_name):
46
+ fk_value = getattr(instance, fk_field_name, None)
47
+ return fk_value is not None
48
+
49
+ # Fallback to checking the field directly
50
+ try:
51
+ return getattr(instance, field_name) is not None
52
+ except Exception:
53
+ return False
54
+
55
+
32
56
  def safe_get_related_attr(instance, field_name, attr_name=None):
33
57
  """
34
58
  Safely get a related object or its attribute without raising RelatedObjectDoesNotExist.
35
-
59
+
36
60
  This is particularly useful in hooks where objects might not have their related
37
61
  fields populated yet (e.g., during bulk_create operations or on unsaved objects).
38
-
62
+
39
63
  Args:
40
64
  instance: The model instance
41
65
  field_name: The foreign key field name
42
66
  attr_name: Optional attribute name to access on the related object
43
-
67
+
44
68
  Returns:
45
69
  The related object, the attribute value, or None if not available
46
-
70
+
47
71
  Example:
48
72
  # Instead of: loan_transaction.status.name (which might fail)
49
73
  # Use: safe_get_related_attr(loan_transaction, 'status', 'name')
50
-
74
+
51
75
  status_name = safe_get_related_attr(loan_transaction, 'status', 'name')
52
76
  if status_name in {Status.COMPLETE.value, Status.FAILED.value}:
53
77
  # Process the transaction
@@ -63,31 +87,96 @@ def safe_get_related_attr(instance, field_name, attr_name=None):
63
87
  # If we have an ID but the object isn't loaded, try to load it
64
88
  try:
65
89
  field = instance._meta.get_field(field_name)
66
- if hasattr(field, "related_model"):
90
+ if hasattr(field, 'related_model'):
67
91
  related_obj = field.related_model.objects.get(id=fk_value)
68
92
  if attr_name is None:
69
93
  return related_obj
70
94
  return getattr(related_obj, attr_name, None)
71
95
  except (field.related_model.DoesNotExist, AttributeError):
72
96
  return None
73
-
97
+
74
98
  # For saved objects or when the above doesn't work, use the original method
75
99
  related_obj = safe_get_related_object(instance, field_name)
76
100
  if related_obj is None:
77
101
  return None
102
+
78
103
  if attr_name is None:
79
104
  return related_obj
105
+
80
106
  return getattr(related_obj, attr_name, None)
81
107
 
82
108
 
83
- def is_field_set(instance, field_name):
109
+ def safe_get_related_attr_with_fallback(instance, field_name, attr_name=None, fallback_value=None):
84
110
  """
85
- Check if a field has been set on a model instance.
111
+ Enhanced version of safe_get_related_attr that provides fallback handling.
112
+
113
+ This function is especially useful for bulk operations where related objects
114
+ might not be fully loaded or might not exist yet.
115
+
116
+ Args:
117
+ instance: The model instance
118
+ field_name: The foreign key field name
119
+ attr_name: Optional attribute name to access on the related object
120
+ fallback_value: Value to return if the related object or attribute doesn't exist
121
+
122
+ Returns:
123
+ The related object, the attribute value, or fallback_value if not available
124
+ """
125
+ # First try the standard safe access
126
+ result = safe_get_related_attr(instance, field_name, attr_name)
127
+ if result is not None:
128
+ return result
129
+
130
+ # If that fails, try to get the foreign key ID and fetch the object directly
131
+ fk_field_name = f"{field_name}_id"
132
+ if hasattr(instance, fk_field_name):
133
+ fk_id = getattr(instance, fk_field_name)
134
+ if fk_id is not None:
135
+ try:
136
+ # Get the field to determine the related model
137
+ field = instance._meta.get_field(field_name)
138
+ if field.is_relation and not field.many_to_many and not field.one_to_many:
139
+ # Try to fetch the related object directly
140
+ related_obj = field.related_model.objects.get(pk=fk_id)
141
+ if attr_name is None:
142
+ return related_obj
143
+ return getattr(related_obj, attr_name, fallback_value)
144
+ except (field.related_model.DoesNotExist, AttributeError):
145
+ pass
146
+
147
+ return fallback_value
148
+
86
149
 
87
- This is useful for checking if a field has been explicitly set,
88
- even if it's been set to None.
150
+ def resolve_dotted_attr(instance, dotted_path):
151
+ """
152
+ Recursively resolve a dotted attribute path, e.g., "type.category".
153
+ This function is designed to work with pre-loaded foreign keys to avoid queries.
89
154
  """
90
- return hasattr(instance, field_name)
155
+ if instance is None:
156
+ return None
157
+
158
+ current = instance
159
+ for attr in dotted_path.split("."):
160
+ if current is None:
161
+ return None
162
+
163
+ # Check if this is a foreign key that might trigger a query
164
+ if hasattr(current, '_meta') and hasattr(current._meta, 'get_field'):
165
+ try:
166
+ field = current._meta.get_field(attr)
167
+ if field.is_relation and not field.many_to_many and not field.one_to_many:
168
+ # For foreign keys, use safe access to prevent RelatedObjectDoesNotExist
169
+ current = safe_get_related_object(current, attr)
170
+ else:
171
+ current = getattr(current, attr, None)
172
+ except Exception:
173
+ # If field lookup fails, fall back to regular attribute access
174
+ current = getattr(current, attr, None)
175
+ else:
176
+ # Not a model instance, use regular attribute access
177
+ current = getattr(current, attr, None)
178
+
179
+ return current
91
180
 
92
181
 
93
182
  class HookCondition:
@@ -114,94 +203,54 @@ class HookCondition:
114
203
  return set()
115
204
 
116
205
 
117
- class IsBlank(HookCondition):
118
- """
119
- Condition that checks if a field is blank (None or empty string).
120
- """
121
-
122
- def __init__(self, field_name):
123
- self.field_name = field_name
124
-
125
- def check(self, instance, original_instance=None):
126
- value = getattr(instance, self.field_name, None)
127
- return value is None or value == ""
128
-
129
- def get_required_fields(self):
130
- return {self.field_name}
131
-
132
-
133
- class AndCondition(HookCondition):
134
- def __init__(self, *conditions):
135
- self.conditions = conditions
136
-
137
- def check(self, instance, original_instance=None):
138
- return all(c.check(instance, original_instance) for c in self.conditions)
139
-
140
- def get_required_fields(self):
141
- fields = set()
142
- for condition in self.conditions:
143
- fields.update(condition.get_required_fields())
144
- return fields
145
-
146
-
147
- class OrCondition(HookCondition):
148
- def __init__(self, *conditions):
149
- self.conditions = conditions
150
-
151
- def check(self, instance, original_instance=None):
152
- return any(c.check(instance, original_instance) for c in self.conditions)
153
-
154
- def get_required_fields(self):
155
- fields = set()
156
- for condition in self.conditions:
157
- fields.update(condition.get_required_fields())
158
- return fields
159
-
160
-
161
- class NotCondition(HookCondition):
162
- def __init__(self, condition):
163
- self.condition = condition
206
+ class IsEqual(HookCondition):
207
+ def __init__(self, field, value):
208
+ self.field = field
209
+ self.value = value
164
210
 
165
211
  def check(self, instance, original_instance=None):
166
- return not self.condition.check(instance, original_instance)
212
+ current_value = resolve_dotted_attr(instance, self.field)
213
+ return current_value == self.value
167
214
 
168
215
  def get_required_fields(self):
169
- return self.condition.get_required_fields()
216
+ return {self.field.split('.')[0]}
170
217
 
171
218
 
172
- class IsEqual(HookCondition):
173
- def __init__(self, field_name, value):
174
- self.field_name = field_name
219
+ class IsNotEqual(HookCondition):
220
+ def __init__(self, field, value):
221
+ self.field = field
175
222
  self.value = value
176
223
 
177
224
  def check(self, instance, original_instance=None):
178
- return getattr(instance, self.field_name, None) == self.value
225
+ current_value = resolve_dotted_attr(instance, self.field)
226
+ return current_value != self.value
179
227
 
180
228
  def get_required_fields(self):
181
- return {self.field_name}
229
+ return {self.field.split('.')[0]}
182
230
 
183
231
 
184
232
  class WasEqual(HookCondition):
185
- def __init__(self, field_name, value):
186
- self.field_name = field_name
233
+ def __init__(self, field, value):
234
+ self.field = field
187
235
  self.value = value
188
236
 
189
237
  def check(self, instance, original_instance=None):
190
238
  if original_instance is None:
191
239
  return False
192
- return getattr(original_instance, self.field_name, None) == self.value
240
+ original_value = resolve_dotted_attr(original_instance, self.field)
241
+ return original_value == self.value
193
242
 
194
243
  def get_required_fields(self):
195
- return {self.field_name}
244
+ return {self.field.split('.')[0]}
196
245
 
197
246
 
198
247
  class HasChanged(HookCondition):
199
- def __init__(self, field_name, has_changed=True):
248
+ def __init__(self, field, has_changed=True):
200
249
  """
201
250
  Check if a field's value has changed or remained the same.
202
-
251
+
203
252
  Args:
204
- field_name: The field name to check
253
+ field: The field name to check
205
254
  has_changed: If True (default), condition passes when field has changed.
206
255
  If False, condition passes when field has remained the same.
207
256
  This is useful for:
@@ -210,149 +259,118 @@ class HasChanged(HookCondition):
210
259
  - Ensuring critical fields remain constant
211
260
  - State machine validations
212
261
  """
213
- self.field_name = field_name
262
+ self.field = field
214
263
  self.has_changed = has_changed
215
264
 
216
265
  def check(self, instance, original_instance=None):
217
266
  if original_instance is None:
218
267
  # For new instances:
219
- # - If we're checking for changes (has_changed=True), return True since it's a new record
220
- # - If we're checking for stability (has_changed=False), return False since it's technically changed from nothing
221
- return self.has_changed
222
-
223
- current_value = getattr(instance, self.field_name, None)
224
- original_value = getattr(original_instance, self.field_name, None)
268
+ # - If we're checking for changes (has_changed=True), return False since there's no change yet
269
+ # - If we're checking for stability (has_changed=False), return True since it's technically unchanged
270
+ return not self.has_changed
271
+
272
+ current_value = resolve_dotted_attr(instance, self.field)
273
+ original_value = resolve_dotted_attr(original_instance, self.field)
225
274
  return (current_value != original_value) == self.has_changed
226
275
 
227
276
  def get_required_fields(self):
228
- return {self.field_name}
277
+ return {self.field.split('.')[0]}
229
278
 
230
279
 
231
280
  class ChangesTo(HookCondition):
232
- def __init__(self, field_name, value):
233
- self.field_name = field_name
281
+ def __init__(self, field, value):
282
+ self.field = field
234
283
  self.value = value
235
284
 
236
285
  def check(self, instance, original_instance=None):
237
286
  if original_instance is None:
238
- return getattr(instance, self.field_name, None) == self.value
239
- return getattr(instance, self.field_name, None) == self.value and getattr(
240
- instance, self.field_name, None
241
- ) != getattr(original_instance, self.field_name, None)
287
+ current_value = resolve_dotted_attr(instance, self.field)
288
+ return current_value == self.value
289
+
290
+ current_value = resolve_dotted_attr(instance, self.field)
291
+ original_value = resolve_dotted_attr(original_instance, self.field)
292
+ return current_value == self.value and current_value != original_value
242
293
 
243
294
  def get_required_fields(self):
244
- return {self.field_name}
295
+ return {self.field.split('.')[0]}
245
296
 
246
297
 
247
- class IsNotEqual(HookCondition):
248
- def __init__(self, field_name, value):
249
- self.field_name = field_name
298
+ class IsGreaterThan(HookCondition):
299
+ def __init__(self, field, value):
300
+ self.field = field
250
301
  self.value = value
251
302
 
252
303
  def check(self, instance, original_instance=None):
253
- return getattr(instance, self.field_name, None) != self.value
254
-
255
- def get_required_fields(self):
256
- return {self.field_name}
304
+ current = resolve_dotted_attr(instance, self.field)
305
+ return current is not None and current > self.value
257
306
 
258
307
 
259
- class IsGreaterThan(HookCondition):
260
- def __init__(self, field_name, value):
261
- self.field_name = field_name
308
+ class IsGreaterThanOrEqual(HookCondition):
309
+ def __init__(self, field, value):
310
+ self.field = field
262
311
  self.value = value
263
312
 
264
313
  def check(self, instance, original_instance=None):
265
- field_value = getattr(instance, self.field_name, None)
266
- if field_value is None:
267
- return False
268
- return field_value > self.value
269
-
270
- def get_required_fields(self):
271
- return {self.field_name}
314
+ current = resolve_dotted_attr(instance, self.field)
315
+ return current is not None and current >= self.value
272
316
 
273
317
 
274
318
  class IsLessThan(HookCondition):
275
- def __init__(self, field_name, value):
276
- self.field_name = field_name
319
+ def __init__(self, field, value):
320
+ self.field = field
277
321
  self.value = value
278
322
 
279
323
  def check(self, instance, original_instance=None):
280
- field_value = getattr(instance, self.field_name, None)
281
- if field_value is None:
282
- return False
283
- return field_value < self.value
284
-
285
- def get_required_fields(self):
286
- return {self.field_name}
324
+ current = resolve_dotted_attr(instance, self.field)
325
+ return current is not None and current < self.value
287
326
 
288
327
 
289
- class IsGreaterThanOrEqual(HookCondition):
290
- def __init__(self, field_name, value):
291
- self.field_name = field_name
328
+ class IsLessThanOrEqual(HookCondition):
329
+ def __init__(self, field, value):
330
+ self.field = field
292
331
  self.value = value
293
332
 
294
333
  def check(self, instance, original_instance=None):
295
- field_value = getattr(instance, self.field_name, None)
296
- if field_value is None:
297
- return False
298
- return field_value >= self.value
334
+ current = resolve_dotted_attr(instance, self.field)
335
+ return current is not None and current <= self.value
299
336
 
300
- def get_required_fields(self):
301
- return {self.field_name}
302
337
 
303
-
304
- class IsLessThanOrEqual(HookCondition):
305
- def __init__(self, field_name, value):
306
- self.field_name = field_name
307
- self.value = value
338
+ class AndCondition(HookCondition):
339
+ def __init__(self, condition1, condition2):
340
+ self.condition1 = condition1
341
+ self.condition2 = condition2
308
342
 
309
343
  def check(self, instance, original_instance=None):
310
- field_value = getattr(instance, self.field_name, None)
311
- if field_value is None:
312
- return False
313
- return field_value <= self.value
344
+ return (
345
+ self.condition1.check(instance, original_instance)
346
+ and self.condition2.check(instance, original_instance)
347
+ )
314
348
 
315
349
  def get_required_fields(self):
316
- return {self.field_name}
350
+ return self.condition1.get_required_fields() | self.condition2.get_required_fields()
317
351
 
318
352
 
319
- class LambdaCondition(HookCondition):
320
- """
321
- A condition that uses a lambda function or any callable.
322
-
323
- This makes it easy to create custom conditions inline without defining
324
- a full class.
325
-
326
- Example:
327
- # Simple lambda condition
328
- condition = LambdaCondition(lambda instance: instance.price > 100)
353
+ class OrCondition(HookCondition):
354
+ def __init__(self, condition1, condition2):
355
+ self.condition1 = condition1
356
+ self.condition2 = condition2
329
357
 
330
- # Lambda with original instance
331
- condition = LambdaCondition(
332
- lambda instance, original: instance.price > original.price
358
+ def check(self, instance, original_instance=None):
359
+ return (
360
+ self.condition1.check(instance, original_instance)
361
+ or self.condition2.check(instance, original_instance)
333
362
  )
334
363
 
335
- # Using in a hook
336
- @hook(Product, "after_update", condition=LambdaCondition(
337
- lambda instance: instance.price > 100 and instance.is_active
338
- ))
339
- def handle_expensive_active_products(self, new_records, old_records):
340
- pass
341
- """
364
+ def get_required_fields(self):
365
+ return self.condition1.get_required_fields() | self.condition2.get_required_fields()
342
366
 
343
- def __init__(self, func, required_fields=None):
344
- """
345
- Initialize with a callable function.
346
367
 
347
- Args:
348
- func: A callable that takes (instance, original_instance=None) and returns bool
349
- required_fields: Optional set of field names this condition depends on
350
- """
351
- self.func = func
352
- self._required_fields = required_fields or set()
368
+ class NotCondition(HookCondition):
369
+ def __init__(self, condition):
370
+ self.condition = condition
353
371
 
354
372
  def check(self, instance, original_instance=None):
355
- return self.func(instance, original_instance)
373
+ return not self.condition.check(instance, original_instance)
356
374
 
357
375
  def get_required_fields(self):
358
- return self._required_fields
376
+ return self.condition.get_required_fields()
@@ -52,31 +52,20 @@ def run(model_cls, event, new_instances, original_instances=None, ctx=None):
52
52
  else:
53
53
  handler_instance, func = _handler_cache[handler_key]
54
54
 
55
- # Filter instances based on condition
56
- if condition:
57
- to_process_new = []
58
- to_process_old = []
55
+ # If no condition, process all instances at once
56
+ if not condition:
57
+ func(new_records=new_instances, old_records=original_instances if any(original_instances) else None)
58
+ continue
59
59
 
60
- logger.debug(f"Checking condition {condition.__class__.__name__} for {len(new_instances)} instances")
61
- for new, original in zip(new_instances, original_instances, strict=True):
62
- logger.debug(f"Checking instance {new.__class__.__name__}(pk={new.pk})")
63
- try:
64
- matches = condition.check(new, original)
65
- logger.debug(f"Condition check result: {matches}")
66
- if matches:
67
- to_process_new.append(new)
68
- to_process_old.append(original)
69
- except Exception as e:
70
- logger.error(f"Error checking condition: {e}")
71
- raise
60
+ # For conditional hooks, filter instances first
61
+ to_process_new = []
62
+ to_process_old = []
72
63
 
73
- # Only call if we have matching instances
74
- if to_process_new:
75
- logger.debug(f"Running hook for {len(to_process_new)} matching instances")
76
- func(new_records=to_process_new, old_records=to_process_old if any(to_process_old) else None)
77
- else:
78
- logger.debug("No instances matched condition")
79
- else:
80
- # No condition, process all instances
81
- logger.debug("No condition, processing all instances")
82
- func(new_records=new_instances, old_records=original_instances if any(original_instances) else None)
64
+ for new, original in zip(new_instances, original_instances, strict=True):
65
+ if condition.check(new, original):
66
+ to_process_new.append(new)
67
+ to_process_old.append(original)
68
+
69
+ if to_process_new:
70
+ # Call the function with keyword arguments
71
+ func(new_records=to_process_new, old_records=to_process_old if any(to_process_old) else None)
@@ -1,4 +1,3 @@
1
- import inspect
2
1
  import logging
3
2
  import threading
4
3
  from collections import deque
@@ -143,21 +142,10 @@ class HookHandler(metaclass=HookMeta):
143
142
  handler = handler_cls()
144
143
  method = getattr(handler, method_name)
145
144
 
146
- # Inspect the method signature to determine parameter order
147
- import inspect
148
-
149
- sig = inspect.signature(method)
150
- params = list(sig.parameters.keys())
151
-
152
- # Remove 'self' from params if it exists
153
- if params and params[0] == "self":
154
- params = params[1:]
155
-
156
- # Always call with keyword arguments to make order irrelevant
157
145
  try:
158
146
  method(
159
- old_records=old_local,
160
147
  new_records=new_local,
148
+ old_records=old_local,
161
149
  **kwargs,
162
150
  )
163
151
  except Exception:
@@ -20,7 +20,7 @@ class BulkHookManager(models.Manager):
20
20
  # Default chunk sizes - can be overridden per model
21
21
  DEFAULT_CHUNK_SIZE = 200
22
22
  DEFAULT_RELATED_CHUNK_SIZE = 500 # Higher for related object fetching
23
-
23
+
24
24
  def __init__(self):
25
25
  super().__init__()
26
26
  self._chunk_size = self.DEFAULT_CHUNK_SIZE
@@ -28,11 +28,16 @@ class BulkHookManager(models.Manager):
28
28
  self._prefetch_related_fields = set()
29
29
  self._select_related_fields = set()
30
30
 
31
- def configure(self, chunk_size=None, related_chunk_size=None,
32
- select_related=None, prefetch_related=None):
31
+ def configure(
32
+ self,
33
+ chunk_size=None,
34
+ related_chunk_size=None,
35
+ select_related=None,
36
+ prefetch_related=None,
37
+ ):
33
38
  """
34
39
  Configure bulk operation parameters for this manager.
35
-
40
+
36
41
  Args:
37
42
  chunk_size: Number of objects to process in each bulk operation chunk
38
43
  related_chunk_size: Number of objects to fetch in each related object query
@@ -53,24 +58,24 @@ class BulkHookManager(models.Manager):
53
58
  Optimized loading of original instances with smart batching and field selection.
54
59
  """
55
60
  queryset = self.model.objects.filter(pk__in=pks)
56
-
57
- # Only select specific fields if provided
58
- if fields_to_fetch:
59
- queryset = queryset.only('pk', *fields_to_fetch)
60
-
61
+
62
+ # Only select specific fields if provided and not empty
63
+ if fields_to_fetch and len(fields_to_fetch) > 0:
64
+ queryset = queryset.only("pk", *fields_to_fetch)
65
+
61
66
  # Apply configured related field optimizations
62
67
  if self._select_related_fields:
63
68
  queryset = queryset.select_related(*self._select_related_fields)
64
69
  if self._prefetch_related_fields:
65
70
  queryset = queryset.prefetch_related(*self._prefetch_related_fields)
66
-
71
+
67
72
  # Batch load in chunks to avoid memory issues
68
73
  all_originals = []
69
74
  for i in range(0, len(pks), self._related_chunk_size):
70
- chunk_pks = pks[i:i + self._related_chunk_size]
75
+ chunk_pks = pks[i : i + self._related_chunk_size]
71
76
  chunk_originals = list(queryset.filter(pk__in=chunk_pks))
72
77
  all_originals.extend(chunk_originals)
73
-
78
+
74
79
  return all_originals
75
80
 
76
81
  def _get_fields_to_fetch(self, objs, fields):
@@ -79,20 +84,51 @@ class BulkHookManager(models.Manager):
79
84
  and what's needed for hooks.
80
85
  """
81
86
  fields_to_fetch = set(fields)
82
-
87
+
83
88
  # Add fields needed by registered hooks
84
89
  from django_bulk_hooks.registry import get_hooks
85
- hooks = get_hooks(self.model, "before_update") + get_hooks(self.model, "after_update")
86
-
90
+
91
+ hooks = get_hooks(self.model, "before_update") + get_hooks(
92
+ self.model, "after_update"
93
+ )
94
+
87
95
  for handler_cls, method_name, condition, _ in hooks:
88
96
  if condition:
89
97
  # If there's a condition, we need all fields it might access
90
98
  fields_to_fetch.update(condition.get_required_fields())
91
-
92
- return fields_to_fetch
99
+
100
+ # Filter out fields that don't exist on the model
101
+ valid_fields = set()
102
+ invalid_fields = set()
103
+ for field_name in fields_to_fetch:
104
+ try:
105
+ self.model._meta.get_field(field_name)
106
+ valid_fields.add(field_name)
107
+ except Exception as e:
108
+ # Field doesn't exist, skip it
109
+ invalid_fields.add(field_name)
110
+ import logging
111
+
112
+ logger = logging.getLogger(__name__)
113
+ logger.debug(
114
+ f"Field '{field_name}' requested by hook condition but doesn't exist on {self.model.__name__}: {e}"
115
+ )
116
+ continue
117
+
118
+ if invalid_fields:
119
+ import logging
120
+ logger = logging.getLogger(__name__)
121
+ logger.warning(
122
+ f"Invalid fields requested for {self.model.__name__}: {invalid_fields}. "
123
+ f"These fields were ignored to prevent errors."
124
+ )
125
+
126
+ return valid_fields
93
127
 
94
128
  @transaction.atomic
95
- def bulk_update(self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs):
129
+ def bulk_update(
130
+ self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
131
+ ):
96
132
  if not objs:
97
133
  return []
98
134
 
@@ -106,14 +142,14 @@ class BulkHookManager(models.Manager):
106
142
  if not bypass_hooks:
107
143
  # Determine which fields we need to fetch
108
144
  fields_to_fetch = self._get_fields_to_fetch(objs, fields)
109
-
145
+
110
146
  # Load originals efficiently
111
147
  pks = [obj.pk for obj in objs if obj.pk is not None]
112
148
  originals = self._load_originals_optimized(pks, fields_to_fetch)
113
-
149
+
114
150
  # Create a mapping for quick lookup
115
151
  original_map = {obj.pk: obj for obj in originals}
116
-
152
+
117
153
  # Align originals with new instances
118
154
  aligned_originals = [original_map.get(obj.pk) for obj in objs]
119
155
 
@@ -135,7 +171,7 @@ class BulkHookManager(models.Manager):
135
171
 
136
172
  # Process in chunks
137
173
  for i in range(0, len(objs), self._chunk_size):
138
- chunk = objs[i:i + self._chunk_size]
174
+ chunk = objs[i : i + self._chunk_size]
139
175
  super(models.Manager, self).bulk_update(chunk, fields, **kwargs)
140
176
 
141
177
  if not bypass_hooks:
@@ -205,30 +241,32 @@ class BulkHookManager(models.Manager):
205
241
  # Process validation in chunks to avoid memory issues
206
242
  if not bypass_validation:
207
243
  for i in range(0, len(objs), self._chunk_size):
208
- chunk = objs[i:i + self._chunk_size]
244
+ chunk = objs[i : i + self._chunk_size]
209
245
  engine.run(model_cls, VALIDATE_CREATE, chunk, ctx=ctx)
210
246
 
211
247
  # Process before_create hooks in chunks
212
248
  for i in range(0, len(objs), self._chunk_size):
213
- chunk = objs[i:i + self._chunk_size]
249
+ chunk = objs[i : i + self._chunk_size]
214
250
  engine.run(model_cls, BEFORE_CREATE, chunk, ctx=ctx)
215
251
 
216
252
  # Perform bulk create in chunks
217
253
  for i in range(0, len(objs), self._chunk_size):
218
- chunk = objs[i:i + self._chunk_size]
254
+ chunk = objs[i : i + self._chunk_size]
219
255
  created_chunk = super(models.Manager, self).bulk_create(chunk, **kwargs)
220
256
  result.extend(created_chunk)
221
257
 
222
258
  if not bypass_hooks:
223
259
  # Process after_create hooks in chunks
224
260
  for i in range(0, len(result), self._chunk_size):
225
- chunk = result[i:i + self._chunk_size]
261
+ chunk = result[i : i + self._chunk_size]
226
262
  engine.run(model_cls, AFTER_CREATE, chunk, ctx=ctx)
227
263
 
228
264
  return result
229
265
 
230
266
  @transaction.atomic
231
- def bulk_delete(self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False):
267
+ def bulk_delete(
268
+ self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False
269
+ ):
232
270
  if not objs:
233
271
  return []
234
272
 
@@ -245,8 +283,8 @@ class BulkHookManager(models.Manager):
245
283
  if not bypass_hooks:
246
284
  # Process hooks in chunks
247
285
  for i in range(0, len(objs), chunk_size):
248
- chunk = objs[i:i + chunk_size]
249
-
286
+ chunk = objs[i : i + chunk_size]
287
+
250
288
  if not bypass_validation:
251
289
  engine.run(model_cls, VALIDATE_DELETE, chunk, ctx=ctx)
252
290
  engine.run(model_cls, BEFORE_DELETE, chunk, ctx=ctx)
@@ -254,13 +292,13 @@ class BulkHookManager(models.Manager):
254
292
  # Collect PKs and delete in chunks
255
293
  pks = [obj.pk for obj in objs if obj.pk is not None]
256
294
  for i in range(0, len(pks), chunk_size):
257
- chunk_pks = pks[i:i + chunk_size]
295
+ chunk_pks = pks[i : i + chunk_size]
258
296
  model_cls._base_manager.filter(pk__in=chunk_pks).delete()
259
297
 
260
298
  if not bypass_hooks:
261
299
  # Process after_delete hooks in chunks
262
300
  for i in range(0, len(objs), chunk_size):
263
- chunk = objs[i:i + chunk_size]
301
+ chunk = objs[i : i + chunk_size]
264
302
  engine.run(model_cls, AFTER_DELETE, chunk, ctx=ctx)
265
303
 
266
304
  return objs
@@ -1,8 +1,4 @@
1
- import contextlib
2
- from functools import wraps
3
-
4
1
  from django.db import models, transaction
5
- from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor
6
2
 
7
3
  from django_bulk_hooks.constants import (
8
4
  AFTER_CREATE,
@@ -18,6 +14,9 @@ from django_bulk_hooks.constants import (
18
14
  from django_bulk_hooks.context import HookContext
19
15
  from django_bulk_hooks.engine import run
20
16
  from django_bulk_hooks.manager import BulkHookManager
17
+ from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor
18
+ from functools import wraps
19
+ import contextlib
21
20
 
22
21
 
23
22
  @contextlib.contextmanager
@@ -27,7 +26,7 @@ def patch_foreign_key_behavior():
27
26
  RelatedObjectDoesNotExist when accessing an unset foreign key field.
28
27
  """
29
28
  original_get = ForwardManyToOneDescriptor.__get__
30
-
29
+
31
30
  @wraps(original_get)
32
31
  def safe_get(self, instance, cls=None):
33
32
  if instance is None:
@@ -36,7 +35,7 @@ def patch_foreign_key_behavior():
36
35
  return original_get(self, instance, cls)
37
36
  except self.RelatedObjectDoesNotExist:
38
37
  return None
39
-
38
+
40
39
  # Patch the descriptor
41
40
  ForwardManyToOneDescriptor.__get__ = safe_get
42
41
  try:
@@ -64,7 +63,7 @@ class HookModelMixin(models.Model):
64
63
  # Skip hook validation during admin form validation
65
64
  # This prevents RelatedObjectDoesNotExist errors when Django hasn't
66
65
  # fully set up the object's relationships yet
67
- if hasattr(self, "_state") and getattr(self._state, "validating", False):
66
+ if hasattr(self, '_state') and getattr(self._state, 'validating', False):
68
67
  return
69
68
 
70
69
  # Determine if this is a create or update operation
@@ -81,9 +80,7 @@ class HookModelMixin(models.Model):
81
80
  old_instance = self.__class__.objects.get(pk=self.pk)
82
81
  ctx = HookContext(self.__class__)
83
82
  with patch_foreign_key_behavior():
84
- run(
85
- self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx
86
- )
83
+ run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
87
84
  except self.__class__.DoesNotExist:
88
85
  # If the old instance doesn't exist, treat as create
89
86
  ctx = HookContext(self.__class__)
@@ -94,40 +91,24 @@ class HookModelMixin(models.Model):
94
91
  is_create = self.pk is None
95
92
  ctx = HookContext(self.__class__)
96
93
 
97
- # Run BEFORE hooks before saving to allow field modifications
94
+ # Use a single context manager for all hooks
98
95
  with patch_foreign_key_behavior():
99
96
  if is_create:
100
97
  # For create operations
101
- run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
102
98
  run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
103
- else:
104
- # For update operations
105
- try:
106
- old_instance = self.__class__.objects.get(pk=self.pk)
107
- run(
108
- self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx
109
- )
110
- run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
111
- except self.__class__.DoesNotExist:
112
- # If the old instance doesn't exist, treat as create
113
- run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
114
- run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
115
-
116
- # Now let Django save with any modifications from BEFORE hooks
117
- super().save(*args, **kwargs)
118
-
119
- # Then run AFTER hooks
120
- with patch_foreign_key_behavior():
121
- if is_create:
122
- # For create operations
99
+ super().save(*args, **kwargs)
123
100
  run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
124
101
  else:
125
102
  # For update operations
126
103
  try:
127
104
  old_instance = self.__class__.objects.get(pk=self.pk)
105
+ run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
106
+ super().save(*args, **kwargs)
128
107
  run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
129
108
  except self.__class__.DoesNotExist:
130
109
  # If the old instance doesn't exist, treat as create
110
+ run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
111
+ super().save(*args, **kwargs)
131
112
  run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
132
113
 
133
114
  return self
@@ -141,5 +122,5 @@ class HookModelMixin(models.Model):
141
122
  run(self.__class__, BEFORE_DELETE, [self], ctx=ctx)
142
123
  result = super().delete(*args, **kwargs)
143
124
  run(self.__class__, AFTER_DELETE, [self], ctx=ctx)
144
-
125
+
145
126
  return result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.100
3
+ Version: 0.1.102
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  Home-page: https://github.com/AugendLimited/django-bulk-hooks
6
6
  License: MIT
@@ -226,166 +226,3 @@ class EfficientTransactionHandler:
226
226
  ```
227
227
 
228
228
  This approach ensures your hooks are robust and won't fail due to missing related objects, while also being efficient with database queries.
229
-
230
- ## 🎯 Lambda Conditions and Anonymous Functions
231
-
232
- `django-bulk-hooks` supports using anonymous functions (lambda functions) and custom callables as conditions, giving you maximum flexibility for complex filtering logic.
233
-
234
- ### Using LambdaCondition
235
-
236
- The `LambdaCondition` class allows you to use lambda functions or any callable as a condition:
237
-
238
- ```python
239
- from django_bulk_hooks import LambdaCondition
240
-
241
- class ProductHandler:
242
- # Simple lambda condition
243
- @hook(Product, "after_create", condition=LambdaCondition(
244
- lambda instance: instance.price > 100
245
- ))
246
- def handle_expensive_products(self, new_records, old_records):
247
- """Handle products with price > 100"""
248
- for product in new_records:
249
- print(f"Expensive product: {product.name}")
250
-
251
- # Lambda with multiple conditions
252
- @hook(Product, "after_update", condition=LambdaCondition(
253
- lambda instance: instance.price > 50 and instance.is_active and instance.stock_quantity > 0
254
- ))
255
- def handle_available_expensive_products(self, new_records, old_records):
256
- """Handle active products with price > 50 and stock > 0"""
257
- for product in new_records:
258
- print(f"Available expensive product: {product.name}")
259
-
260
- # Lambda comparing with original instance
261
- @hook(Product, "after_update", condition=LambdaCondition(
262
- lambda instance, original: original and instance.price > original.price * 1.5
263
- ))
264
- def handle_significant_price_increases(self, new_records, old_records):
265
- """Handle products with >50% price increase"""
266
- for new_product, old_product in zip(new_records, old_records):
267
- if old_product:
268
- increase = ((new_product.price - old_product.price) / old_product.price) * 100
269
- print(f"Significant price increase: {new_product.name} +{increase:.1f}%")
270
- ```
271
-
272
- ### Combining Lambda Conditions with Built-in Conditions
273
-
274
- You can combine lambda conditions with built-in conditions using the `&` (AND) and `|` (OR) operators:
275
-
276
- ```python
277
- from django_bulk_hooks.conditions import HasChanged, IsEqual
278
-
279
- class AdvancedProductHandler:
280
- # Combine lambda with built-in conditions
281
- @hook(Product, "after_update", condition=(
282
- HasChanged("price") &
283
- LambdaCondition(lambda instance: instance.price > 100)
284
- ))
285
- def handle_expensive_price_changes(self, new_records, old_records):
286
- """Handle when expensive products have price changes"""
287
- for new_product, old_product in zip(new_records, old_records):
288
- print(f"Expensive product price changed: {new_product.name}")
289
-
290
- # Complex combined conditions
291
- @hook(Order, "after_update", condition=(
292
- LambdaCondition(lambda instance: instance.status == 'completed') &
293
- LambdaCondition(lambda instance, original: original and instance.total_amount > original.total_amount)
294
- ))
295
- def handle_completed_orders_with_increased_amount(self, new_records, old_records):
296
- """Handle completed orders that had amount increases"""
297
- for new_order, old_order in zip(new_records, old_records):
298
- if old_order:
299
- increase = new_order.total_amount - old_order.total_amount
300
- print(f"Completed order with amount increase: {new_order.customer_name} +${increase}")
301
- ```
302
-
303
- ### Custom Condition Classes
304
-
305
- For reusable logic, you can create custom condition classes:
306
-
307
- ```python
308
- from django_bulk_hooks.conditions import HookCondition
309
-
310
- class IsPremiumProduct(HookCondition):
311
- def check(self, instance, original_instance=None):
312
- return (
313
- instance.price > 200 and
314
- instance.rating >= 4.0 and
315
- instance.is_active
316
- )
317
-
318
- def get_required_fields(self):
319
- return {'price', 'rating', 'is_active'}
320
-
321
- class ProductHandler:
322
- @hook(Product, "after_create", condition=IsPremiumProduct())
323
- def handle_premium_products(self, new_records, old_records):
324
- """Handle premium products"""
325
- for product in new_records:
326
- print(f"Premium product: {product.name}")
327
- ```
328
-
329
- ### Lambda Conditions with Required Fields
330
-
331
- For optimization, you can specify which fields your lambda condition depends on:
332
-
333
- ```python
334
- class OptimizedProductHandler:
335
- @hook(Product, "after_update", condition=LambdaCondition(
336
- lambda instance: instance.price > 100 and instance.category == 'electronics',
337
- required_fields={'price', 'category'}
338
- ))
339
- def handle_expensive_electronics(self, new_records, old_records):
340
- """Handle expensive electronics products"""
341
- for product in new_records:
342
- print(f"Expensive electronics: {product.name}")
343
- ```
344
-
345
- ### Best Practices for Lambda Conditions
346
-
347
- 1. **Keep lambdas simple** - Complex logic should be moved to custom condition classes
348
- 2. **Handle None values** - Always check for None before performing operations
349
- 3. **Specify required fields** - This helps with query optimization
350
- 4. **Use descriptive names** - Make your lambda conditions self-documenting
351
- 5. **Test thoroughly** - Lambda conditions can be harder to debug than named functions
352
-
353
- ```python
354
- # ✅ GOOD: Simple, clear lambda
355
- condition = LambdaCondition(lambda instance: instance.price > 100)
356
-
357
- # ✅ GOOD: Handles None values
358
- condition = LambdaCondition(
359
- lambda instance: instance.price is not None and instance.price > 100
360
- )
361
-
362
- # ❌ AVOID: Complex logic in lambda
363
- condition = LambdaCondition(
364
- lambda instance: (
365
- instance.price > 100 and
366
- instance.category in ['electronics', 'computers'] and
367
- instance.stock_quantity > 0 and
368
- instance.rating >= 4.0 and
369
- instance.is_active and
370
- instance.created_at > datetime.now() - timedelta(days=30)
371
- )
372
- )
373
-
374
- # ✅ BETTER: Use custom condition class for complex logic
375
- class IsRecentExpensiveElectronics(HookCondition):
376
- def check(self, instance, original_instance=None):
377
- return (
378
- instance.price > 100 and
379
- instance.category in ['electronics', 'computers'] and
380
- instance.stock_quantity > 0 and
381
- instance.rating >= 4.0 and
382
- instance.is_active and
383
- instance.created_at > datetime.now() - timedelta(days=30)
384
- )
385
-
386
- def get_required_fields(self):
387
- return {'price', 'category', 'stock_quantity', 'rating', 'is_active', 'created_at'}
388
- ```
389
-
390
- ## 🔧 Best Practices for Related Objects
391
-
@@ -0,0 +1,16 @@
1
+ django_bulk_hooks/__init__.py,sha256=b5LIO5oWX9ZVITZddma_E_Hosx8Zy9B3_v3z8HmSykg,1132
2
+ django_bulk_hooks/conditions.py,sha256=iCdIrpVciGsmyKgIEjcC0nl_2-mAxay4Tss-ZaenSuY,13735
3
+ django_bulk_hooks/constants.py,sha256=3x1H1fSUUNo0DZONN7GUVDuySZctTR-jtByBHmAIX5w,303
4
+ django_bulk_hooks/context.py,sha256=HVDT73uSzvgrOR6mdXTvsBm3hLOgBU8ant_mB7VlFuM,380
5
+ django_bulk_hooks/decorators.py,sha256=zstmb27dKcOHu3Atg7cauewCTzPvUmq03mzVKJRi56o,7230
6
+ django_bulk_hooks/engine.py,sha256=kWxAggInO8GhmQfSzJrGATAPgnuG7580llpO9NoxHA8,2897
7
+ django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
8
+ django_bulk_hooks/handler.py,sha256=Qpg_zT6SsQiTlhduvzXxPdG6uynjyR2fBjj-R6HZiXI,4861
9
+ django_bulk_hooks/manager.py,sha256=vyIc7ktNbjXCJrqP7SO7lsamBYFrZSCIQBmXSnpK874,12481
10
+ django_bulk_hooks/models.py,sha256=9KvWkmrR0wbTHN6r7-FrSSO9ViS83NvG7iXLBw_iDZs,4793
11
+ django_bulk_hooks/queryset.py,sha256=7lLqhZ-XOYsZ1I3Loxi4Nhz79M8HlTYE413AW8nyeDI,1330
12
+ django_bulk_hooks/registry.py,sha256=Vh78exKYcdZhM27120kQm-iXGOjd_kf9ZUYBZ8eQ2V0,683
13
+ django_bulk_hooks-0.1.102.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
14
+ django_bulk_hooks-0.1.102.dist-info/METADATA,sha256=xpdCGrDTHoLswDQAXL67JvvvUkyEyTkyQfdbRGDiuz8,9040
15
+ django_bulk_hooks-0.1.102.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
16
+ django_bulk_hooks-0.1.102.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- django_bulk_hooks/__init__.py,sha256=EAWve4HjrrIuPbl8uc1s1ISDM3RPDtwCvTOPRwFpX8w,1392
2
- django_bulk_hooks/conditions.py,sha256=wDtY90Kv3xjWx8HEA4aAjva8fDDaYegJhn0Eu6G0F60,12150
3
- django_bulk_hooks/constants.py,sha256=3x1H1fSUUNo0DZONN7GUVDuySZctTR-jtByBHmAIX5w,303
4
- django_bulk_hooks/context.py,sha256=HVDT73uSzvgrOR6mdXTvsBm3hLOgBU8ant_mB7VlFuM,380
5
- django_bulk_hooks/decorators.py,sha256=zstmb27dKcOHu3Atg7cauewCTzPvUmq03mzVKJRi56o,7230
6
- django_bulk_hooks/engine.py,sha256=qSBvBel3pBWVE92ZzgKjMn2ceQpA7FMws2uMXes1MkA,3626
7
- django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
8
- django_bulk_hooks/handler.py,sha256=tdDolHAJ_Nd7-RT4s9HRyLtM1UWGjRjP1Y_U6Af32Gg,5325
9
- django_bulk_hooks/manager.py,sha256=DcVosEA4RS79KSYgw3Z14_a9Sd8CfxNNc5F3eSb8xc0,11459
10
- django_bulk_hooks/models.py,sha256=U5nCxingZS2sznDjgW8fWo93SisA03WKcGpxxApqhuM,5519
11
- django_bulk_hooks/queryset.py,sha256=7lLqhZ-XOYsZ1I3Loxi4Nhz79M8HlTYE413AW8nyeDI,1330
12
- django_bulk_hooks/registry.py,sha256=Vh78exKYcdZhM27120kQm-iXGOjd_kf9ZUYBZ8eQ2V0,683
13
- django_bulk_hooks-0.1.100.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
14
- django_bulk_hooks-0.1.100.dist-info/METADATA,sha256=AH1pPY4iPjCTvBY5Vyj4dWYJeU5CELEkVt0uRdiW2bg,15415
15
- django_bulk_hooks-0.1.100.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
16
- django_bulk_hooks-0.1.100.dist-info/RECORD,,