django-bulk-hooks 0.1.280__py3-none-any.whl → 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of django-bulk-hooks might be problematic. Click here for more details.

@@ -1,75 +1,68 @@
1
1
  import logging
2
- import threading
3
- from collections import deque
4
2
 
5
- from django.db import transaction
6
-
7
- from django_bulk_hooks.registry import get_hooks, register_hook
3
+ from django_bulk_hooks.registry import register_hook
8
4
 
9
5
  logger = logging.getLogger(__name__)
10
6
 
11
7
 
12
- # Thread-local hook context and hook state
13
- class HookVars(threading.local):
14
- def __init__(self):
15
- self.new = None
16
- self.old = None
17
- self.event = None
18
- self.model = None
19
- self.depth = 0
20
-
21
-
22
- hook_vars = HookVars()
23
-
24
- # Hook queue per thread
25
- _hook_context = threading.local()
26
-
27
-
28
- def get_hook_queue():
29
- if not hasattr(_hook_context, "queue"):
30
- _hook_context.queue = deque()
31
- return _hook_context.queue
32
-
33
-
34
- class HookContextState:
35
- @property
36
- def is_before(self):
37
- return hook_vars.event.startswith("before_") if hook_vars.event else False
38
-
39
- @property
40
- def is_after(self):
41
- return hook_vars.event.startswith("after_") if hook_vars.event else False
42
-
43
- @property
44
- def is_create(self):
45
- return "create" in hook_vars.event if hook_vars.event else False
46
-
47
- @property
48
- def is_update(self):
49
- return "update" in hook_vars.event if hook_vars.event else False
50
-
51
- @property
52
- def new(self):
53
- return hook_vars.new
54
-
55
- @property
56
- def old(self):
57
- return hook_vars.old
8
+ class HookMeta(type):
9
+ _registered = set()
10
+ _class_hook_map: dict[
11
+ type, set[tuple]
12
+ ] = {} # Track which hooks belong to which class
58
13
 
59
- @property
60
- def model(self):
61
- return hook_vars.model
14
+ def __new__(mcs, name, bases, namespace):
15
+ cls = super().__new__(mcs, name, bases, namespace)
16
+ mcs._register_hooks_for_class(cls)
17
+ return cls
62
18
 
19
+ @classmethod
20
+ def _register_hooks_for_class(mcs, cls):
21
+ """
22
+ Register hooks for a given class following OOP inheritance semantics.
23
+
24
+ - Child classes inherit all parent hook methods
25
+ - Child overrides replace parent implementations (not add to them)
26
+ - Child can add new hook methods
27
+ """
28
+ from django_bulk_hooks.registry import register_hook, unregister_hook
29
+
30
+ # Step 1: Unregister ALL hooks from parent classes in the MRO
31
+ # This ensures only the most-derived class owns the active hooks,
32
+ # providing true OOP semantics (overrides replace, others are inherited once).
33
+ for base in cls.__mro__[1:]: # Skip cls itself, start from first parent
34
+ if not isinstance(base, HookMeta):
35
+ continue
63
36
 
64
- HookContext = HookContextState()
37
+ if base in mcs._class_hook_map:
38
+ for model_cls, event, base_cls, method_name in list(
39
+ mcs._class_hook_map[base]
40
+ ):
41
+ key = (model_cls, event, base_cls, method_name)
42
+ if key in HookMeta._registered:
43
+ unregister_hook(model_cls, event, base_cls, method_name)
44
+ HookMeta._registered.discard(key)
45
+ logger.debug(
46
+ f"Unregistered base hook: {base_cls.__name__}.{method_name} "
47
+ f"(superseded by {cls.__name__})"
48
+ )
65
49
 
50
+ # Step 2: Register all hook methods on this class (including inherited ones)
51
+ # Walk the MRO to find ALL methods with hook decorators
52
+ all_hook_methods = {}
53
+ for klass in reversed(cls.__mro__): # Start from most base class
54
+ if not isinstance(klass, HookMeta):
55
+ continue
56
+ for method_name, method in klass.__dict__.items():
57
+ if hasattr(method, "hooks_hooks"):
58
+ # Store with method name as key - child methods will override parent
59
+ all_hook_methods[method_name] = method
66
60
 
67
- class HookMeta(type):
68
- _registered = set()
61
+ # Step 3: Register all hook methods with THIS class as the handler
62
+ if cls not in mcs._class_hook_map:
63
+ mcs._class_hook_map[cls] = set()
69
64
 
