Skip to content

Commit da009e5

Browse files
committed
refactor: add wrapper for catching on handled exceptions
1 parent c4dad2e commit da009e5

9 files changed

Lines changed: 233 additions & 190 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,16 @@ jobs:
1919
runs-on: ubuntu-latest
2020

2121
steps:
22-
- uses: actions/checkout@v4
22+
- uses: actions/checkout@v6
2323

2424
- name: Install uv
25-
uses: astral-sh/setup-uv@v5
25+
uses: astral-sh/setup-uv@v7
2626
with:
2727
enable-cache: true
2828
cache-dependency-glob: uv.lock
2929

3030
- name: Set up Python
31-
uses: actions/setup-python@v5
31+
uses: actions/setup-python@v6
3232
with:
3333
python-version-file: ".python-version"
3434

CONTRIBUTING.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ cd sqlnotify
2222
1. **Install dependencies**
2323

2424
```bash
25+
# Using make
26+
make install-dev
27+
28+
# Use uv
2529
uv sync --all-groups --extra all
2630
```
2731

@@ -51,6 +55,16 @@ make test
5155
uv run pytest
5256
```
5357

58+
### Run tests with coverage
59+
60+
```bash
61+
# Using make
62+
make test_cov
63+
64+
# Or directly with pytest
65+
uv run pytest --cov=sqlnotify --cov-report=html
66+
```
67+
5468
## Code Style
5569

5670
SQLNotify follows Python best practices and uses automated tools to maintain code quality:

Makefile

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
UV := uv
2-
CODECOV_CLI := codecovcli
32

4-
.PHONY: test build publish release
3+
.PHONY: test test_cov build publish release install-dev
4+
5+
6+
install-dev:
7+
@echo "Installing development dependencies..."
8+
@$(UV) sync --all-groups --extra all
9+
@echo "Development dependencies installed"
510

611

712
build:
@@ -25,3 +30,9 @@ test:
2530
@echo "Running all tests for sqlnotify..."
2631
@docker compose run --remove-orphans sqlnotify bash -c "$(UV) run pytest"
2732
@echo "All tests completed"
33+
34+
35+
test_cov:
36+
@echo "Running all tests with coverage for sqlnotify..."
37+
@docker compose run --remove-orphans sqlnotify bash -c "$(UV) run pytest --cov=sqlnotify --cov-report=html"
38+
@echo "All tests completed with coverage report generated"

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ await notifier.anotify(
323323
# notifier.notify(User, Operation.UPDATE, payload={"id": 123})
324324
```
325325

326-
**Note**: The `notify`/`anotify` methods validate payload size and can use overflow tables for large payloads. If `use_overflow_table=True`, payloads exceeding NOTIFY limit are automatically stored in the overflow table, and only an overflow ID is sent through the notification channel.
326+
**Note**: The `notify`/`anotify` methods validate payload size and can use overflow tables for large payloads. If `use_overflow_table=True`, payloads exceeding SQLNotify limit are automatically stored in the overflow table, and only an overflow ID is sent through the notification channel.
327327

328328
### Model Change Detection
329329

