surreal-orm-lite 0.3.0__tar.gz → 0.4.0__tar.gz

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.
Files changed (19) hide show
  1. {surreal_orm_lite-0.3.0 → surreal_orm_lite-0.4.0}/CHANGELOG.md +25 -0
  2. {surreal_orm_lite-0.3.0 → surreal_orm_lite-0.4.0}/PKG-INFO +76 -1
  3. {surreal_orm_lite-0.3.0 → surreal_orm_lite-0.4.0}/README.md +75 -0
  4. {surreal_orm_lite-0.3.0 → surreal_orm_lite-0.4.0}/pyproject.toml +1 -1
  5. {surreal_orm_lite-0.3.0 → surreal_orm_lite-0.4.0}/src/surreal_orm_lite/__init__.py +26 -1
  6. {surreal_orm_lite-0.3.0 → surreal_orm_lite-0.4.0}/src/surreal_orm_lite/model_base.py +101 -14
  7. surreal_orm_lite-0.4.0/src/surreal_orm_lite/signals.py +286 -0
  8. {surreal_orm_lite-0.3.0 → surreal_orm_lite-0.4.0}/.gitignore +0 -0
  9. {surreal_orm_lite-0.3.0 → surreal_orm_lite-0.4.0}/LICENSE +0 -0
  10. {surreal_orm_lite-0.3.0 → surreal_orm_lite-0.4.0}/Makefile +0 -0
  11. {surreal_orm_lite-0.3.0 → surreal_orm_lite-0.4.0}/src/__init__.py +0 -0
  12. {surreal_orm_lite-0.3.0 → surreal_orm_lite-0.4.0}/src/surreal_orm_lite/aggregations.py +0 -0
  13. {surreal_orm_lite-0.3.0 → surreal_orm_lite-0.4.0}/src/surreal_orm_lite/connection_manager.py +0 -0
  14. {surreal_orm_lite-0.3.0 → surreal_orm_lite-0.4.0}/src/surreal_orm_lite/constants.py +0 -0
  15. {surreal_orm_lite-0.3.0 → surreal_orm_lite-0.4.0}/src/surreal_orm_lite/enum.py +0 -0
  16. {surreal_orm_lite-0.3.0 → surreal_orm_lite-0.4.0}/src/surreal_orm_lite/exceptions.py +0 -0
  17. {surreal_orm_lite-0.3.0 → surreal_orm_lite-0.4.0}/src/surreal_orm_lite/py.typed +0 -0
  18. {surreal_orm_lite-0.3.0 → surreal_orm_lite-0.4.0}/src/surreal_orm_lite/query_set.py +0 -0
  19. {surreal_orm_lite-0.3.0 → surreal_orm_lite-0.4.0}/src/surreal_orm_lite/utils.py +0 -0
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-02-07
9
+
10
+ ### Added
11
+
12
+ - **Model Signals**: Django-style event system for model lifecycle
13
+ - `Signal` class for pre/post event handlers
14
+ - `AroundSignal` class for context manager-style wrapping signals with `yield`
15
+ - `pre_save` / `post_save` - Fired before/after `save()` operations
16
+ - `pre_update` / `post_update` - Fired before/after `update()` and `merge()` operations
17
+ - `pre_delete` / `post_delete` - Fired before/after `delete()` operations
18
+ - `around_save` / `around_update` / `around_delete` - Wrap operations for timing, logging, etc.
19
+ - `connect(model_class)` decorator for registering handlers
20
+ - `disconnect(handler, model_class)` for removing handlers
21
+ - `clear()` for removing all handlers
22
+ - `has_handlers()` for checking if handlers are registered
23
+
24
+ - `post_save` signal includes `created` flag to distinguish new records
25
+ - `pre_update` / `post_update` signals include `update_fields` list
26
+ - New test file `tests/test_signals.py` with unit and e2e tests
27
+
28
+ ### Changed
29
+
30
+ - `save()`, `update()`, `merge()`, `delete()` now emit signals when handlers are registered
31
+ - Internal `_do_save()` method extracted from `save()` for signal integration
32
+
8
33
  ## [0.3.0] - 2026-02-05
9
34
 
10
35
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: surreal-orm-lite
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Lightweight Django-style ORM for SurrealDB using the official Python SDK. Async support with Pydantic validation.
5
5
  Project-URL: Homepage, https://github.com/EulogySnowfall/SurrealDB-ORM-lite