70
- def __new__(mcs, name, bases, namespace):
71
- cls = super().__new__(mcs, name, bases, namespace)
72
- for method_name, method in namespace.items():
65
+ for method_name, method in all_hook_methods.items():
73
66
  if hasattr(method, "hooks_hooks"):
74
67
  for model_cls, event, condition, priority in method.hooks_hooks:
75
68
  key = (model_cls, event, cls, method_name)
@@ -83,106 +76,40 @@ class HookMeta(type):
83
76
  priority=priority,
84
77
  )
85
78
  HookMeta._registered.add(key)
86
- return cls
79
+ mcs._class_hook_map[cls].add(key)
80
+ logger.debug(
81
+ f"Registered hook: {cls.__name__}.{method_name} "
82
+ f"for {model_cls.__name__}.{event}"
83
+ )
84
+
85
+ @classmethod
86
+ def re_register_all_hooks(mcs):
87
+ """Re-register all hooks for all existing Hook classes."""
88
+ # Clear the registered set and class hook map so we can re-register
89
+ HookMeta._registered.clear()
90
+ mcs._class_hook_map.clear()
91
+
92
+ # Find all Hook classes and re-register their hooks
93
+ import gc
94
+
95
+ registered_classes = set()
96
+ for obj in gc.get_objects():
97
+ if isinstance(obj, type) and isinstance(obj, HookMeta):
98
+ if obj not in registered_classes:
99
+ registered_classes.add(obj)
100
+ mcs._register_hooks_for_class(obj)
87
101
 
88
102
 
89
103
  class Hook(metaclass=HookMeta):
