django-bulk-hooks 0.1.227__py3-none-any.whl → 0.1.229__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.

@@ -2,8 +2,6 @@ import logging
2
2
  import threading
3
3
  from collections import deque
4
4
 
5
- from django.db import transaction
6
-
7
5
  from django_bulk_hooks.registry import get_hooks, register_hook
8
6
 
9
7
  logger = logging.getLogger(__name__)
@@ -61,36 +59,40 @@ class HookContextState:
61
59
  return hook_vars.model
62
60
 
63
61
 
64
- Hook = HookContextState()
65
-
66
-
67
62
  class HookMeta(type):
68
63
  """Metaclass that automatically registers hooks when Hook classes are defined."""
69
64
 
70
65
  def __new__(mcs, name, bases, namespace):
71
66
  cls = super().__new__(mcs, name, bases, namespace)
72
67
 
73
- # Register hooks for this class
74
- for method_name, method in namespace.items():
75
- if hasattr(method, "hooks_hooks"):
76
- for model_cls, event, condition, priority in method.hooks_hooks:
77
- # Create a unique key for this hook registration
78
- key = (model_cls, event, cls, method_name)
79
-
80
- # Register the hook
81
- register_hook(
82
- model=model_cls,
83
- event=event,
84
- handler_cls=cls,
85
- method_name=method_name,
86
- condition=condition,
87
- priority=priority,
88
- )
89
-
90
- logger.debug(
91
- f"Registered hook {cls.__name__}.{method_name} "
92
- f"for {model_cls.__name__}.{event} with priority {priority}"
93
- )
68
+ # Register hooks for this class, including inherited methods
69
+ # We need to check all methods in the MRO to handle inheritance
70
+ for attr_name in dir(cls):
71
+ if attr_name.startswith('_'):
72
+ continue
73
+
74
+ try:
75
+ attr = getattr(cls, attr_name)
76
+ if callable(attr) and hasattr(attr, "hooks_hooks"):
77
+ for model_cls, event, condition, priority in attr.hooks_hooks:
78
+ # Register the hook
79
+ register_hook(
80
+ model=model_cls,
81
+ event=event,
82
+ handler_cls=cls,
83
+ method_name=attr_name,
84
+ condition=condition,
85
+ priority=priority,
86
+ )
87
+
88
+ logger.debug(
89
+ f"Registered hook {cls.__name__}.{attr_name} "
90
+ f"for {model_cls.__name__}.{event} with priority {priority}"
91
+ )
92
+ except Exception as e:
93
+ # Skip attributes that can't be accessed
94
+ logger.debug(f"Skipping attribute {attr_name}: {e}")
95
+ continue
94
96
 
95
97
  return cls
96
98
 
@@ -132,7 +134,7 @@ class Hook(metaclass=HookMeta):
132
134
  hook_vars.event = event
133
135
  hook_vars.model = model
134
136
 
135
- hooks = sorted(get_hooks(model, event), key=lambda x: x[3])
137
+ hooks = get_hooks(model, event)
136
138
 
137
139
  def _execute():
138
140
  new_local = new_records or []
@@ -161,16 +163,18 @@ class Hook(metaclass=HookMeta):
161
163
  logger.exception(
162
164
  "Error in hook %s.%s", handler_cls.__name__, method_name
163
165
  )
166
+ # Re-raise the exception to ensure proper error handling
167
+ raise
164
168
 
165
- conn = transaction.get_connection()
166
169
  try:
167
- if conn.in_atomic_block and event.startswith("after_"):
168
- transaction.on_commit(_execute)
169
- else:
170
- _execute()
170
+ _execute()
171
171
  finally:
172
172
  hook_vars.new = None
173
173
  hook_vars.old = None
174
174
  hook_vars.event = None
175
175
  hook_vars.model = None
176
176
  hook_vars.depth -= 1
177
+
178
+
179
+ # Create a global Hook instance for context access
180
+ HookContext = HookContextState()
@@ -1,113 +1,135 @@
1
+ from typing import Iterable, Sequence, Any
1
2
  from django.db import models
2
3
 
3
4
  from django_bulk_hooks.queryset import HookQuerySet, HookQuerySetMixin
4
5
 
5
6
 
6
7
  class BulkHookManager(models.Manager):
