django-bulk-hooks 0.2.9__py3-none-any.whl → 0.2.93__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,288 +1,276 @@
1
- """
2
- Central registry for hook handlers.
3
-
4
- Provides thread-safe registration and lookup of hooks with
5
- deterministic priority ordering.
6
- """
7
-
8
- import logging
9
- import threading
10
- from collections.abc import Callable
11
- from typing import Dict, List, Optional, Tuple, Type, Union
12
-
13
- from django_bulk_hooks.enums import Priority
14
-
15
- logger = logging.getLogger(__name__)
16
-
17
- # Type alias for hook info tuple
18
- HookInfo = Tuple[Type, str, Optional[Callable], int]
19
-
20
-
21
- class HookRegistry:
22
- """
23
- Central registry for all hook handlers.
24
-
25
- Manages registration, lookup, and lifecycle of hooks with
26
- thread-safe operations and deterministic ordering by priority.
27
-
28
- This is a singleton - use get_registry() to access the instance.
29
- """
30
-
31
- def __init__(self):
32
- """Initialize an empty registry with thread-safe storage."""
33
- self._hooks: Dict[Tuple[Type, str], List[HookInfo]] = {}
34
- self._lock = threading.RLock()
35
-
36
- def register(
37
- self,
38
- model: Type,
39
- event: str,
40
- handler_cls: Type,
41
- method_name: str,
42
- condition: Optional[Callable],
43
- priority: Union[int, Priority],
44
- ) -> None:
45
- """
46
- Register a hook handler for a model and event.
47
-
48
- Args:
49
- model: Django model class
50
- event: Event name (e.g., 'after_update', 'before_create')
51
- handler_cls: Hook handler class
52
- method_name: Name of the method to call on handler
53
- condition: Optional condition to filter records
54
- priority: Execution priority (lower values execute first)
55
- """
56
- with self._lock:
57
- key = (model, event)
58
- hooks = self._hooks.setdefault(key, [])
59
-
60
- # Check for duplicates before adding
61
- hook_info = (handler_cls, method_name, condition, priority)
62
- if hook_info not in hooks:
63
- hooks.append(hook_info)
64
- # Sort by priority (lower values first)
65
- hooks.sort(key=lambda x: x[3])
66
- logger.debug(
67
- f"Registered {handler_cls.__name__}.{method_name} "
68
- f"for {model.__name__}.{event} (priority={priority})"
69
- )
70
- else:
71
- logger.debug(
72
- f"Hook {handler_cls.__name__}.{method_name} "
73
- f"already registered for {model.__name__}.{event}"
74
- )
75
-
76
- def get_hooks(self, model: Type, event: str) -> List[HookInfo]:
77
- """
78
- Get all hooks for a model and event.
79
-
80
- Args:
81
- model: Django model class
82
- event: Event name
83
-
84
- Returns:
85
- List of hook info tuples (handler_cls, method_name, condition, priority)
86
- sorted by priority (lower values first)
87
- """
88
- with self._lock:
89
- key = (model, event)
90
- hooks = self._hooks.get(key, [])
91
-
92
- # Only log when hooks are found or for specific events to reduce noise
93
- if hooks or event in [
94
- "after_update",
95
- "before_update",
96
- "after_create",
97
- "before_create",
98
- ]:
99
- logger.debug(
100
- f"get_hooks {model.__name__}.{event} found {len(hooks)} hooks"
101
- )
102
-
103
- return hooks
104
-
105
- def unregister(
106
- self, model: Type, event: str, handler_cls: Type, method_name: str
107
- ) -> None:
108
- """
109
- Unregister a specific hook handler.
110
-
111
- Used when child classes override parent hook methods.
112
-
113
- Args:
114
- model: Django model class
115
- event: Event name
116
- handler_cls: Hook handler class to remove
117
- method_name: Method name to remove
118
- """
119
- with self._lock:
120
- key = (model, event)
121
- if key not in self._hooks:
122
- return
123
-
124
- hooks = self._hooks[key]
125
- # Filter out the specific hook
126
- self._hooks[key] = [
127
- (h_cls, m_name, cond, pri)
128
- for h_cls, m_name, cond, pri in hooks
129
- if not (h_cls == handler_cls and m_name == method_name)
130
- ]
131
-
132
- # Clean up empty hook lists
133
- if not self._hooks[key]:
134
- del self._hooks[key]
135
-
136
- logger.debug(
137
- f"Unregistered {handler_cls.__name__}.{method_name} "
138
- f"for {model.__name__}.{event}"
139
- )
140
-
141
- def clear(self) -> None:
142
- """
143
- Clear all registered hooks.
144
-
145
- Useful for testing to ensure clean state between tests.
146
- """
147
- with self._lock:
148
- self._hooks.clear()
149
-
150
- # Also clear HookMeta state to ensure complete reset
151
- from django_bulk_hooks.handler import HookMeta
152
-
153
- HookMeta._registered.clear()
154
- HookMeta._class_hook_map.clear()
155
-
156
- logger.debug("Cleared all registered hooks")
157
-
158
- def list_all(self) -> Dict[Tuple[Type, str], List[HookInfo]]:
159
- """
160
- Get all registered hooks for debugging.
161
-
162
- Returns:
163
- Dictionary mapping (model, event) tuples to lists of hook info
164
- """
165
- with self._lock:
166
- return dict(self._hooks)
167
-
168
- def count_hooks(
169
- self, model: Optional[Type] = None, event: Optional[str] = None
170
- ) -> int:
171
- """
172
- Count registered hooks, optionally filtered by model and/or event.
173
-
174
- Args:
175
- model: Optional model class to filter by
176
- event: Optional event name to filter by
177
-
178
- Returns:
179
- Number of matching hooks
180
- """
181
- with self._lock:
182
- if model is None and event is None:
183
- # Count all hooks
184
- return sum(len(hooks) for hooks in self._hooks.values())
185
- elif model is not None and event is not None:
186
- # Count hooks for specific model and event
187
- return len(self._hooks.get((model, event), []))
188
- elif model is not None:
189
- # Count all hooks for a model
190
- return sum(
191
- len(hooks)
192
- for (m, _), hooks in self._hooks.items()
193
- if m == model
194
- )
195
- else: # event is not None
196
- # Count all hooks for an event
197
- return sum(
198
- len(hooks)
199
- for (_, e), hooks in self._hooks.items()
200
- if e == event
201
- )
202
-
203
-
204
- # Global singleton registry
205
- _registry: Optional[HookRegistry] = None
206
- _registry_lock = threading.Lock()
207
-
208
-
209
- def get_registry() -> HookRegistry:
210
- """
211
- Get the global hook registry instance.
212
-
213
- Creates the registry on first access (singleton pattern).
214
- Thread-safe initialization.
215
-
216
- Returns:
217
- HookRegistry singleton instance
218
- """
219
- global _registry
220
-
221
- if _registry is None:
222
- with _registry_lock:
223
- # Double-checked locking
224
- if _registry is None:
225
- _registry = HookRegistry()
226
-
227
- return _registry
228
-
229
-
230
- # Backward-compatible module-level functions
231
- def register_hook(
232
- model: Type,
233
- event: str,
234
- handler_cls: Type,
235
- method_name: str,
236
- condition: Optional[Callable],
237
- priority: Union[int, Priority],
238
- ) -> None:
239
- """
240
- Register a hook handler (backward-compatible function).
241
-
242
- Delegates to the global registry instance.
243
- """
244
- registry = get_registry()
245
- registry.register(model, event, handler_cls, method_name, condition, priority)
246
-
247
-
248
- def get_hooks(model: Type, event: str) -> List[HookInfo]:
249
- """
250
- Get hooks for a model and event (backward-compatible function).
251
-
252
- Delegates to the global registry instance.
253
- """
254
- registry = get_registry()
255
- return registry.get_hooks(model, event)
256
-
257
-
258
- def unregister_hook(
259
- model: Type, event: str, handler_cls: Type, method_name: str
260
- ) -> None:
261
- """
262
- Unregister a hook handler (backward-compatible function).
263
-
264
- Delegates to the global registry instance.
265
- """
266
- registry = get_registry()
267
- registry.unregister(model, event, handler_cls, method_name)
268
-
269
-
270
- def clear_hooks() -> None:
271
- """
272
- Clear all registered hooks (backward-compatible function).
273
-
274
- Delegates to the global registry instance.
275
- Useful for testing.
276
- """
277
- registry = get_registry()
278
- registry.clear()
279
-
280
-
281
- def list_all_hooks() -> Dict[Tuple[Type, str], List[HookInfo]]:
282
- """
283
- List all registered hooks (backward-compatible function).
284
-
285
- Delegates to the global registry instance.
286
- """
287
- registry = get_registry()
288
- return registry.list_all()
1
+ """
2
+ Central registry for hook handlers.
3
+
4
+ Provides thread-safe registration and lookup of hooks with
5
+ deterministic priority ordering.
6
+ """
7
+
8
+ import logging
9
+ import threading
10
+ from collections.abc import Callable
11
+
12
+ from django_bulk_hooks.enums import Priority
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Type alias for hook info tuple
17
+ HookInfo = tuple[type, str, Callable | None, int]
18
+
19
+
20
+ class HookRegistry:
21
+ """
22
+ Central registry for all hook handlers.
23
+
24
+ Manages registration, lookup, and lifecycle of hooks with
25
+ thread-safe operations and deterministic ordering by priority.
26
+
27
+ This is a singleton - use get_registry() to access the instance.
28
+ """
29
+
30
+ def __init__(self):
31
+ """Initialize an empty registry with thread-safe storage."""
32
+ self._hooks: dict[tuple[type, str], list[HookInfo]] = {}
33
+ self._lock = threading.RLock()
34
+
35
+ def register(
36
+ self,
37
+ model: type,
38
+ event: str,
39
+ handler_cls: type,
40
+ method_name: str,
41
+ condition: Callable | None,
42
+ priority: int | Priority,
43
+ ) -> None:
44
+ """
45
+ Register a hook handler for a model and event.
46
+
47
+ Args:
48
+ model: Django model class
49
+ event: Event name (e.g., 'after_update', 'before_create')
50
+ handler_cls: Hook handler class
51
+ method_name: Name of the method to call on handler
52
+ condition: Optional condition to filter records
53
+ priority: Execution priority (lower values execute first)
54
+ """
55
+ with self._lock:
56
+ key = (model, event)
57
+ hooks = self._hooks.setdefault(key, [])
58
+
59
+ # Check for duplicates before adding
60
+ hook_info = (handler_cls, method_name, condition, priority)
61
+ if hook_info not in hooks:
62
+ hooks.append(hook_info)
63
+ # Sort by priority (lower values first)
64
+ hooks.sort(key=lambda x: x[3])
65
+ else:
66
+ pass # Hook already registered
67
+
68
+ def get_hooks(self, model: type, event: str) -> list[HookInfo]:
69
+ """
70
+ Get all hooks for a model and event.
71
+
72
+ Args:
73
+ model: Django model class
74
+ event: Event name
75
+
76
+ Returns:
77
+ List of hook info tuples (handler_cls, method_name, condition, priority)
78
+ sorted by priority (lower values first)
79
+ """
80
+ with self._lock:
81
+ key = (model, event)
82
+ hooks = self._hooks.get(key, [])
83
+ logger.debug(f"Retrieved {len(hooks)} hooks for {model.__name__}.{event}")
84
+ return hooks
85
+
86
+ def unregister(
87
+ self,
88
+ model: type,
89
+ event: str,
90
+ handler_cls: type,
91
+ method_name: str,
92
+ ) -> None:
93
+ """
94
+ Unregister a specific hook handler.
95
+
96
+ Used when child classes override parent hook methods.
97
+
98
+ Args:
99
+ model: Django model class
100
+ event: Event name
101
+ handler_cls: Hook handler class to remove
102
+ method_name: Method name to remove
103
+ """
104
+ with self._lock:
105
+ key = (model, event)
106
+ if key not in self._hooks:
107
+ return
108
+
109
+ hooks = self._hooks[key]
110
+ # Filter out the specific hook
111
+ self._hooks[key] = [
112
+ (h_cls, m_name, cond, pri) for h_cls, m_name, cond, pri in hooks if not (h_cls == handler_cls and m_name == method_name)
113
+ ]
114
+
115
+ # Clean up empty hook lists
116
+ if not self._hooks[key]:
117
+ del self._hooks[key]
118
+
119
+ def clear(self) -> None:
120
+ """
121
+ Clear all registered hooks.
122
+
123
+ Useful for testing to ensure clean state between tests.
124
+ """
125
+ with self._lock:
126
+ self._hooks.clear()
127
+
128
+ # Also clear HookMeta state to ensure complete reset
129
+ from django_bulk_hooks.handler import HookMeta
130
+
131
+ HookMeta._registered.clear()
132
+ HookMeta._class_hook_map.clear()
133
+
134
+ def list_all(self) -> dict[tuple[type, str], list[HookInfo]]:
135
+ """
136
+ Get all registered hooks for debugging.
137
+
138
+ Returns:
139
+ Dictionary mapping (model, event) tuples to lists of hook info
140
+ """
141
+ with self._lock:
142
+ return dict(self._hooks)
143
+
144
+ @property
145
+ def hooks(self) -> dict[tuple[type, str], list[HookInfo]]:
146
+ """
147
+ Expose internal hooks dictionary for testing purposes.
148
+
149
+ This property provides direct access to the internal hooks storage
150
+ to allow tests to clear the registry state between test runs.
151
+ """
152
+ return self._hooks
153
+
154
+ def count_hooks(
155
+ self,
156
+ model: type | None = None,
157
+ event: str | None = None,
158
+ ) -> int:
159
+ """
160
+ Count registered hooks, optionally filtered by model and/or event.
161
+
162
+ Args:
163
+ model: Optional model class to filter by
164
+ event: Optional event name to filter by
165
+
166
+ Returns:
167
+ Number of matching hooks
168
+ """
169
+ with self._lock:
170
+ if model is None and event is None:
171
+ # Count all hooks
172
+ return sum(len(hooks) for hooks in self._hooks.values())
173
+ if model is not None and event is not None:
174
+ # Count hooks for specific model and event
175
+ return len(self._hooks.get((model, event), []))
176
+ if model is not None:
177
+ # Count all hooks for a model
178
+ return sum(len(hooks) for (m, _), hooks in self._hooks.items() if m == model)
179
+ # event is not None
180
+ # Count all hooks for an event
181
+ return sum(len(hooks) for (_, e), hooks in self._hooks.items() if e == event)
182
+
183
+
184
+ # Global singleton registry
185
+ _registry: HookRegistry | None = None
186
+ _registry_lock = threading.Lock()
187
+
188
+
189
+ def get_registry() -> HookRegistry:
190
+ """
191
+ Get the global hook registry instance.
192
+
193
+ Creates the registry on first access (singleton pattern).
194
+ Thread-safe initialization.
195
+
196
+ Returns:
197
+ HookRegistry singleton instance
198
+ """
199
+ global _registry
200
+
201
+ if _registry is None:
202
+ with _registry_lock:
203
+ # Double-checked locking
204
+ if _registry is None:
205
+ _registry = HookRegistry()
206
+
207
+ return _registry
208
+
209
+
210
+ # Backward-compatible module-level functions
211
+ def register_hook(
212
+ model: type,
213
+ event: str,
214
+ handler_cls: type,
215
+ method_name: str,
216
+ condition: Callable | None,
217
+ priority: int | Priority,
218
+ ) -> None:
219
+ """
220
+ Register a hook handler (backward-compatible function).
221
+
222
+ Delegates to the global registry instance.
223
+ """
224
+ registry = get_registry()
225
+ registry.register(model, event, handler_cls, method_name, condition, priority)
226
+
227
+
228
+ def get_hooks(model: type, event: str) -> list[HookInfo]:
229
+ """
230
+ Get hooks for a model and event (backward-compatible function).
231
+
232
+ Delegates to the global registry instance.
233
+ """
234
+ registry = get_registry()
235
+ return registry.get_hooks(model, event)
236
+
237
+
238
+ def unregister_hook(
239
+ model: type,
240
+ event: str,
241
+ handler_cls: type,
242
+ method_name: str,
243
+ ) -> None:
244
+ """
245
+ Unregister a hook handler (backward-compatible function).
246
+
247
+ Delegates to the global registry instance.
248
+ """
249
+ registry = get_registry()
250
+ registry.unregister(model, event, handler_cls, method_name)
251
+
252
+
253
+ def clear_hooks() -> None:
254
+ """
255
+ Clear all registered hooks (backward-compatible function).
256
+
257
+ Delegates to the global registry instance.
258
+ Useful for testing.
259
+ """
260
+ registry = get_registry()
261
+ registry.clear()
262
+
263
+
264
+ def list_all_hooks() -> dict[tuple[type, str], list[HookInfo]]:
265
+ """
266
+ List all registered hooks (backward-compatible function).
267
+
268
+ Delegates to the global registry instance.
269
+ """
270
+ registry = get_registry()
271
+ return registry.list_all()
272
+
273
+
274
+ # Expose hooks dictionary for testing purposes
275
+ # This provides backward compatibility with tests that expect to access _hooks directly
276
+ _hooks = get_registry().hooks
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.2.9
3
+ Version: 0.2.93
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  License: MIT
6
6
  Keywords: django,bulk,hooks