90
- @classmethod
91
- def handle(
92
- cls,
93
- event: str,
94
- model: type,
95
- *,
96
- new_records: list = None,
97
- old_records: list = None,
98
- **kwargs,
99
- ) -> None:
100
- queue = get_hook_queue()
101
- queue.append((cls, event, model, new_records, old_records, kwargs))
102
- logger.debug(f"Added item to queue: {event}, depth: {hook_vars.depth}")
103
-
104
- # If we're already processing hooks (depth > 0), don't process the queue
105
- # The outermost call will process the entire queue
106
- if hook_vars.depth > 0:
107
- logger.debug(f"Depth > 0, returning without processing queue")
108
- return
109
-
110
- # Process the entire queue
111
- logger.debug(f"Processing queue with {len(queue)} items")
112
- while queue:
113
- item = queue.popleft()
114
- if len(item) == 6:
115
- cls_, event_, model_, new_, old_, kw_ = item
116
- logger.debug(f"Processing queue item: {event_}")
117
- # Call _process on the Hook class, not the calling class
118
- Hook._process(event_, model_, new_, old_, **kw_)
119
- else:
120
- logger.warning(f"Invalid queue item format: {item}")
121
- continue
104
+ """
105
+ Base class for hook handlers.
122
106
 
123
- @classmethod
124
- def _process(
125
- cls,
126
- event,
127
- model,
128
- new_records,
129
- old_records,
130
- **kwargs,
131
- ):
132
- hook_vars.depth += 1
133
- hook_vars.new = new_records
134
- hook_vars.old = old_records
135
- hook_vars.event = event
136
- hook_vars.model = model
137
-
138
- hooks = sorted(get_hooks(model, event), key=lambda x: x[3])
139
- logger.debug(f"Found {len(hooks)} hooks for {event}")
140
-
141
- def _execute():
142
- logger.debug(f"Executing {len(hooks)} hooks for {event}")
143
- new_local = new_records or []
144
- old_local = old_records or []
145
- if len(old_local) < len(new_local):
146
- old_local += [None] * (len(new_local) - len(old_local))
147
-
148
- for handler_cls, method_name, condition, priority in hooks:
149
- logger.debug(f"Processing hook {handler_cls.__name__}.{method_name}")
150
- if condition is not None:
151
- checks = [
152
- condition.check(n, o) for n, o in zip(new_local, old_local)
153
- ]
154
- if not any(checks):
155
- logger.debug(f"Condition failed for {handler_cls.__name__}.{method_name}")
156
- continue
157
-
158
- handler = handler_cls()
159
- method = getattr(handler, method_name)
160
- logger.debug(f"Executing {handler_cls.__name__}.{method_name}")
161
-
162
- try:
163
- method(
164
- new_records=new_local,
165
- old_records=old_local,
166
- **kwargs,
167
- )
168
- logger.debug(f"Successfully executed {handler_cls.__name__}.{method_name}")
169
- except Exception:
170
- logger.exception(
171
- "Error in hook %s.%s", handler_cls.__name__, method_name
172
- )
173
-
174
- conn = transaction.get_connection()
175
- logger.debug(f"Transaction in_atomic_block: {conn.in_atomic_block}, event: {event}")
176
- try:
177
- if conn.in_atomic_block and event.startswith("after_"):
178
- logger.debug(f"Deferring {event} to on_commit")
179
- transaction.on_commit(_execute)
180
- else:
181
- logger.debug(f"Executing {event} immediately")
182
- _execute()
183
- finally:
184
- hook_vars.new = None
185
- hook_vars.old = None
186
- hook_vars.event = None
187
- hook_vars.model = None
188
- hook_vars.depth -= 1
107
+ Hooks are registered via the @hook decorator and executed by
108
+ the HookDispatcher. This class serves as a base for all hook
109
+ handlers and uses HookMeta for automatic registration.
110
+
111
+ All hook execution logic has been moved to HookDispatcher for
112
+ a single, consistent execution path.
113
+ """
114
+
115
+ pass
@@ -0,0 +1,99 @@
1
+ """
2
+ Helper functions for building ChangeSets from operation contexts.
3
+
4
+ These functions eliminate duplication across queryset.py, bulk_operations.py,
5
+ and models.py by providing reusable ChangeSet builders.
6
+
7
+ NOTE: These helpers are pure changeset builders - they don't fetch data.
8
+ Data fetching is the responsibility of ModelAnalyzer.
9
+ """
10
+
11
+ from django_bulk_hooks.changeset import ChangeSet, RecordChange
12
+
13
+
14
+ def build_changeset_for_update(
15
+ model_cls, instances, update_kwargs, old_records_map=None, **meta
16
+ ):
17
+ """
18
+ Build ChangeSet for update operations.
19
+
20
+ Args:
21
+ model_cls: Django model class
22
+ instances: List of instances being updated
23
+ update_kwargs: Dict of fields being updated
24
+ old_records_map: Optional dict of {pk: old_instance}. If None, no old records.
25
+ **meta: Additional metadata (e.g., has_subquery=True, lock_records=False)
26
+
27
+ Returns:
28
+ ChangeSet instance ready for dispatcher
29
+ """
30
+ if old_records_map is None:
31
+ old_records_map = {}
32
+
33
+ changes = [
34
+ RecordChange(
35
+ new, old_records_map.get(new.pk), changed_fields=list(update_kwargs.keys())
36
+ )
37
+ for new in instances
38
+ ]
39
+
40
+ operation_meta = {"update_kwargs": update_kwargs}
41
+ operation_meta.update(meta)
42
+
43
+ return ChangeSet(model_cls, changes, "update", operation_meta)
44
+
45
+
46
+ def build_changeset_for_create(model_cls, instances, **meta):
47
+ """
48
+ Build ChangeSet for create operations.
49
+
50
+ Args:
51
+ model_cls: Django model class
52
+ instances: List of instances being created
53
+ **meta: Additional metadata (e.g., batch_size=1000)
54
+
55
+ Returns:
56
+ ChangeSet instance ready for dispatcher
57
+ """
58
+ changes = [RecordChange(new, None) for new in instances]
59
+ return ChangeSet(model_cls, changes, "create", meta)
60
+
61
+
62
+ def build_changeset_for_delete(model_cls, instances, **meta):
63
+ """
64
+ Build ChangeSet for delete operations.
65
+
66
+ For delete, the "new_record" is the object being deleted (current state),
67
+ and old_record is also the same (or None). This matches Salesforce behavior
68
+ where Hook.new contains the records being deleted.
69
+
70
+ Args:
71
+ model_cls: Django model class
72
+ instances: List of instances being deleted
73
+ **meta: Additional metadata
74
+
75
+ Returns:
76
+ ChangeSet instance ready for dispatcher
77
+ """
78
+ changes = [
79
+ RecordChange(obj, obj) # new_record and old_record are the same for delete
80
+ for obj in instances
81
+ ]
82
+ return ChangeSet(model_cls, changes, "delete", meta)
83
+
84
+
85
+ def dispatch_hooks_for_operation(changeset, event, bypass_hooks=False):
86
+ """
87
+ Dispatch hooks for an operation using the dispatcher.
88
+
89
+ This is a convenience function that wraps the dispatcher call.
90
+
91
+ Args:
92
+ changeset: ChangeSet instance
93
+ event: Event name (e.g., 'before_update', 'after_create')
94
+ bypass_hooks: If True, skip hook execution
95
+ """
96
+ from django_bulk_hooks.dispatcher import get_dispatcher
97
+
98
+ dispatcher = get_dispatcher()
99
+ dispatcher.dispatch(changeset, event, bypass_hooks=bypass_hooks)
@@ -1,20 +1,29 @@
1
1
  from django.db import models