7
- def get_queryset(self):
8
- # Use super().get_queryset() to let Django and MRO build the queryset
9
- # This ensures cooperation with other managers
10
- base_queryset = super().get_queryset()
11
-
12
- # If the base queryset already has hook functionality, return it as-is
13
- if isinstance(base_queryset, HookQuerySetMixin):
14
- return base_queryset
15
-
16
- # Otherwise, create a new HookQuerySet with the same parameters
17
- # This is much simpler and avoids dynamic class creation issues
18
- return HookQuerySet(
19
- model=base_queryset.model,
20
- query=base_queryset.query,
21
- using=base_queryset._db,
22
- hints=base_queryset._hints
23
- )
8
+ """Manager that ensures all queryset operations are hook-aware.
24
9
 
25
- def bulk_create(
26
- self,
27
- objs,
28
- batch_size=None,
29
- ignore_conflicts=False,
30
- update_conflicts=False,
31
- update_fields=None,
32
- unique_fields=None,
33
- bypass_hooks=False,
34
- bypass_validation=False,
35
- **kwargs,
36
- ):
37
- """
38
- Delegate to QuerySet's bulk_create implementation.
39
- This follows Django's pattern where Manager methods call QuerySet methods.
40
- """
41
- return self.get_queryset().bulk_create(
42
- objs,
43
- bypass_hooks=bypass_hooks,
44
- bypass_validation=bypass_validation,
45
- batch_size=batch_size,
46
- ignore_conflicts=ignore_conflicts,
47
- update_conflicts=update_conflicts,
48
- update_fields=update_fields,
49
- unique_fields=unique_fields,
50
- **kwargs,
51
- )
10
+ Delegates operations to a hook-enabled queryset while preserving any
11
+ customizations from other managers in the MRO by starting with
12
+ ``super().get_queryset()``.
13
+ """
52
14
 
53
- def bulk_update(
54
- self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
55
- ):
56
- """
57
- Delegate to QuerySet's bulk_update implementation.
58
- This follows Django's pattern where Manager methods call QuerySet methods.
59
- """
60
- return self.get_queryset().bulk_update(
61
- objs,
62
- fields,
63
- bypass_hooks=bypass_hooks,
64
- bypass_validation=bypass_validation,
65
- **kwargs,
66
- )
15
+ # Cache for composed queryset classes to preserve custom queryset APIs
16
+ _qs_compose_cache = {}
67
17
 
68
- def bulk_delete(
69
- self,
70
- objs,
71
- batch_size=None,
72
- bypass_hooks=False,
73
- bypass_validation=False,
74
- **kwargs,
75
- ):
76
- """
77
- Delegate to QuerySet's bulk_delete implementation.
78
- This follows Django's pattern where Manager methods call QuerySet methods.
79
- """
80
- return self.get_queryset().bulk_delete(
81
- objs,
82
- bypass_hooks=bypass_hooks,
83
- bypass_validation=bypass_validation,
84
- batch_size=batch_size,
85
- **kwargs,
86
- )
18
+ def get_queryset(self) -> HookQuerySet:
19
+ # Use super().get_queryset() to let Django and MRO build the queryset
20
+ base_queryset = super().get_queryset()
87
21
 
88
- def delete(self):
89
- """
90
- Delegate to QuerySet's delete implementation.
91
- This follows Django's pattern where Manager methods call QuerySet methods.
92
- """
93
- return self.get_queryset().delete()
22
+ # If the base queryset already has hook functionality, return it as-is
23
+ if isinstance(base_queryset, HookQuerySetMixin):
24
+ return base_queryset # type: ignore[return-value]
94
25
 
95
- def update(self, **kwargs):
96
- """
97
- Delegate to QuerySet's update implementation.
98
- This follows Django's pattern where Manager methods call QuerySet methods.
99
- """
100
- return self.get_queryset().update(**kwargs)
26
+ # Otherwise, dynamically compose a queryset class that preserves the
27
+ # base queryset's custom API while adding hook behavior
28
+ base_cls = base_queryset.__class__
29
+ composed_cls = self._qs_compose_cache.get(base_cls)
30
+ if composed_cls is None:
31
+ composed_name = f"ComposedHookQuerySet_{base_cls.__name__}"
32
+ composed_cls = type(composed_name, (HookQuerySetMixin, base_cls), {})
33
+ self._qs_compose_cache[base_cls] = composed_cls
101
34
 