@@ -548,7 +548,7 @@ notifier.start() # Synchronous start
548548
2. **Trigger Creation** - The dialect creates database-specific triggers on your tables
549549
3. **Change Detection** - When data changes, triggers fire and call notification functions
550550
4. **NOTIFY** - Functions use database-native notification mechanisms (e.g., PostgreSQL's NOTIFY)
551-
5. **LISTEN** - SQLNotify maintains a dedicated connection listening for notifications
551+
5. **LISTEN** - SQLNotify maintains a dedicated connection listening for notifications or polls for changes (SQLite)
552552
6. **Event Distribution** - Incoming notifications are routed to subscribed callbacks
553553
7. **Callback Execution** - Your subscriber functions are called with change events
554554

pyproject.toml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,13 @@ dependencies = ["sqlalchemy>=2.0.0", "asyncpg>=0.31.0"]
4444
[dependency-groups]
4545
test = [
4646
"pre-commit>=4.5.1",
47-
"coverage>=7.13.3",
47+
"coverage>=7.13.4",
4848
"pytest>=9.0.2",
4949
"pytest-cov>=7.0.0",
5050
"pytest-xdist>=3.8.0",
5151
"pytest-asyncio>=1.3.0",
52-
"psycopg[binary]>=3.3.2",
53-
"sqlmodel>=0.0.25",
54-
"httpx>=0.28.0",
52+
"psycopg[binary]==3.3.2",
53+
"sqlmodel==0.0.33",
5554
]
5655

5756

src/sqlnotify/constants.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
PACKAGE_NAME = "sqlnotify"
22

3-
MAX_SQLNOTIFY_PAYLOAD_BYTES = 7999 # PostgreSQL NOTIFY payload limit (8000 - 1 for terminator)
3+
MAX_SQLNOTIFY_PAYLOAD_BYTES = 7999 # SQLNotify payload limit (8000 - 1 for terminator)
44

5-
MAX_SQLNOTIFY_IDENTIFER_BYTES = 63 # PostgreSQL identifier limit (63 bytes)
5+
MAX_SQLNOTIFY_IDENTIFER_BYTES = 63 # SQLNotify identifier limit (63 bytes)
66

77
MAX_SQLNOTIFY_EXTRA_COLUMNS = 5 # Limit extra columns to help stay within payload size limit, but this is not a hard limit since column data size can vary greatly
88

src/sqlnotify/notifiers/notifier.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from ..exceptions import SQLNotifyConfigurationError
1111
from ..logger import get_logger
1212
from ..types import ChangeEvent, Operation
13-
from ..utils import extract_database_url, strip_database_query_params
13+
from ..utils import extract_database_url, strip_database_query_params, wrap_unhandled_error
1414
from ..watcher import Watcher
1515
from .base import BaseNotifier
1616

@@ -135,6 +135,7 @@ def dialect_name(self) -> str:
135135
def is_running(self) -> bool:
136136
return self._running
137137

138+
@wrap_unhandled_error(lambda self: self._logger)
138139
def start(self) -> None:
139140
"""
140141
Start watching for database changes synchronously.
@@ -151,6 +152,7 @@ def start(self) -> None:
151152

152153
self._start_sync()
153154

155+
@wrap_unhandled_error(lambda self: self._logger)
154156
async def astart(self) -> None:
155157
"""
156158
Start watching for database changes asynchronously.
@@ -203,6 +205,7 @@ def _start_sync(self) -> None:
203205
if self._logger:
204206
self._logger.info(f"SQLNotify Notifier started (sync mode, dialect: {self.dialect_name})")
205207

208+
@wrap_unhandled_error(lambda self: self._logger)
206209
def stop(self) -> None:
207210
"""
208211
Stop watching for database changes synchronously
@@ -219,6 +222,7 @@ def stop(self) -> None:
219222

220223
self._stop_sync()
221224

225+
@wrap_unhandled_error(lambda self: self._logger)
222226
async def astop(self) -> None:
223227
"""
224228
Stop watching for database changes asynchronously.
@@ -261,6 +265,7 @@ def _stop_sync(self) -> None:
261265
if self._logger:
262266
self._logger.info("SQLNotify Notifier stopped (sync mode)")
263267

268+
@wrap_unhandled_error(lambda self: self._logger, reraise=False)
264269
def cleanup(self) -> None:
265270
"""
266271
Remove all triggers and functions from the database synchronously
@@ -277,6 +282,7 @@ def cleanup(self) -> None:
277282

278283
self._cleanup_sync()
279284

285+
@wrap_unhandled_error(lambda self: self._logger, reraise=False)
280286
async def acleanup(self) -> None:
281287
"""
282288
Remove all triggers and functions from the database asynchronously.
@@ -311,6 +317,7 @@ def _cleanup_sync(self) -> None:
311317
if self._logger:
312318
self._logger.info("SQLNotify Notifier cleaned up all triggers and functions (sync mode)")
313319

320+
@wrap_unhandled_error(lambda self: self._logger)
314321
def notify(
315322
self,
316323
model: type | str,
@@ -345,6 +352,7 @@ def notify(
345352

346353
self._notify_sync(watcher, payload, use_overflow_table)
347354

355+
@wrap_unhandled_error(lambda self: self._logger)
348356
async def anotify(
349357
self,
350358
model: type | str,
@@ -580,7 +588,7 @@ async def _dispatch_event(self, watcher: Watcher, payload: dict[str, Any]) -> No
580588
581589
Args:
582590
watcher (Watcher): The watcher that triggered
583-
payload (dict): The notification payload
591+
payload (dict[str, Any]): The notification payload
584592
"""
585593

586594
if len(watcher.primary_keys) == 1:

src/sqlnotify/utils.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
1+
import inspect
2+
import logging
3+
from collections.abc import Callable
4+
from functools import wraps
5+
from typing import Any
6+
17
from sqlalchemy.engine import Engine
28
from sqlalchemy.ext.asyncio import AsyncEngine
39

410
from .constants import MAX_SQLNOTIFY_IDENTIFER_BYTES, MAX_SQLNOTIFY_PAYLOAD_BYTES
5-
from .exceptions import SQLNotifyIdentifierSizeError, SQLNotifyPayloadSizeError
11+
from .exceptions import SQLNotifyException, SQLNotifyIdentifierSizeError, SQLNotifyPayloadSizeError
612

713

814
def extract_database_url(engine: AsyncEngine | Engine) -> str:
915
"""
1016
Extract the database URL from an engine for use with asyncpg LISTEN.
1117
12-
Converts SQLAlchemy URL format to plain PostgreSQL URL suitable for asyncpg.
18+
Converts SQLAlchemy URL format to raw database DSN.
1319
1420
Args:
1521
engine (Union[AsyncEngine, Engine]): SQLAlchemy Engine or AsyncEngine
1622
1723
Returns:
18-
str: PostgreSQL connection URL suitable for asyncpg
24+
str: Database connection URL
1925
"""
2026

2127
url = engine.url.render_as_string(hide_password=False)
@@ -163,3 +169,65 @@ def validate_identifier_size(
163169
raise SQLNotifyIdentifierSizeError(combined)
164170

165171
return True
172+
173+
174+
def wrap_unhandled_error(
175+
logger_getter: Callable[..., logging.Logger] | None = None,
176+
reraise=True,
177+
):
178+
"""
179+
Decorator factory that wraps unhandled exceptions and converts them to SQLNotifyException
180+
"""
181+
182+
def _get_logger_from_call(*args, **kwargs):
183+
if callable(logger_getter):
184+
try:
185+
return logger_getter(*args, **kwargs)
186+
except Exception:
187+
return None
188+
189+
return logger_getter
190+
191+
def decorator(func: Callable[..., Any]):
192+
193+
if inspect.iscoroutinefunction(func):
194+
195+
@wraps(func)
196+
async def async_wrapper(*args, **kwargs):
197+
try:
198+
return await func(*args, **kwargs)
199+
except SQLNotifyException:
200+
raise
201+
except Exception as e:
202+
logger = _get_logger_from_call(*args, **kwargs)
203+
if logger:
204+
logger.exception(f"Unhandled exception in {func.__name__}")
205+
206+
if reraise:
207+
raise SQLNotifyException(f"SQLNotify unhandled exception caught. Error: {str(e)}") from e
208+
else:
209+
return None
210+
211+
return async_wrapper
212+
213+
else:
214+
215+
@wraps(func)
216+
def sync_wrapper(*args, **kwargs):
217+
try:
218+
return func(*args, **kwargs)
219+
except SQLNotifyException:
220+
raise
221+
except Exception as e:
222+
logger = _get_logger_from_call(*args, **kwargs)
223+
if logger:
224+
logger.exception(f"Unhandled exception in {func.__name__}")
225+
226+
if reraise:
227+
raise SQLNotifyException(f"SQLNotify unhandled exception caught. Error: {str(e)}") from e
228+
else:
229+
return None
230+
231+
return sync_wrapper
232+
233+
return decorator

0 commit comments

Comments
 (0)