@@ -244,17 +244,68 @@ LoanAccount.objects.bulk_update(reordered) # fields are auto-detected
244
244
 
245
245
  ## 🧩 Integration with Other Managers
246
246
 
247
- You can extend from `BulkHookManager` to work with other manager classes. The manager uses a cooperative approach that dynamically injects bulk hook functionality into any queryset, ensuring compatibility with other managers.
247
+ ### Recommended: QuerySet-based Composition (New Approach)
248
+
249
+ For the best compatibility and to avoid inheritance conflicts, use the queryset-based composition approach:
250
+
251
+ ```python
252
+ from django_bulk_hooks.queryset import HookQuerySet
253
+ from queryable_properties.managers import QueryablePropertiesManager
254
+
255
+ class MyManager(QueryablePropertiesManager):
256
+ """Manager that combines queryable properties with hooks"""
257
+
258
+ def get_queryset(self):
259
+ # Get the QueryableProperties QuerySet
260
+ qs = super().get_queryset()
261
+ # Apply hooks on top of it
262
+ return HookQuerySet.with_hooks(qs)
263
+
264
+ class Article(models.Model):
265
+ title = models.CharField(max_length=100)
266
+ published = models.BooleanField(default=False)
267
+
268
+ objects = MyManager()
269
+
270
+ # This gives you both queryable properties AND hooks
271
+ # No inheritance conflicts, no MRO issues!
272
+ ```
273
+
274
+ ### Alternative: Explicit Hook Application
275
+
276
+ For more control, you can apply hooks explicitly:
277
+
278
+ ```python
279
+ class MyManager(QueryablePropertiesManager):
280
+ def get_queryset(self):
281
+ return super().get_queryset()
282
+
283
+ def with_hooks(self):
284
+ """Apply hooks to this queryset"""
285
+ return HookQuerySet.with_hooks(self.get_queryset())
286
+
287
+ # Usage:
288
+ Article.objects.with_hooks().filter(published=True).update(title="Updated")
289
+ ```
290
+
291
+ ### Legacy: Manager Inheritance (Not Recommended)
292
+
293
+ The old inheritance approach still works but is not recommended due to potential MRO conflicts:
248
294
 
249
295
  ```python
250
296
  from django_bulk_hooks.manager import BulkHookManager
251
297
  from queryable_properties.managers import QueryablePropertiesManager
252
298
 
253
299
  class MyManager(BulkHookManager, QueryablePropertiesManager):
254
- pass
300
+ pass # ⚠️ Can cause inheritance conflicts
255
301
  ```
256
302
 
257
- This approach uses the industry-standard injection pattern, similar to how `QueryablePropertiesManager` works, ensuring both functionalities work seamlessly together without any framework-specific knowledge.
303
+ **Why the new approach is better:**
304
+ - ✅ No inheritance conflicts
305
+ - ✅ No MRO (Method Resolution Order) issues
306
+ - ✅ Works with any manager combination
307
+ - ✅ Cleaner and more maintainable
308
+ - ✅ Follows Django's queryset enhancement patterns
258
309
 
259
310
  Framework needs to:
260
311
  Register these methods