102
- def save(self, obj):
103
- """
104
- Save a single object using the appropriate bulk operation.
105
- """
106
- if obj.pk:
107
- self.bulk_update(
108
- [obj],
109
- fields=[field.name for field in obj._meta.fields if field.name != "id"],
110
- )
111
- else:
112
- self.bulk_create([obj])
113
- return obj
35
+ return composed_cls(
36
+ model=base_queryset.model,
37
+ query=base_queryset.query,
38
+ using=base_queryset._db,
39
+ hints=base_queryset._hints,
40
+ )
41
+
42
+ def bulk_create(
43
+ self,
44
+ objs: Iterable[models.Model],
45
+ batch_size: int | None = None,
46
+ ignore_conflicts: bool = False,
47
+ update_conflicts: bool = False,
48
+ update_fields: Sequence[str] | None = None,
49
+ unique_fields: Sequence[str] | None = None,
50
+ bypass_hooks: bool = False,
51
+ bypass_validation: bool = False,
52
+ **kwargs: Any,
53
+ ) -> list[models.Model]:
54
+ """
55
+ Delegate to QuerySet's bulk_create implementation.
56
+ This follows Django's pattern where Manager methods call QuerySet methods.
57
+ """
58
+ return self.get_queryset().bulk_create(
59
+ objs,
60
+ bypass_hooks=bypass_hooks,
61
+ bypass_validation=bypass_validation,
62
+ batch_size=batch_size,
63
+ ignore_conflicts=ignore_conflicts,
64
+ update_conflicts=update_conflicts,
65
+ update_fields=update_fields,
66
+ unique_fields=unique_fields,
67
+ **kwargs,
68
+ )
69
+
70
+ def bulk_update(
71
+ self,
72
+ objs: Iterable[models.Model],
73
+ fields: Sequence[str],
74
+ bypass_hooks: bool = False,
75
+ bypass_validation: bool = False,
76
+ **kwargs: Any,
77
+ ) -> int:
78
+ """
79
+ Delegate to QuerySet's bulk_update implementation.
80
+ This follows Django's pattern where Manager methods call QuerySet methods.
81
+ """
82
+ return self.get_queryset().bulk_update(
83
+ objs,
84
+ fields,
85
+ bypass_hooks=bypass_hooks,
86
+ bypass_validation=bypass_validation,
87
+ **kwargs,
88
+ )
89
+
90
+ def bulk_delete(
91
+ self,
92
+ objs: Iterable[models.Model],
93
+ batch_size: int | None = None,
94
+ bypass_hooks: bool = False,
95
+ bypass_validation: bool = False,
96
+ **kwargs: Any,
97
+ ) -> int:
98
+ """
99
+ Delegate to QuerySet's bulk_delete implementation.
100
+ This follows Django's pattern where Manager methods call QuerySet methods.
101
+ """
102
+ return self.get_queryset().bulk_delete(
103
+ objs,
104
+ bypass_hooks=bypass_hooks,
105
+ bypass_validation=bypass_validation,
106
+ batch_size=batch_size,
107
+ **kwargs,
108
+ )
109
+
110
+ def delete(self) -> int:
111
+ """
112
+ Delegate to QuerySet's delete implementation.
113
+ This follows Django's pattern where Manager methods call QuerySet methods.
114
+ """
115
+ return self.get_queryset().delete()
116
+
117
+ def update(self, **kwargs: Any) -> int:
118
+ """
119
+ Delegate to QuerySet's update implementation.
120
+ This follows Django's pattern where Manager methods call QuerySet methods.
121
+ """
122
+ return self.get_queryset().update(**kwargs)
123
+
124
+ def save(self, obj: models.Model) -> models.Model:
125
+ """
126
+ Save a single object using the appropriate bulk operation.
127
+ """
128
+ if obj.pk:
129
+ self.bulk_update(
130
+ [obj],
131
+ fields=[field.name for field in obj._meta.fields if field.name != "id"],
132
+ )
133
+ else:
134
+ self.bulk_create([obj])
135
+ return obj
@@ -1,6 +1,5 @@
1
1
  import logging