2
2
 
3
- from django_bulk_hooks.queryset import HookQuerySet, HookQuerySetMixin
3
+ from django_bulk_hooks.queryset import HookQuerySet
4
4
 
5
5
 
6
6
  class BulkHookManager(models.Manager):
7
+ """
8
+ Manager that provides hook-aware bulk operations.
9
+
10
+ This is a simple facade that returns HookQuerySet,
11
+ delegating all bulk operations to it.
12
+ """
13
+
7
14
  def get_queryset(self):
8
- # Use super().get_queryset() to let Django and MRO build the queryset
9
- # This ensures cooperation with other managers
15
+ """
16
+ Return a HookQuerySet for this manager.
17
+
18
+ This ensures all bulk operations go through the coordinator.
19
+ """
10
20
  base_queryset = super().get_queryset()
11
21
 
12
- # If the base queryset already has hook functionality, return it as-is
13
- if isinstance(base_queryset, HookQuerySetMixin):
22
+ # If the base queryset is already a HookQuerySet, return it as-is
23
+ if isinstance(base_queryset, HookQuerySet):
14
24
  return base_queryset
15
25
 
16
26
  # Otherwise, create a new HookQuerySet with the same parameters
17
- # This is much simpler and avoids dynamic class creation issues
18
27
  return HookQuerySet(
19
28
  model=base_queryset.model,
20
29
  query=base_queryset.query,
@@ -50,7 +59,14 @@ class BulkHookManager(models.Manager):
50
59
  **kwargs,
51
60
  )
52
61
 
53
- def bulk_update(self, objs, bypass_hooks=False, bypass_validation=False, **kwargs):
62
+ def bulk_update(
63
+ self,
64
+ objs,
65
+ fields=None,
66
+ bypass_hooks=False,
67
+ bypass_validation=False,
68
+ **kwargs,
69
+ ):
54
70
  """
55
71
  Delegate to QuerySet's bulk_update implementation.
56
72
  This follows Django's pattern where Manager methods call QuerySet methods.
@@ -59,6 +75,8 @@ class BulkHookManager(models.Manager):
59
75
  are not supported by bulk_update and will be ignored with a warning.
60
76
  These parameters are only available in bulk_create for UPSERT operations.
61
77
  """