6
6
  Project-URL: Documentation, https://github.com/EulogySnowfall/SurrealDB-ORM-lite
@@ -191,6 +191,9 @@ results = await User.objects().query(
191
191
  | Custom primary keys | ✅ |
192
192
  | HTTP connections | ✅ |
193
193
  | WebSocket connections | ✅ |
194
+ | Aggregations | ✅ |
195
+ | GROUP BY | ✅ |
196
+ | Model Signals | ✅ |
194
197
 
195
198
  ### Supported Filter Lookups
196
199
 
@@ -201,6 +204,78 @@ results = await User.objects().query(
201
204
  - `startswith`, `istartswith`
202
205
  - `endswith`, `iendswith`
203
206
 
207
+ ### 5. Aggregations
208
+
209
+ ```python
210
+ from surreal_orm_lite import Count, Sum, Avg, Min, Max
211
+
212
+ # Simple aggregations
213
+ count = await User.objects().count()
214
+ total = await Order.objects().sum("amount")
215
+ avg_age = await User.objects().avg("age")
216
+ max_price = await Product.objects().max("price")
217
+ min_price = await Product.objects().min("price")
218
+
219
+ # Check existence
220
+ has_admins = await User.objects().filter(role="admin").exists()
221
+
222
+ # GROUP BY with annotations
223
+ results = await User.objects().values("status").annotate(count=Count()).exec()
224
+ # [{"status": "active", "count": 42}, {"status": "inactive", "count": 8}]
225
+
226
+ # Raw SurrealQL queries
227
+ results = await User.raw_query(
228
+ "SELECT * FROM User WHERE age > $min_age",
229
+ variables={"min_age": 18}
230
+ )
231
+ ```
232
+
233
+ ### 6. Model Signals
234
+
235
+ ```python
236
+ from surreal_orm_lite import pre_save, post_save, pre_delete, post_delete
237
+
238
+ @post_save.connect(User)
239
+ async def on_user_saved(sender, instance, created, **kwargs):
240
+ """Called after every User save."""
241
+ if created:
242
+ await send_welcome_email(instance.email)
243
+ await invalidate_cache(f"user:{instance.id}")
244
+
245
+ @pre_delete.connect(User)
246
+ async def on_user_deleting(sender, instance, **kwargs):
247
+ """Called before User deletion."""
248
+ await archive_user_data(instance.id)
249
+ ```
250
+
251
+ **Available signals:**
252
+
253
+ | Signal | When | Extra kwargs |
254
+ | --------------- | --------------------------- | ---------------- |
255
+ | `pre_save` | Before `save()` | |
256
+ | `post_save` | After `save()` | `created` |
257
+ | `pre_update` | Before `update()`/`merge()` | `update_fields` |
258
+ | `post_update` | After `update()`/`merge()` | `update_fields` |
259
+ | `pre_delete` | Before `delete()` | |
260
+ | `post_delete` | After `delete()` | |
261
+ | `around_save` | Wraps `save()` | |
262
+ | `around_update` | Wraps `update()`/`merge()` | `update_fields` |
263
+ | `around_delete` | Wraps `delete()` | |
264
+
265
+ **Around signals** use async generators to wrap operations:
266
+
267
+ ```python
268
+ from surreal_orm_lite import around_save
269
+
270
+ @around_save.connect(User)
271
+ async def time_user_save(sender, instance, **kwargs):
272
+ import time
273
+ start = time.time()
274
+ yield # save() executes here
275
+ duration = time.time() - start
276
+ print(f"Save took {duration:.3f}s")
277
+ ```
278
+
204
279
  ---
205
280
 
206
281
  ## Configuration Options
@@ -141,6 +141,9 @@ results = await User.objects().query(
141
141
  | Custom primary keys | ✅ |
142
142
  | HTTP connections | ✅ |
143
143
  | WebSocket connections | ✅ |
144
+ | Aggregations | ✅ |
145
+ | GROUP BY | ✅ |
146
+ | Model Signals | ✅ |
144
147
 
145
148
  ### Supported Filter Lookups
146
149
 
@@ -151,6 +154,78 @@ results = await User.objects().query(
151
154
  - `startswith`, `istartswith`
152
155
  - `endswith`, `iendswith`
153
156
 
157
+ ### 5. Aggregations
158
+
159
+ ```python
160
+ from surreal_orm_lite import Count, Sum, Avg, Min, Max
161
+
162
+ # Simple aggregations
163
+ count = await User.objects().count()
164
+ total = await Order.objects().sum("amount")
165
+ avg_age = await User.objects().avg("age")
166
+ max_price = await Product.objects().max("price")
167
+ min_price = await Product.objects().min("price")
168
+
169
+ # Check existence
170
+ has_admins = await User.objects().filter(role="admin").exists()
171
+
172
+ # GROUP BY with annotations
173
+ results = await User.objects().values("status").annotate(count=Count()).exec()
174
+ # [{"status": "active", "count": 42}, {"status": "inactive", "count": 8}]
175
+
176
+ # Raw SurrealQL queries
177
+ results = await User.raw_query(
178
+ "SELECT * FROM User WHERE age > $min_age",
179
+ variables={"min_age": 18}
180
+ )
181
+ ```
182
+
183
+ ### 6. Model Signals
184
+
185
+ ```python
186
+ from surreal_orm_lite import pre_save, post_save, pre_delete, post_delete
187
+
188
+ @post_save.connect(User)
189
+ async def on_user_saved(sender, instance, created, **kwargs):
190
+ """Called after every User save."""
191
+ if created:
192
+ await send_welcome_email(instance.email)
193
+ await invalidate_cache(f"user:{instance.id}")
194
+
195
+ @pre_delete.connect(User)
196
+ async def on_user_deleting(sender, instance, **kwargs):
197
+ """Called before User deletion."""
198
+ await archive_user_data(instance.id)
199
+ ```
200
+
201
+ **Available signals:**
202
+
203
+ | Signal | When | Extra kwargs |
204
+ | --------------- | --------------------------- | ---------------- |
205
+ | `pre_save` | Before `save()` | |
206
+ | `post_save` | After `save()` | `created` |
207
+ | `pre_update` | Before `update()`/`merge()` | `update_fields` |
208
+ | `post_update` | After `update()`/`merge()` | `update_fields` |
209
+ | `pre_delete` | Before `delete()` | |
210
+ | `post_delete` | After `delete()` | |
211
+ | `around_save` | Wraps `save()` | |
212
+ | `around_update` | Wraps `update()`/`merge()` | `update_fields` |
213
+ | `around_delete` | Wraps `delete()` | |
214
+
215
+ **Around signals** use async generators to wrap operations:
216
+
217
+ ```python
218
+ from surreal_orm_lite import around_save
219
+
220
+ @around_save.connect(User)
221
+ async def time_user_save(sender, instance, **kwargs):
222
+ import time
223
+ start = time.time()
224
+ yield # save() executes here
225
+ duration = time.time() - start
226
+ print(f"Save took {duration:.3f}s")
227
+ ```
228
+
154
229
  ---
155
230
 
156
231
  ## Configuration Options
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "surreal-orm-lite"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Lightweight Django-style ORM for SurrealDB using the official Python SDK. Async support with Pydantic validation."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,4 +1,4 @@
1
- __version__ = "0.3.0"
1
+ __version__ = "0.4.0"
2
2
 
3
3
  from .aggregations import Aggregation, Avg, Count, Max, Min, Sum
4
4
  from .connection_manager import SurrealDBConnectionManager
@@ -12,6 +12,19 @@ from .exceptions import (
12
12
  )
13
13
  from .model_base import BaseSurrealModel, SurrealConfigDict
14
14
  from .query_set import QuerySet
15
+ from .signals import (
16
+ AroundSignal,
17
+ Signal,
18
+ around_delete,
19
+ around_save,
20
+ around_update,
21
+ post_delete,
22
+ post_save,
23
+ post_update,
24
+ pre_delete,
25
+ pre_save,
26
+ pre_update,
27
+ )
15
28
 
16
29
  __all__ = [
17
30
  "__version__",
@@ -30,6 +43,18 @@ __all__ = [
30
43
  "Avg",
31
44
  "Min",
32
45
  "Max",
46
+ # Signals
47
+ "Signal",
48
+ "AroundSignal",
49
+ "pre_save",
50
+ "post_save",
51
+ "pre_update",
52
+ "post_update",
53
+ "pre_delete",
54
+ "post_delete",
55
+ "around_save",
56
+ "around_update",
57
+ "around_delete",
33
58
  # Exceptions
34
59
  "SurrealORMError",
35
60
  "SurrealDbError",
@@ -7,6 +7,17 @@ from surrealdb import RecordID
7
7
 
8
8
  from .connection_manager import SurrealDBConnectionManager
9
9
  from .exceptions import SurrealDbError
10
+ from .signals import (
11
+ around_delete,
12
+ around_save,
13
+ around_update,
14
+ post_delete,
15
+ post_save,
16
+ post_update,
17
+ pre_delete,
18
+ pre_save,
19
+ pre_update,
20
+ )
10
21
 
11
22
  logger = logging.getLogger(__name__)
12
23
 
@@ -52,7 +63,7 @@ class BaseSurrealModel(BaseModel):
52
63
  Get the ID of the model instance.
53
64
  """
54
65
  if hasattr(self, "id"):
55
- id_value = self.id
66
+ id_value = self.id # type: ignore[attr-defined]
56
67
  return str(id_value) if id_value is not None else None
57
68
 
58
69
  if hasattr(self, "model_config"):
@@ -111,9 +122,10 @@ class BaseSurrealModel(BaseModel):
111
122
  if hasattr(self, key):
112
123
  object.__setattr__(self, key, value)
113
124
 
114
- async def save(self) -> Self:
125
+ async def _do_save(self) -> tuple[Self, bool]:
115
126
  """
116
- Save the model instance to the database.
127
+ Internal save logic. Returns (self, created) where created indicates
128
+ whether a new record was created (always True for save).
117
129
  """
118
130
  client = await SurrealDBConnectionManager.get_client()
119
131
  data = self.model_dump(exclude={"id"})
@@ -122,13 +134,13 @@ class BaseSurrealModel(BaseModel):
122
134
 
123
135
  if id is not None:
124
136
  # Escape special characters in ID
125
- escaped_id = f"`{id}`" if any(c in str(id) for c in "@#$%^&*()") else id
137
+ escaped_id = f"`{id}`" if any(c in str(id) for c in "@#$%^&*()-+=/\\! ") else id
126
138
  thing = f"{table}:{escaped_id}"
127
139
  result = await client.create(thing, data)
128
140
  # SDK 1.0.8 returns error message as string instead of raising exception
129
141
  if isinstance(result, str) and "already exists" in result:
130
142
  raise SurrealDbError(f"There was a problem with the database: {result}")
131
- return self
143
+ return self, True
132
144
 
133
145
  # Auto-generate the ID
134
146
  record = await client.create(table, data) # pragma: no cover
@@ -150,38 +162,95 @@ class BaseSurrealModel(BaseModel):
150
162
  value = str(value).split(":")[1]
151
163
  if hasattr(self, key):
152
164
  object.__setattr__(self, key, value)
153
- return self
165
+ return self, True
154
166
 
155
167
  raise SurrealDbError("Can't save data, no record returned.") # pragma: no cover
156
168
 
169
+ async def save(self) -> Self:
170
+ """
171
+ Save the model instance to the database.
172
+
173
+ Emits pre_save, post_save, and around_save signals.
174
+ """
175
+ sender = self.__class__
176
+ has_signals = pre_save.has_handlers(sender) or post_save.has_handlers(sender) or around_save.has_handlers(sender)
177
+
178
+ if not has_signals:
179
+ result, created = await self._do_save()
180
+ return result
181
+
182
+ await pre_save.send(sender, instance=self)
183
+
184
+ async with around_save.wrap(sender, instance=self):
185
+ result, created = await self._do_save()
186
+
187
+ await post_save.send(sender, instance=self, created=created)
188
+
189
+ return result
190
+
157
191
  async def update(self) -> Any:
158
192
  """
159
193
  Update the model instance to the database.
194
+
195
+ Emits pre_update, post_update, and around_update signals.
160
196
  """
161
197
  client = await SurrealDBConnectionManager.get_client()
198
+ sender = self.__class__
162
199
 
163
200
  data = self.model_dump(exclude={"id"})
164
201
  id = self.get_id()
165
202
  if id is not None:
166
203
  thing = f"{self.__class__.__name__}:{id}"
167
- result = await client.update(thing, data)
204
+ update_fields = list(data.keys())
205
+ has_signals = (
206
+ pre_update.has_handlers(sender) or post_update.has_handlers(sender) or around_update.has_handlers(sender)
207
+ )
208
+
209
+ if not has_signals:
210
+ return await client.update(thing, data)
211
+
212
+ await pre_update.send(sender, instance=self, update_fields=update_fields)
213
+
214
+ async with around_update.wrap(sender, instance=self, update_fields=update_fields):
215
+ result = await client.update(thing, data)
216
+
217
+ await post_update.send(sender, instance=self, update_fields=update_fields)
218
+
168
219
  return result
169
220
  raise SurrealDbError("Can't update data, no id found.")
170
221
 
171
222
  async def merge(self, **data: Any) -> Any:
172
223
  """
173
- Update the model instance to the database.
224
+ Partial update of the model instance in the database.
225
+
226
+ Emits pre_update, post_update, and around_update signals.
174
227
  """
175
228
 
176
229
  client = await SurrealDBConnectionManager.get_client()
230
+ sender = self.__class__
177
231
  data_set = dict(data.items())
178
232
 
179
233
  id = self.get_id()
180
- if id:
234
+ if id is not None:
181
235
  thing = f"{self.get_table_name()}:{id}"
236
+ update_fields = list(data_set.keys())
237
+ has_signals = (
238
+ pre_update.has_handlers(sender) or post_update.has_handlers(sender) or around_update.has_handlers(sender)
239
+ )
240
+
241
+ if not has_signals:
242
+ await client.merge(thing, data_set)
243
+ await self.refresh()
244
+ return
245
+
246
+ await pre_update.send(sender, instance=self, update_fields=update_fields)
247
+
248
+ async with around_update.wrap(sender, instance=self, update_fields=update_fields):
249
+ await client.merge(thing, data_set)
250
+ await self.refresh()
251
+
252
+ await post_update.send(sender, instance=self, update_fields=update_fields)
182
253
 
183
- await client.merge(thing, data_set)
184
- await self.refresh()
185
254
  return
186
255
 
187
256
  raise SurrealDbError(f"No Id for the data to merge: {data}")
@@ -189,18 +258,36 @@ class BaseSurrealModel(BaseModel):
189
258
  async def delete(self) -> None:
190
259
  """
191
260
  Delete the model instance from the database.
261
+
262
+ Emits pre_delete, post_delete, and around_delete signals.
192
263
  """
193
264
 
194
265
  client = await SurrealDBConnectionManager.get_client()
266
+ sender = self.__class__
195
267
 
196
268
  id = self.get_id()
269
+ if id is None:
270
+ raise SurrealDbError("Can't delete data, no id found.")
197
271
 
198
272
  thing = f"{self.get_table_name()}:{id}"
273
+ has_signals = pre_delete.has_handlers(sender) or post_delete.has_handlers(sender) or around_delete.has_handlers(sender)
274
+
275
+ if not has_signals:
276
+ deleted = await client.delete(thing)
277
+ if not deleted:
278
+ raise SurrealDbError(f"Can't delete Record id -> '{id}' not found!")
279
+ logger.info(f"Record deleted -> {deleted}.")
280
+ return
281
+
282
+ await pre_delete.send(sender, instance=self)
283
+
284
+ async with around_delete.wrap(sender, instance=self):
285
+ deleted = await client.delete(thing)
199
286
 
200
- deleted = await client.delete(thing)
287
+ if not deleted:
288
+ raise SurrealDbError(f"Can't delete Record id -> '{id}' not found!")
201
289
 
202
- if not deleted:
203
- raise SurrealDbError(f"Can't delete Record id -> '{id}' not found!")
290
+ await post_delete.send(sender, instance=self)
204
291
 
205
292
  logger.info(f"Record deleted -> {deleted}.")
206
293
 
@@ -0,0 +1,286 @@
1
+ """
2
+ Signal system for SurrealDB-ORM-lite.
3
+
4
+ Provides Django-style pre/post signals and around signals
5
+ for model lifecycle events (save, update, delete).
6
+
7
+ Usage:
8
+ from surreal_orm_lite import pre_save, post_save, pre_delete, post_delete
9
+
10
+ @post_save.connect(User)
11
+ async def on_user_saved(sender, instance, created, **kwargs):
12
+ if created:
13
+ await send_welcome_email(instance.email)
14
+
15
+ @around_save.connect(User)
16
+ async def time_user_save(sender, instance, **kwargs):
17
+ import time
18
+ start = time.time()
19
+ yield # save() executes here
20
+ duration = time.time() - start
21
+ print(f"Save took {duration:.3f}s")
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import contextlib
27
+ import logging
28
+ from collections import defaultdict
29
+ from collections.abc import AsyncGenerator, Callable, Coroutine
30
+ from typing import Any
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # Type alias for signal handlers
35
+ SignalHandler = Callable[..., Coroutine[Any, Any, None]]
36
+ AroundHandler = Callable[..., AsyncGenerator[None, None]]
37
+
38
+
39
+ class Signal:
40
+ """
41
+ A signal that dispatches events to registered async handlers.
42
+
43
+ Handlers are registered per model class and called in registration order
44
+ when the signal is sent.
45
+
46
+ Example:
47
+ pre_save = Signal("pre_save")
48
+
49
+ @pre_save.connect(User)
50
+ async def on_user_pre_save(sender, instance, **kwargs):
51
+ print(f"About to save {instance}")
52
+ """
53
+
54
+ def __init__(self, name: str) -> None:
55
+ self.name = name
56
+ self._handlers: dict[type, list[SignalHandler]] = defaultdict(list)
57
+
58
+ def connect(self, model_class: type) -> Callable[[SignalHandler], SignalHandler]:
59
+ """
60
+ Decorator to register a handler for a specific model class.
61
+
62
+ Args:
63
+ model_class: The model class to listen for signals on.
64
+
65
+ Returns:
66
+ Decorator that registers the handler.
67
+ """
68
+
69
+ def decorator(handler: SignalHandler) -> SignalHandler:
70
+ if handler not in self._handlers[model_class]:
71
+ self._handlers[model_class].append(handler)
72
+ return handler
73
+
74
+ return decorator
75
+
76
+ def disconnect(self, handler: SignalHandler, model_class: type) -> bool:
77
+ """
78
+ Remove a handler for a specific model class.
79
+
80
+ Args:
81
+ handler: The handler function to remove.
82
+ model_class: The model class the handler was registered for.
83
+
84
+ Returns:
85
+ True if the handler was found and removed, False otherwise.
86
+ """
87
+ handlers = self._handlers.get(model_class, [])
88
+ if handler in handlers:
89
+ handlers.remove(handler)
90
+ if not handlers:
91
+ self._handlers.pop(model_class, None)
92
+ return True
93
+ return False
94
+
95
+ async def send(self, sender: type, **kwargs: Any) -> None:
96
+ """
97
+ Dispatch the signal to all registered handlers for the sender class.
98
+
99
+ Args:
100
+ sender: The model class that is sending the signal.
101
+ **kwargs: Additional keyword arguments passed to handlers.
102
+ """
103
+ for handler in self._handlers.get(sender, []):
104
+ await handler(sender=sender, **kwargs)
105
+
106
+ def has_handlers(self, model_class: type) -> bool:
107
+ """Check if any handlers are registered for the given model class."""
108
+ return bool(self._handlers.get(model_class))
109
+
110
+ def clear(self, model_class: type | None = None) -> None:
111
+ """
112
+ Clear registered handlers.
113
+
114
+ Args:
115
+ model_class: If provided, clear handlers only for this model.
116
+ If None, clear all handlers for all models.
117
+ """
118
+ if model_class is not None:
119
+ self._handlers.pop(model_class, None)
120
+ else:
121
+ self._handlers.clear()
122
+
123
+
124
+ class AroundSignal:
125
+ """
126
+ A signal that wraps an operation using async generators (context manager style).
127
+
128
+ Handlers yield once: code before yield runs before the operation,
129
+ code after yield runs after. This allows measuring timing, wrapping
130
+ in try/except, etc.
131
+
132
+ Example:
133
+ around_save = AroundSignal("around_save")
134
+
135
+ @around_save.connect(User)
136
+ async def time_user_save(sender, instance, **kwargs):
137
+ start = time.time()
138
+ yield # save() executes here
139
+ duration = time.time() - start
140
+ print(f"Save took {duration:.3f}s")
141
+ """
142
+
143
+ def __init__(self, name: str) -> None:
144
+ self.name = name
145
+ self._handlers: dict[type, list[AroundHandler]] = defaultdict(list)
146
+
147
+ def connect(self, model_class: type) -> Callable[[AroundHandler], AroundHandler]:
148
+ """
149
+ Decorator to register an around handler for a specific model class.
150
+
151
+ Args:
152
+ model_class: The model class to listen for signals on.
153
+
154
+ Returns:
155
+ Decorator that registers the handler.
156
+ """
157
+
158
+ def decorator(handler: AroundHandler) -> AroundHandler:
159
+ if handler not in self._handlers[model_class]:
160
+ self._handlers[model_class].append(handler)
161
+ return handler
162
+
163
+ return decorator
164
+
165
+ def disconnect(self, handler: AroundHandler, model_class: type) -> bool:
166
+ """
167
+ Remove a handler for a specific model class.
168
+
169
+ Args:
170
+ handler: The handler function to remove.
171
+ model_class: The model class the handler was registered for.
172
+
173
+ Returns:
174
+ True if the handler was found and removed, False otherwise.
175
+ """
176
+ handlers = self._handlers.get(model_class, [])
177
+ if handler in handlers:
178
+ handlers.remove(handler)
179
+ if not handlers:
180
+ self._handlers.pop(model_class, None)
181
+ return True
182
+ return False
183
+
184
+ @contextlib.asynccontextmanager
185
+ async def wrap(self, sender: type, **kwargs: Any) -> AsyncGenerator[None, None]:
186
+ """
187
+ Context manager that runs all around handlers for the sender class.
188
+
189
+ The handlers' pre-yield code runs before yielding,
190
+ and their post-yield code runs after in reverse (LIFO) order,
191
+ similar to nested context managers.
192
+
193
+ Args:
194
+ sender: The model class that is sending the signal.
195
+ **kwargs: Additional keyword arguments passed to handlers.
196
+ """
197
+ handlers = self._handlers.get(sender, [])
198
+ active: list[tuple[AsyncGenerator[None, None], AroundHandler]] = []
199
+
200
+ # Start all generators (run pre-yield code)
201
+ try:
202
+ for handler in handlers:
203
+ gen = handler(sender=sender, **kwargs)
204
+ try:
205
+ await gen.__anext__()
206
+ except StopAsyncIteration:
207
+ logger.warning(
208
+ "AroundSignal handler %r for sender %r did not yield; it must yield exactly once.",
209
+ handler,
210
+ sender,
211
+ )
212
+ with contextlib.suppress(Exception):
213
+ await gen.aclose()
214
+ continue
215
+ active.append((gen, handler))
216
+ except Exception:
217
+ # If startup fails, close any already-started generators in LIFO order
218
+ for gen, _ in reversed(active):
219
+ with contextlib.suppress(Exception):
220
+ await gen.aclose()
221
+ raise
222
+
223
+ try:
224
+ yield
225
+ finally:
226
+ # Complete all generators (run post-yield code) in reverse (LIFO) order.
227
+ # Post-yield handler errors are logged and suppressed so they don't
228
+ # mask an exception raised by the wrapped operation.
229
+ for gen, handler in reversed(active):
230
+ try:
231
+ await gen.__anext__()
232
+ except StopAsyncIteration:
233
+ continue
234
+ except Exception:
235
+ logger.exception(
236
+ "AroundSignal handler %r for sender %r raised during post-yield cleanup.",
237
+ handler,
238
+ sender,
239
+ )
240
+ else:
241
+ logger.warning(
242
+ "AroundSignal handler %r for sender %r yielded more than once; handlers must yield exactly once.",
243
+ handler,
244
+ sender,
245
+ )
246
+ with contextlib.suppress(Exception):
247
+ await gen.aclose()
248
+
249
+ def has_handlers(self, model_class: type) -> bool:
250
+ """Check if any handlers are registered for the given model class."""
251
+ return bool(self._handlers.get(model_class))
252
+
253
+ def clear(self, model_class: type | None = None) -> None:
254
+ """
255
+ Clear registered handlers.
256
+
257
+ Args:
258
+ model_class: If provided, clear handlers only for this model.
259
+ If None, clear all handlers for all models.
260
+ """
261
+ if model_class is not None:
262
+ self._handlers.pop(model_class, None)
263
+ else:
264
+ self._handlers.clear()
265
+
266
+
267
+ # =============================================================================
268
+ # Pre-defined signal instances
269
+ # =============================================================================
270
+
271
+ # Pre/Post signals for save operations
272
+ pre_save = Signal("pre_save")
273
+ post_save = Signal("post_save")
274
+
275
+ # Pre/Post signals for update operations
276
+ pre_update = Signal("pre_update")
277
+ post_update = Signal("post_update")
278
+
279
+ # Pre/Post signals for delete operations
280
+ pre_delete = Signal("pre_delete")
281
+ post_delete = Signal("post_delete")
282
+
283
+ # Around signals
284
+ around_save = AroundSignal("around_save")
285
+ around_update = AroundSignal("around_update")
286
+ around_delete = AroundSignal("around_delete")