2
- from django.db import models
3
-
2
+ from django.db import models, transaction
4
3
  from django_bulk_hooks.constants import (
5
4
  AFTER_CREATE,
6
5
  AFTER_DELETE,
@@ -42,18 +41,18 @@ class HookModelMixin(models.Model):
42
41
 
43
42
  if is_create:
44
43
  # For create operations, run VALIDATE_CREATE hooks for validation
45
- ctx = HookContext(self.__class__)
44
+ ctx = HookContext(self.__class__, bypass_hooks)
46
45
  run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
47
46
  else:
48
47
  # For update operations, run VALIDATE_UPDATE hooks for validation
49
48
  try:
50
49
  # Use _base_manager to avoid triggering hooks recursively
51
50
  old_instance = self.__class__._base_manager.get(pk=self.pk)
52
- ctx = HookContext(self.__class__)
51
+ ctx = HookContext(self.__class__, bypass_hooks)
53
52
  run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
54
53
  except self.__class__.DoesNotExist:
55
54
  # If the old instance doesn't exist, treat as create
56
- ctx = HookContext(self.__class__)
55
+ ctx = HookContext(self.__class__, bypass_hooks)
57
56
  run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
58
57
 
59
58
  def save(self, *args, bypass_hooks=False, **kwargs):
@@ -62,12 +61,27 @@ class HookModelMixin(models.Model):
62
61
  logger.debug(f"save() called with bypass_hooks=True for {self.__class__.__name__} pk={self.pk}")
63
62
  return self._base_manager.save(self, *args, **kwargs)
64
63
 
65
- is_create = self.pk is None
64
+ # Only create a new transaction if we're not already in one
65
+ # This allows for proper nested transaction handling
66
+ from django.db import connection
67
+ if connection.in_atomic_block:
68
+ # We're already in a transaction, don't create a new one
69
+ return self._save_with_hooks(*args, **kwargs)
70
+ else:
71
+ # We're not in a transaction, so create one
72
+ with transaction.atomic():
73
+ return self._save_with_hooks(*args, **kwargs)
66
74
 
75
+ def _save_with_hooks(self, *args, **kwargs):
76
+ """Internal method to handle save with hooks."""
77
+ is_create = self.pk is None
78
+
67
79
  if is_create:
68
80
  logger.debug(f"save() creating new {self.__class__.__name__} instance")
69
81
  # For create operations, we don't have old records
70
- ctx = HookContext(self.__class__)
82
+ ctx = HookContext(self.__class__, bypass_hooks=False)
83
+
84
+ # Run hooks - if any fail, the transaction will be rolled back
71
85
  run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
72
86
  run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
73
87
 
@@ -80,7 +94,9 @@ class HookModelMixin(models.Model):
80
94
  try:
81
95
  # Use _base_manager to avoid triggering hooks recursively
82
96
  old_instance = self.__class__._base_manager.get(pk=self.pk)
83
- ctx = HookContext(self.__class__)
97
+ ctx = HookContext(self.__class__, bypass_hooks=False)
98
+
99
+ # Run hooks - if any fail, the transaction will be rolled back
84
100
  run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
85
101
  run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
86
102
 
@@ -89,7 +105,9 @@ class HookModelMixin(models.Model):
89
105
  run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
90
106
  except self.__class__.DoesNotExist:
91
107
  # If the old instance doesn't exist, treat as create
92
- ctx = HookContext(self.__class__)
108
+ ctx = HookContext(self.__class__, bypass_hooks=False)
109
+
110
+ # Run hooks - if any fail, the transaction will be rolled back
93
111
  run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
94
112
  run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
95
113
 
@@ -104,9 +122,22 @@ class HookModelMixin(models.Model):
104
122
  if bypass_hooks:
105
123
  return self._base_manager.delete(self, *args, **kwargs)
106
124
 
107
- ctx = HookContext(self.__class__)
125
+ # Only create a new transaction if we're not already in one
126
+ # This allows for proper nested transaction handling
127
+ from django.db import connection
128
+ if connection.in_atomic_block:
129
+ # We're already in a transaction, don't create a new one
130
+ return self._delete_with_hooks(*args, **kwargs)
131
+ else:
132
+ # We're not in a transaction, so create one
133
+ with transaction.atomic():
134
+ return self._delete_with_hooks(*args, **kwargs)
135
+
136
+ def _delete_with_hooks(self, *args, **kwargs):
137
+ """Internal method to handle delete with hooks."""
138
+ ctx = HookContext(self.__class__, bypass_hooks=False)
108
139
 
109
- # Run validation hooks first
140
+ # Run hooks - if any fail, the transaction will be rolled back
110
141
  run(self.__class__, VALIDATE_DELETE, [self], ctx=ctx)
111
142
 
112
143
  # Then run business logic hooks
@@ -5,12 +5,12 @@ class Priority(IntEnum):
5
5
  """
6
6
  Named priorities for django-bulk-hooks hooks.
7
7
 
8
- Lower values run earlier (higher priority).
9
- Hooks are sorted in ascending order.
8
+ Higher values run earlier (higher priority).
9
+ Hooks are sorted in descending order.
10
10
  """
11
11
 
12
- HIGHEST = 0 # runs first
13
- HIGH = 25 # runs early
12
+ LOWEST = 0 # runs last
13
+ LOW = 25 # runs later
14
14
  NORMAL = 50 # default ordering
15
- LOW = 75 # runs later
16
- LOWEST = 100 # runs last
15
+ HIGH = 75 # runs early
16
+ HIGHEST = 100 # runs first