78
+ if fields is not None:
79
+ kwargs["fields"] = fields
62
80
  return self.get_queryset().bulk_update(
63
81
  objs,
64
82
  bypass_hooks=bypass_hooks,
@@ -1,19 +1,7 @@
1
1
  import logging
2
+
2
3
  from django.db import models
3
4
 
4
- from django_bulk_hooks.constants import (
5
- AFTER_CREATE,
6
- AFTER_DELETE,
7
- AFTER_UPDATE,
8
- BEFORE_CREATE,
9
- BEFORE_DELETE,
10
- BEFORE_UPDATE,
11
- VALIDATE_CREATE,
12
- VALIDATE_DELETE,
13
- VALIDATE_UPDATE,
14
- )
15
- from django_bulk_hooks.context import HookContext
16
- from django_bulk_hooks.engine import run
17
5
  from django_bulk_hooks.manager import BulkHookManager
18
6
 
19
7
  logger = logging.getLogger(__name__)
@@ -27,9 +15,9 @@ class HookModelMixin(models.Model):
27
15
 
28
16
  def clean(self, bypass_hooks=False):
29
17
  """
30
- Override clean() to trigger validation hooks.
18
+ Override clean() to hook validation hooks.
31
19
  This ensures that when Django calls clean() (like in admin forms),
32
- it triggers the VALIDATE_* hooks for validation only.
20
+ it hooks the VALIDATE_* hooks for validation only.
33
21
  """
34
22
  super().clean()
35
23
 
@@ -37,79 +25,52 @@ class HookModelMixin(models.Model):
37
25
  if bypass_hooks:
38
26
  return
39
27
 
40
- # Determine if this is a create or update operation
28
+ # Delegate to coordinator (consistent with save/delete)
41
29
  is_create = self.pk is None
42
-
43
- if is_create:
44
- # For create operations, run VALIDATE_CREATE hooks for validation
45
- ctx = HookContext(self.__class__)
46
- run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
47
- else:
48
- # For update operations, run VALIDATE_UPDATE hooks for validation
49
- try:
50
- # Use _base_manager to avoid triggering hooks recursively
51
- old_instance = self.__class__._base_manager.get(pk=self.pk)
52
- ctx = HookContext(self.__class__)
53
- run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
54
- except self.__class__.DoesNotExist:
55
- # If the old instance doesn't exist, treat as create
56
- ctx = HookContext(self.__class__)
57
- run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
30
+ self.__class__.objects.get_queryset().coordinator.clean(
31
+ [self], is_create=is_create
32
+ )
58
33
 
59
34
  def save(self, *args, bypass_hooks=False, **kwargs):
60
- # If bypass_hooks is True, use base manager to avoid triggering hooks
35
+ """
36
+ Save the model instance.
37
+
38
+ Delegates to bulk_create/bulk_update which handle all hook logic
39
+ including MTI parent hooks.
40
+ """
61
41
  if bypass_hooks:
62
- logger.debug(f"save() called with bypass_hooks=True for {self.__class__.__name__} pk={self.pk}")
63
- return self.__class__._base_manager.save(self, *args, **kwargs)
42
+ # Use super().save() to call Django's default save without our hook logic
43
+ return super().save(*args, **kwargs)
64
44
 
65
45
  is_create = self.pk is None
66
46
 
67
47
  if is_create:
68
- logger.debug(f"save() creating new {self.__class__.__name__} instance")
69
- # For create operations, we don't have old records
70
- ctx = HookContext(self.__class__)
71
- run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
72
-
73
- super().save(*args, **kwargs)
74
-
75
- run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
48
+ # Delegate to bulk_create which handles all hook logic
49
+ result = self.__class__.objects.bulk_create([self])
50
+ return result[0] if result else self
76
51
  else:
77
- logger.debug(f"save() updating existing {self.__class__.__name__} instance pk={self.pk}")
78
- # For update operations, we need to get the old record
79
- try:
80
- # Use _base_manager to avoid triggering hooks recursively
81
- old_instance = self.__class__._base_manager.get(pk=self.pk)
82
- ctx = HookContext(self.__class__)
83
- run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
84
-
85
- super().save(*args, **kwargs)
86
-
87
- run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
88
- except self.__class__.DoesNotExist:
89
- # If the old instance doesn't exist, treat as create
90
- ctx = HookContext(self.__class__)
91
- run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
92
-
93
- super().save(*args, **kwargs)
94
-
95
- run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
96
-
97
- return self
52
+ # Delegate to bulk_update which handles all hook logic
53
+ update_fields = kwargs.get("update_fields")
54
+ if update_fields is None:
55
+ # Update all non-auto fields
56
+ update_fields = [
57
+ f.name
58
+ for f in self.__class__._meta.fields
59
+ if not f.auto_created and f.name != "id"
60
+ ]
61
+ self.__class__.objects.bulk_update([self], update_fields)
62
+ return self
98
63
 
99
64
  def delete(self, *args, bypass_hooks=False, **kwargs):
100
- # If bypass_hooks is True, use base manager to avoid triggering hooks
101
- if bypass_hooks:
102
- return self.__class__._base_manager.delete(self, *args, **kwargs)
103
-
104
- ctx = HookContext(self.__class__)
105
-
106
- # Run validation hooks first
107
- run(self.__class__, VALIDATE_DELETE, [self], ctx=ctx)
108
-
109
- # Then run business logic hooks
110
- run(self.__class__, BEFORE_DELETE, [self], ctx=ctx)
65
+ """
66
+ Delete the model instance.
111
67
 
112
- result = super().delete(*args, **kwargs)
68
+ Delegates to bulk_delete which handles all hook logic
69
+ including MTI parent hooks.
70
+ """
71
+ if bypass_hooks:
72
+ # Use super().delete() to call Django's default delete without our hook logic
73
+ return super().delete(*args, **kwargs)
113
74
 
114
- run(self.__class__, AFTER_DELETE, [self], ctx=ctx)
115
- return result
75
+ # Delegate to bulk_delete (handles both MTI and non-MTI)
76
+ return self.__class__.objects.filter(pk=self.pk).delete()
@@ -0,0 +1,18 @@
1
+ """
2
+ Operations module for django-bulk-hooks.
3
+
4
+ This module contains all services for bulk operations following
5
+ a clean, service-based architecture.
6
+ """
7
+
8
+ from django_bulk_hooks.operations.coordinator import BulkOperationCoordinator
9
+ from django_bulk_hooks.operations.analyzer import ModelAnalyzer
10
+ from django_bulk_hooks.operations.bulk_executor import BulkExecutor
11
+ from django_bulk_hooks.operations.mti_handler import MTIHandler
12
+
13
+ __all__ = [
14
+ 'BulkOperationCoordinator',
15
+ 'ModelAnalyzer',
16
+ 'BulkExecutor',
17
+ 'MTIHandler',
18
+ ]