sqlnotify 0.1.0__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.
@@ -0,0 +1,74 @@
1
+ import logging
2
+
3
+ from sqlalchemy.engine import Engine
4
+ from sqlalchemy.ext.asyncio import AsyncEngine
5
+
6
+ from ..exceptions import SQLNotifyUnSupportedDatabaseProviderError
7
+ from .base import BaseDialect
8
+ from .postgresql import PostgreSQLDialect
9
+ from .sqlite import SQLiteDialect
10
+
11
+
12
+ def detect_dialect_name(engine: AsyncEngine | Engine) -> str:
13
+ """
14
+ Detect the database dialect from an SQLAlchemy engine
15
+
16
+ Args:
17
+ engine (Union[AsyncEngine, Engine]): SQLAlchemy Engine or AsyncEngine
18
+
19
+ Returns:
20
+ str: The dialect name (e.g., 'postgresql', 'mysql', 'sqlite')
21
+ """
22
+
23
+ if isinstance(engine, AsyncEngine):
24
+ dialect_name = engine.dialect.name
25
+ else:
26
+ dialect_name = engine.dialect.name
27
+
28
+ return dialect_name
29
+
30
+
31
+ def get_dialect_for_engine(
32
+ engine: AsyncEngine | Engine,
33
+ async_engine: AsyncEngine | None = None,
34
+ sync_engine: Engine | None = None,
35
+ logger: logging.Logger | None = None,
36
+ revoke_on_model_change: bool = True,
37
+ ) -> BaseDialect:
38
+ """
39
+ Get the appropriate dialect implementation for the given engine
40
+
41
+ Args:
42
+ engine (Union[AsyncEngine, Engine]): SQLAlchemy Engine or AsyncEngine
43
+ async_engine (Optional[AsyncEngine]): Async engine if available
44
+ sync_engine (Optional[Engine]): Sync engine if available
45
+ logger (Optional[logging.Logger]): Logger instance
46
+ revoke_on_model_change (bool): Whether to revoke triggers on model change
47
+
48
+ Returns:
49
+ BaseDialect: The appropriate dialect implementation
50
+
51
+ Raises:
52
+ SQLNotifyUnSupportedDatabaseProviderError: If the dialect is not supported
53
+ """
54
+
55
+ dialect_name = detect_dialect_name(engine)
56
+
57
+ if dialect_name == "postgresql":
58
+ return PostgreSQLDialect(
59
+ async_engine=async_engine,
60
+ sync_engine=sync_engine,
61
+ logger=logger,
62
+ revoke_on_model_change=revoke_on_model_change,
63
+ )
64
+ elif dialect_name == "sqlite":
65
+ return SQLiteDialect(
66
+ async_engine=async_engine,
67
+ sync_engine=sync_engine,
68
+ logger=logger,
69
+ revoke_on_model_change=revoke_on_model_change,
70
+ )
71
+
72
+ raise SQLNotifyUnSupportedDatabaseProviderError(
73
+ f"Database dialect '{dialect_name}' is not supported. " f"Currently supported dialects: PostgreSQL, SQLite."
74
+ )
@@ -0,0 +1,38 @@
1
+ class SQLNotifyException(Exception):
2
+ """
3
+ Base exception for SQLNotify
4
+ """
5
+
6
+ pass
7
+
8
+
9
+ class SQLNotifyConfigurationError(SQLNotifyException):
10
+ """
11
+ Raised when there is a configuration error in SQLNotify
12
+ """
13
+
14
+ pass
15
+
16
+
17
+ class SQLNotifyPayloadSizeError(SQLNotifyException):
18
+ """
19
+ Raised when the maximum payload size for SQLNotify is exceeded
20
+ """
21
+
22
+ pass
23
+
24
+
25
+ class SQLNotifyIdentifierSizeError(SQLNotifyException):
26
+ """
27
+ Raised when the maximum size of an identifier for SQLNotify is exceeded
28
+ """
29
+
30
+ pass
31
+
32
+
33
+ class SQLNotifyUnSupportedDatabaseProviderError(SQLNotifyException):
34
+ """
35
+ Raised when an unsupported database provider is specified for SQLNotify
36
+ """
37
+
38
+ pass
sqlnotify/logger.py ADDED
@@ -0,0 +1,32 @@
1
+ import logging
2
+
3
+ from .constants import PACKAGE_NAME
4
+
5
+
6
+ def get_logger(name: str = PACKAGE_NAME, enabled: bool = True) -> logging.Logger:
7
+ """
8
+ Get a logger for the SQLNotify package.
9
+
10
+ Args:
11
+ name (str): The name of the logger. Defaults to the package name.
12
+ enable (bool): If False, adds a NullHandler to disable logging. Defaults to True.
13
+
14
+ Returns:
15
+ logging.Logger: Configured logger instance
16
+ """
17
+
18
+ logger = logging.getLogger(name)
19
+
20
+ logger.handlers.clear()
21
+
22
+ if enabled:
23
+ handler = logging.StreamHandler()
24
+ formatter = logging.Formatter(fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
25
+ handler.setFormatter(formatter)
26
+ logger.addHandler(handler)
27
+ logger.setLevel(logging.INFO)
28
+ else:
29
+ logger.addHandler(logging.NullHandler())
30
+ logger.setLevel(logging.CRITICAL + 1)
31
+
32
+ return logger
@@ -0,0 +1,3 @@
1
+ from .notifier import Notifier
2
+
3
+ __all__ = ["Notifier"]
@@ -0,0 +1,240 @@
1
+ import asyncio
2
+ import inspect
3
+ from collections.abc import Callable
4
+ from functools import wraps
5
+ from typing import Any
6
+
7
+ from sqlalchemy import inspect as sa_inspect
8
+ from sqlalchemy.orm import Mapper
9
+ from typing_extensions import Self
10
+
11
+ from ..constants import MAX_SQLNOTIFY_EXTRA_COLUMNS, MAX_SQLNOTIFY_PAYLOAD_BYTES
12
+ from ..exceptions import SQLNotifyConfigurationError
13
+ from ..types import FilterOnParams, Operation
14
+ from ..watcher import Watcher
15
+
16
+
17
+ class BaseNotifier:
18
+ """
19
+ Base class for SQLNotify notifier
20
+
21
+ It basically acts as a constructor class for registration and validation of watchers and subscribers of the notifier, but does not implement the actual listening logic.
22
+
23
+ Raises:
24
+ SQLNotifyConfigurationError: If there are configuration issues with watchers or subscribers
25
+ """
26
+
27
+ def __init__(self):
28
+ self.watchers: list[Watcher] = []
29
+ self.subscribers: dict[str, list[Callable]] = {}
30
+ self._listening_task: asyncio.Task | None = None
31
+ self._running = False
32
+ self._logger = None
33
+ self._listener_ready: asyncio.Event | None = None
34
+
35
+ def watch(
36
+ self,
37
+ model: type,
38
+ operation: Operation,
39
+ extra_columns: list[str] | None = None,
40
+ trigger_columns: list[str] | None = None,
41
+ primary_keys: list[str] = ["id"], # noqa: B006
42
+ channel_label: str | None = None,
43
+ use_overflow_table: bool = False,
44
+ ) -> Self:
45
+ """
46
+ Register a watcher for a model and operation
47
+
48
+ Args:
49
+ model (Type): SQLModel or SQLAlchemy model class
50
+ operation (Operation | str): Operation to watch (insert, update, delete)
51
+ extra_columns (Optional[List[str]]): Additional columns to include in notifications. If None (the default),
52
+ only the primary key(s) will be returned in the notification event.
53
+ trigger_columns (Optional[List[str]]): For UPDATE, only trigger on these columns
54
+ primary_keys (List[str]): List of primary key column names. Must be actual primary keys on the model. Defaults to ["id"]
55
+ channel_label (Optional[str]): Custom label for the watcher
56
+ use_overflow_table (bool): If True, large payloads are stored in overflow table
57
+
58
+ Returns:
59
+ Notifier: The Notifier instance
60
+
61
+ Raises:
62
+ SQLNotifyConfigurationError: If there are configuration issues with the watcher (e.g. invalid column names, primary keys not actually being primary keys, etc.)
63
+ """
64
+
65
+ model_columns = self._get_model_columns(model)
66
+ model_primary_keys = self._get_model_primary_keys(model)
67
+
68
+ if not primary_keys:
69
+ raise SQLNotifyConfigurationError(f"primary_keys cannot be empty for model '{model.__name__}'")
70
+
71
+ invalid_primary_keys = [pk for pk in primary_keys if pk not in model_columns]
72
+ if invalid_primary_keys:
73
+ raise SQLNotifyConfigurationError(
74
+ f"Primary key column(s) {invalid_primary_keys} not found on model '{model.__name__}'. "
75
+ f"Available columns: {sorted(model_columns)}"
76
+ )
77
+
78
+ not_actual_pks = [pk for pk in primary_keys if pk not in model_primary_keys]
79
+ if not_actual_pks:
80
+ raise SQLNotifyConfigurationError(
81
+ f"Column(s) {not_actual_pks} exist on model '{model.__name__}' but are not primary keys. "
82
+ f"Actual primary keys: {sorted(model_primary_keys) if model_primary_keys else 'None defined'}"
83
+ )
84
+
85
+ if extra_columns:
86
+ invalid_extra_cols = [col for col in extra_columns if col not in model_columns]
87
+ if invalid_extra_cols:
88
+ raise SQLNotifyConfigurationError(
89
+ f"Extra column(s) {invalid_extra_cols} not found on model '{model.__name__}'. "
90
+ f"Available columns: {sorted(model_columns)}"
91
+ )
92
+
93
+ if trigger_columns:
94
+ invalid_trigger_cols = [col for col in trigger_columns if col not in model_columns]
95
+ if invalid_trigger_cols:
96
+ raise SQLNotifyConfigurationError(
97
+ f"Trigger column(s) {invalid_trigger_cols} not found on model '{model.__name__}'. "
98
+ f"Available columns: {sorted(model_columns)}"
99
+ )
100
+
101
+ if extra_columns and len(extra_columns) > MAX_SQLNOTIFY_EXTRA_COLUMNS:
102
+ if self._logger:
103
+ self._logger.warning(
104
+ f"SQLNotify Watcher for {model.__name__}.{operation} has {len(extra_columns)} extra_columns. "
105
+ f"This may exceed SQLNotify {MAX_SQLNOTIFY_PAYLOAD_BYTES} byte limit. "
106
+ f"Consider setting use_overflow_table=True or reducing extra_columns."
107
+ )
108
+
109
+ watcher = Watcher(
110
+ model=model,
111
+ operation=operation,
112
+ extra_columns=extra_columns,
113
+ trigger_columns=trigger_columns,
114
+ primary_keys=primary_keys,
115
+ channel_label=channel_label,
116
+ use_overflow_table=use_overflow_table,
117
+ logger=self._logger,
118
+ )
119
+
120
+ self.watchers.append(watcher)
121
+
122
+ return self
123
+
124
+ def subscribe(
125
+ self,
126
+ model: type | str,
127
+ operation: Operation,
128
+ filters: list[FilterOnParams] | None = None,
129
+ ):
130
+ """
131
+ Decorator to subscribe to change events
132
+
133
+ Args:
134
+ model (Union[Type, str]): Model class or class name as string to watch
135
+ operation (Operation | str): Operation to watch
136
+ filters (Optional[List[FilterOnParams]]): Optional list of column filters to watch specific records
137
+
138
+ Returns:
139
+ Callable: Decorator for subscriber function
140
+
141
+ Raises:
142
+ SQLNotifyConfigurationError: If model is not registered as a watcher for the specified operation,
143
+ or if filter column names don't exist on the model
144
+ """
145
+
146
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
147
+ if inspect.iscoroutinefunction(func):
148
+
149
+ @wraps(func)
150
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
151
+ return await func(*args, **kwargs)
152
+
153
+ registered_func = async_wrapper
154
+ else:
155
+
156
+ @wraps(func)
157
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
158
+ return func(*args, **kwargs)
159
+
160
+ registered_func = sync_wrapper
161
+
162
+ matching_watcher = None
163
+
164
+ for watcher in self.watchers:
165
+ watcher_matches = False
166
+
167
+ if isinstance(model, str):
168
+ watcher_matches = watcher.model.__name__ == model and watcher.operation == operation
169
+ else:
170
+ watcher_matches = watcher.model == model and watcher.operation == operation
171
+
172
+ if watcher_matches:
173
+ matching_watcher = watcher
174
+ break
175
+
176
+ if not matching_watcher:
177
+ raise SQLNotifyConfigurationError(
178
+ f"Model {model if isinstance(model, str) else model.__name__} is not registered as a watcher for operation {operation}"
179
+ )
180
+
181
+ if filters:
182
+ model_columns = self._get_model_columns(matching_watcher.model)
183
+ for filter_param in filters:
184
+ column_name = filter_param["column"]
185
+
186
+ if column_name not in model_columns:
187
+ raise SQLNotifyConfigurationError(
188
+ f"Column '{column_name}' does not exist on model '{matching_watcher.model.__name__}'. "
189
+ f"Available columns: {', '.join(model_columns)}"
190
+ )
191
+
192
+ channel = matching_watcher.channel_name
193
+
194
+ if filters:
195
+ sorted_filters = sorted(filters, key=lambda f: f["column"])
196
+ filter_parts = [f"{f['column']}:{f['value']}" for f in sorted_filters]
197
+ channel = f"{channel}:{'|'.join(filter_parts)}"
198
+
199
+ if channel not in self.subscribers:
200
+ self.subscribers[channel] = []
201
+
202
+ self.subscribers[channel].append(registered_func)
203
+
204
+ return registered_func
205
+
206
+ return decorator
207
+
208
+ def _get_model_columns(self, model: type) -> set[str]:
209
+ """
210
+ Get all column names from a model.
211
+
212
+ Args:
213
+ model (Type): SQLModel or SQLAlchemy model class
214
+
215
+ Returns:
216
+ set[str]: Set of column names
217
+ """
218
+
219
+ try:
220
+ mapper: Mapper[Any] = sa_inspect(model)
221
+ return {c.key for c in mapper.column_attrs}
222
+ except Exception:
223
+ return set()
224
+
225
+ def _get_model_primary_keys(self, model: type) -> set[str]:
226
+ """
227
+ Get actual primary key column names from a model.
228
+
229
+ Args:
230
+ model (Type): SQLModel or SQLAlchemy model class
231
+
232
+ Returns:
233
+ set[str]: Set of primary key column names
234
+ """
235
+
236
+ try:
237
+ mapper: Mapper[Any] = sa_inspect(model)
238
+ return {column.name for column in mapper.primary_key}
239
+ except Exception:
240
+ return set()