sqlalchemy-events-lib 0.2.0__tar.gz → 0.3.1__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.
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/PKG-INFO +23 -15
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/README.md +22 -14
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/pyproject.toml +1 -1
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/__init__.py +1 -1
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/core.py +54 -20
- sqlalchemy_events_lib-0.3.1/src/sqlalchemy_events/decorators.py +25 -0
- sqlalchemy_events_lib-0.3.1/src/sqlalchemy_events/default_logger.py +25 -0
- sqlalchemy_events_lib-0.3.1/src/sqlalchemy_events/discovery.py +44 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/handlers.py +3 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events_lib.egg-info/PKG-INFO +23 -15
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events_lib.egg-info/SOURCES.txt +5 -1
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/tests/conftest.py +2 -4
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/tests/test_autodiscover.py +19 -0
- sqlalchemy_events_lib-0.3.1/src/tests/test_handlers.py +17 -0
- sqlalchemy_events_lib-0.3.1/src/tests/test_sa_events.py +27 -0
- sqlalchemy_events_lib-0.3.1/src/tests/test_with_events_deco.py +45 -0
- sqlalchemy_events_lib-0.2.0/src/sqlalchemy_events/decorators.py +0 -14
- sqlalchemy_events_lib-0.2.0/src/sqlalchemy_events/discovery.py +0 -24
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/LICENSE +0 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/setup.cfg +0 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/callbacks_strategies/__init__.py +0 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/callbacks_strategies/base.py +0 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/callbacks_strategies/postgres_callback.py +0 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/events.py +0 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/exceptions.py +0 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/init_triggers_strategies/__init__.py +0 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/init_triggers_strategies/base.py +0 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/init_triggers_strategies/postgres_init_triggers.py +0 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/registry.py +0 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/types.py +0 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/utils.py +0 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events_lib.egg-info/dependency_links.txt +0 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events_lib.egg-info/requires.txt +0 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events_lib.egg-info/top_level.txt +0 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/tests/test_is_created_triggers.py +0 -0
- {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/tests/test_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlalchemy-events-lib
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Event-driven extension for SQLAlchemy that enables listening to database CUD events. This library allows you to react to database changes in real time using a clean, declarative API.
|
|
5
5
|
Author-email: Alexey Kostarev <normjkeeewm@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -96,7 +96,6 @@ ___
|
|
|
96
96
|
session.py
|
|
97
97
|
```python
|
|
98
98
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
99
|
-
|
|
100
99
|
from config import DATABASE_URL
|
|
101
100
|
|
|
102
101
|
engine = create_async_engine(DATABASE_URL)
|
|
@@ -110,16 +109,15 @@ main.py
|
|
|
110
109
|
```python
|
|
111
110
|
import asyncio
|
|
112
111
|
from sqlalchemy_events import SQLAlchemyEvents
|
|
113
|
-
from models import Base
|
|
114
112
|
from session import engine
|
|
115
113
|
|
|
116
114
|
|
|
117
115
|
async def main():
|
|
118
|
-
SQLAlchemyEvents(
|
|
119
|
-
base=Base,
|
|
116
|
+
sa_events = SQLAlchemyEvents(
|
|
120
117
|
engine=engine,
|
|
121
118
|
autodiscover_paths=['services']
|
|
122
119
|
)
|
|
120
|
+
await sa_events()
|
|
123
121
|
while True:
|
|
124
122
|
await asyncio.sleep(9999)
|
|
125
123
|
|
|
@@ -133,22 +131,23 @@ if __name__ == '__main__':
|
|
|
133
131
|
The SQLAlchemyEvents class accepts the following parameters:
|
|
134
132
|
```python
|
|
135
133
|
SQLAlchemyEvents(
|
|
136
|
-
base,
|
|
137
134
|
engine,
|
|
138
135
|
autodiscover_paths,
|
|
139
|
-
logger=None
|
|
136
|
+
logger=None,
|
|
137
|
+
verbose=True
|
|
140
138
|
)
|
|
141
139
|
```
|
|
142
140
|
|
|
143
141
|
Parameters:
|
|
144
|
-
* **base** - SQLAlchemy declarative base class used to discover mapped models.
|
|
145
142
|
* **engine** - SQLAlchemy Engine or AsyncEngine instance.
|
|
146
143
|
* **autodiscover_paths** - List of Python module paths where event handlers are defined.
|
|
147
144
|
These modules are automatically imported so that decorators such as
|
|
148
|
-
`@sa_insert_handler`, `@sa_update_handler`, `@sa_delete_handler` are executed.
|
|
145
|
+
`@sa_insert_handler`, `@sa_update_handler`, `@sa_delete_handler` are executed.
|
|
149
146
|
**Example:**
|
|
150
147
|
`autodiscover_paths=["services", "app.handlers"]`
|
|
151
148
|
|
|
149
|
+
* **verbose** - Enables detailed logging output. When set to True, the library will log additional informational and warning messages to help with debugging and configuration.
|
|
150
|
+
|
|
152
151
|
### Important:
|
|
153
152
|
|
|
154
153
|
All modules containing event handlers must be imported through autodiscover
|
|
@@ -166,16 +165,25 @@ If provided, the library will log internal lifecycle events such as:
|
|
|
166
165
|
**Example:**
|
|
167
166
|
```python
|
|
168
167
|
import logging
|
|
168
|
+
import asyncio
|
|
169
|
+
from sqlalchemy_events import SQLAlchemyEvents
|
|
170
|
+
from session import engine
|
|
169
171
|
|
|
170
172
|
logger = logging.getLogger('sqlalchemy_events')
|
|
171
173
|
logger.setLevel(logging.INFO)
|
|
172
174
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
)
|
|
175
|
+
async def main():
|
|
176
|
+
sa_events = SQLAlchemyEvents(
|
|
177
|
+
engine=engine,
|
|
178
|
+
autodiscover_paths=['services'],
|
|
179
|
+
logger=logger
|
|
180
|
+
)
|
|
181
|
+
await sa_events()
|
|
182
|
+
while True:
|
|
183
|
+
await asyncio.sleep(9999)
|
|
184
|
+
|
|
185
|
+
if __name__ == '__main__':
|
|
186
|
+
asyncio.run(main())
|
|
179
187
|
```
|
|
180
188
|
|
|
181
189
|
## How it works
|
|
@@ -74,7 +74,6 @@ ___
|
|
|
74
74
|
session.py
|
|
75
75
|
```python
|
|
76
76
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
77
|
-
|
|
78
77
|
from config import DATABASE_URL
|
|
79
78
|
|
|
80
79
|
engine = create_async_engine(DATABASE_URL)
|
|
@@ -88,16 +87,15 @@ main.py
|
|
|
88
87
|
```python
|
|
89
88
|
import asyncio
|
|
90
89
|
from sqlalchemy_events import SQLAlchemyEvents
|
|
91
|
-
from models import Base
|
|
92
90
|
from session import engine
|
|
93
91
|
|
|
94
92
|
|
|
95
93
|
async def main():
|
|
96
|
-
SQLAlchemyEvents(
|
|
97
|
-
base=Base,
|
|
94
|
+
sa_events = SQLAlchemyEvents(
|
|
98
95
|
engine=engine,
|
|
99
96
|
autodiscover_paths=['services']
|
|
100
97
|
)
|
|
98
|
+
await sa_events()
|
|
101
99
|
while True:
|
|
102
100
|
await asyncio.sleep(9999)
|
|
103
101
|
|
|
@@ -111,22 +109,23 @@ if __name__ == '__main__':
|
|
|
111
109
|
The SQLAlchemyEvents class accepts the following parameters:
|
|
112
110
|
```python
|
|
113
111
|
SQLAlchemyEvents(
|
|
114
|
-
base,
|
|
115
112
|
engine,
|
|
116
113
|
autodiscover_paths,
|
|
117
|
-
logger=None
|
|
114
|
+
logger=None,
|
|
115
|
+
verbose=True
|
|
118
116
|
)
|
|
119
117
|
```
|
|
120
118
|
|
|
121
119
|
Parameters:
|
|
122
|
-
* **base** - SQLAlchemy declarative base class used to discover mapped models.
|
|
123
120
|
* **engine** - SQLAlchemy Engine or AsyncEngine instance.
|
|
124
121
|
* **autodiscover_paths** - List of Python module paths where event handlers are defined.
|
|
125
122
|
These modules are automatically imported so that decorators such as
|
|
126
|
-
`@sa_insert_handler`, `@sa_update_handler`, `@sa_delete_handler` are executed.
|
|
123
|
+
`@sa_insert_handler`, `@sa_update_handler`, `@sa_delete_handler` are executed.
|
|
127
124
|
**Example:**
|
|
128
125
|
`autodiscover_paths=["services", "app.handlers"]`
|
|
129
126
|
|
|
127
|
+
* **verbose** - Enables detailed logging output. When set to True, the library will log additional informational and warning messages to help with debugging and configuration.
|
|
128
|
+
|
|
130
129
|
### Important:
|
|
131
130
|
|
|
132
131
|
All modules containing event handlers must be imported through autodiscover
|
|
@@ -144,16 +143,25 @@ If provided, the library will log internal lifecycle events such as:
|
|
|
144
143
|
**Example:**
|
|
145
144
|
```python
|
|
146
145
|
import logging
|
|
146
|
+
import asyncio
|
|
147
|
+
from sqlalchemy_events import SQLAlchemyEvents
|
|
148
|
+
from session import engine
|
|
147
149
|
|
|
148
150
|
logger = logging.getLogger('sqlalchemy_events')
|
|
149
151
|
logger.setLevel(logging.INFO)
|
|
150
152
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
)
|
|
153
|
+
async def main():
|
|
154
|
+
sa_events = SQLAlchemyEvents(
|
|
155
|
+
engine=engine,
|
|
156
|
+
autodiscover_paths=['services'],
|
|
157
|
+
logger=logger
|
|
158
|
+
)
|
|
159
|
+
await sa_events()
|
|
160
|
+
while True:
|
|
161
|
+
await asyncio.sleep(9999)
|
|
162
|
+
|
|
163
|
+
if __name__ == '__main__':
|
|
164
|
+
asyncio.run(main())
|
|
157
165
|
```
|
|
158
166
|
|
|
159
167
|
## How it works
|
|
@@ -10,7 +10,7 @@ where = ["src"]
|
|
|
10
10
|
|
|
11
11
|
[project]
|
|
12
12
|
name = "sqlalchemy-events-lib"
|
|
13
|
-
version = "0.
|
|
13
|
+
version = "0.3.1"
|
|
14
14
|
description = "Event-driven extension for SQLAlchemy that enables listening to database CUD events. This library allows you to react to database changes in real time using a clean, declarative API."
|
|
15
15
|
requires-python = ">=3.12"
|
|
16
16
|
authors = [
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import logging
|
|
3
|
+
from collections import defaultdict
|
|
3
4
|
from pathlib import Path
|
|
4
|
-
from typing import Optional,
|
|
5
|
+
from typing import Optional, Union
|
|
5
6
|
import inspect
|
|
6
7
|
|
|
7
8
|
from sqlalchemy import Engine
|
|
8
9
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
9
10
|
from sqlalchemy.orm import DeclarativeBase
|
|
10
11
|
|
|
12
|
+
from .default_logger import DefaultLogger
|
|
11
13
|
from .discovery import autodiscover
|
|
12
14
|
from .events import SaEventStrategy, sa_events_strategy
|
|
13
15
|
from .registry import get_event_handlers
|
|
16
|
+
from .types import Handler
|
|
14
17
|
from .utils import dialect_resolver
|
|
15
18
|
|
|
16
19
|
|
|
@@ -18,19 +21,34 @@ class SQLAlchemyEvents:
|
|
|
18
21
|
|
|
19
22
|
def __init__(
|
|
20
23
|
self,
|
|
21
|
-
base: Type[DeclarativeBase],
|
|
22
24
|
engine: Union[AsyncEngine, Engine],
|
|
23
|
-
autodiscover_paths: list[str],
|
|
24
|
-
logger: Optional[logging.Logger] = None
|
|
25
|
+
autodiscover_paths: list[str | Path],
|
|
26
|
+
logger: Optional[logging.Logger] = None,
|
|
27
|
+
verbose: bool = True
|
|
25
28
|
) -> None:
|
|
26
|
-
self.base = base
|
|
27
29
|
self.engine = engine
|
|
28
30
|
self.autodiscover_paths = autodiscover_paths
|
|
29
|
-
self.logger = logger
|
|
30
|
-
|
|
31
|
+
self.logger = logger or DefaultLogger() if verbose else None
|
|
32
|
+
self.verbose = verbose
|
|
33
|
+
|
|
34
|
+
async def __call__(self) -> None:
|
|
35
|
+
if not isinstance(self.engine, (AsyncEngine, Engine)):
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
'[SQLAlchemyEvents] \'engine\' must be an instance of '
|
|
38
|
+
'sqlalchemy.Engine or sqlalchemy.ext.asyncio.AsyncEngine.'
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if not self.autodiscover_paths:
|
|
42
|
+
if self.verbose:
|
|
43
|
+
self.logger.warning(
|
|
44
|
+
'[SQLAlchemyEvents] No autodiscover paths specified. '
|
|
45
|
+
'Please provide at least one module path (e.g., \'app.handlers\') '
|
|
46
|
+
'in \'autodiscover_paths\' during initialization.'
|
|
47
|
+
)
|
|
48
|
+
return
|
|
31
49
|
|
|
32
|
-
|
|
33
|
-
if not
|
|
50
|
+
handlers = await self.__find_handlers()
|
|
51
|
+
if not handlers:
|
|
34
52
|
return
|
|
35
53
|
|
|
36
54
|
dialect = dialect_resolver(self.engine)
|
|
@@ -38,13 +56,13 @@ class SQLAlchemyEvents:
|
|
|
38
56
|
if not event_strategy:
|
|
39
57
|
raise RuntimeError(f'[SQLAlchemyEvents] Unsupported database {dialect}. '
|
|
40
58
|
f'This library supports only {', '.join(sa_events_strategy.keys())}')
|
|
41
|
-
await self.__start_listen(event_strategy)
|
|
59
|
+
await self.__start_listen(event_strategy, handlers)
|
|
42
60
|
|
|
43
61
|
async def __find_handlers(self):
|
|
44
62
|
autodiscover(self.autodiscover_paths)
|
|
45
63
|
handlers = get_event_handlers()
|
|
46
64
|
if not handlers:
|
|
47
|
-
if self.
|
|
65
|
+
if self.verbose:
|
|
48
66
|
self.logger.info('[SQLAlchemyEvents] No handlers found')
|
|
49
67
|
return
|
|
50
68
|
res_handlers = []
|
|
@@ -53,6 +71,7 @@ class SQLAlchemyEvents:
|
|
|
53
71
|
|
|
54
72
|
filtered_handlers = []
|
|
55
73
|
handler_paths = set()
|
|
74
|
+
handlers_qty = defaultdict(int)
|
|
56
75
|
for handler in res_handlers:
|
|
57
76
|
file_path = inspect.getsourcefile(handler.func) or inspect.getfile(handler.func)
|
|
58
77
|
|
|
@@ -64,13 +83,28 @@ class SQLAlchemyEvents:
|
|
|
64
83
|
|
|
65
84
|
handler_paths.add(handler_path)
|
|
66
85
|
filtered_handlers.append(handler)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
86
|
+
handlers_qty[f'{file_func.parent.name}/{file_name}'] += 1
|
|
87
|
+
|
|
88
|
+
if self.verbose:
|
|
89
|
+
for path, qty in handlers_qty.items():
|
|
90
|
+
self.logger.info(f'[SQLAlchemyEvents] Registered {qty} {'handler' if qty == 1 else 'handlers'} '
|
|
91
|
+
f'from \'{path}\'')
|
|
70
92
|
|
|
71
93
|
return filtered_handlers
|
|
72
94
|
|
|
73
|
-
async def __start_listen(self, event_strategy: SaEventStrategy):
|
|
95
|
+
async def __start_listen(self, event_strategy: SaEventStrategy, handlers: list[Handler]):
|
|
96
|
+
base = None
|
|
97
|
+
try:
|
|
98
|
+
model = handlers[0].args['model']
|
|
99
|
+
for cls in model.__mro__:
|
|
100
|
+
if issubclass(cls, DeclarativeBase) and cls is not DeclarativeBase:
|
|
101
|
+
base = cls
|
|
102
|
+
|
|
103
|
+
if not base:
|
|
104
|
+
raise Exception
|
|
105
|
+
except Exception:
|
|
106
|
+
raise RuntimeError('[SQLAlchemyEvents] No Base found in Registered handlers')
|
|
107
|
+
|
|
74
108
|
if isinstance(self.engine, AsyncEngine):
|
|
75
109
|
async with self.engine.connect() as conn:
|
|
76
110
|
raw_conn = await conn.get_raw_connection()
|
|
@@ -80,11 +114,11 @@ class SQLAlchemyEvents:
|
|
|
80
114
|
raise RuntimeError('[SQLAlchemyEvents] Driver does not support LISTEN/NOTIFY')
|
|
81
115
|
|
|
82
116
|
await event_strategy.init_triggers(
|
|
83
|
-
model_list=
|
|
117
|
+
model_list=base.__subclasses__(),
|
|
84
118
|
conn=conn,
|
|
85
119
|
logger=self.logger
|
|
86
120
|
)
|
|
87
|
-
if self.
|
|
121
|
+
if self.verbose:
|
|
88
122
|
self.logger.info('[SQLAlchemyEvents] Start listening')
|
|
89
123
|
await driver_conn.add_listener('sqlalchemy_events', event_strategy.callback.handle)
|
|
90
124
|
return
|
|
@@ -103,11 +137,11 @@ class SQLAlchemyEvents:
|
|
|
103
137
|
if not asyncio.iscoroutine(result):
|
|
104
138
|
raise RuntimeError(sync_engine_error)
|
|
105
139
|
await event_strategy.init_triggers(
|
|
106
|
-
model_list=
|
|
140
|
+
model_list=base.__subclasses__(),
|
|
107
141
|
conn=conn,
|
|
108
142
|
logger=self.logger
|
|
109
143
|
)
|
|
110
|
-
if self.
|
|
144
|
+
if self.verbose:
|
|
111
145
|
self.logger.info('[SQLAlchemyEvents] Start listening')
|
|
112
146
|
await result
|
|
113
147
|
|
|
@@ -120,6 +154,6 @@ class SQLAlchemyEvents:
|
|
|
120
154
|
try:
|
|
121
155
|
await driver_conn.wait_for_notify()
|
|
122
156
|
except Exception as e:
|
|
123
|
-
if self.
|
|
157
|
+
if self.verbose:
|
|
124
158
|
self.logger.error(f'[SQLAlchemyEvents] listener crashed: {e}')
|
|
125
159
|
await asyncio.sleep(1)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
2
|
+
|
|
3
|
+
from .types import SaEvent
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def with_events(events: list[SaEvent]):
|
|
7
|
+
if not isinstance(events, list) or not events:
|
|
8
|
+
raise RuntimeError('Events must be a list with at least one SeEvent element')
|
|
9
|
+
|
|
10
|
+
for e in events:
|
|
11
|
+
if not isinstance(e, SaEvent):
|
|
12
|
+
raise RuntimeError(f'Event must be a SaEvent instance, not {type(e)}')
|
|
13
|
+
|
|
14
|
+
def wrapper(cls):
|
|
15
|
+
if not issubclass(cls, DeclarativeBase):
|
|
16
|
+
raise RuntimeError('Model must inherit from DeclarativeBase')
|
|
17
|
+
cls.__events__ = set(events)
|
|
18
|
+
|
|
19
|
+
class Events:
|
|
20
|
+
for e in events:
|
|
21
|
+
locals()[e.name] = e.value
|
|
22
|
+
|
|
23
|
+
cls.events = Events
|
|
24
|
+
return cls
|
|
25
|
+
return wrapper
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DefaultLogger:
|
|
5
|
+
def __init__(self) -> None:
|
|
6
|
+
self.__logger = logging.getLogger('SQLAlchemyEvents')
|
|
7
|
+
self.__logger.setLevel(logging.INFO)
|
|
8
|
+
|
|
9
|
+
if not self.__logger.handlers:
|
|
10
|
+
formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
|
|
11
|
+
console_handler = logging.StreamHandler()
|
|
12
|
+
console_handler.setFormatter(formatter)
|
|
13
|
+
self.__logger.addHandler(console_handler)
|
|
14
|
+
|
|
15
|
+
def info(self, message: str) -> None:
|
|
16
|
+
self.__logger.info(message)
|
|
17
|
+
|
|
18
|
+
def error(self, message: str, exc_info: bool = True) -> None:
|
|
19
|
+
self.__logger.error(message, exc_info=exc_info)
|
|
20
|
+
|
|
21
|
+
def warning(self, message: str) -> None:
|
|
22
|
+
self.__logger.warning(message)
|
|
23
|
+
|
|
24
|
+
def debug(self, message: str) -> None:
|
|
25
|
+
self.__logger.debug(message)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import pkgutil
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from types import ModuleType
|
|
5
|
+
from typing import Iterable, List
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def autodiscover(paths: Iterable[str | Path]) -> List[ModuleType]:
|
|
9
|
+
modules: List[ModuleType] = []
|
|
10
|
+
seen: set[str] = set()
|
|
11
|
+
|
|
12
|
+
def add_module(module: ModuleType):
|
|
13
|
+
if module.__name__ in seen:
|
|
14
|
+
return
|
|
15
|
+
seen.add(module.__name__)
|
|
16
|
+
modules.append(module)
|
|
17
|
+
|
|
18
|
+
for path in paths:
|
|
19
|
+
is_file = False
|
|
20
|
+
|
|
21
|
+
if isinstance(path, Path):
|
|
22
|
+
is_file = path.suffix == '.py'
|
|
23
|
+
path = str(path)
|
|
24
|
+
|
|
25
|
+
if isinstance(path, str) and path.endswith('.py'):
|
|
26
|
+
is_file = True
|
|
27
|
+
path = path[:-3]
|
|
28
|
+
|
|
29
|
+
path = path.replace('\\', '.').replace('/', '.').lstrip('.')
|
|
30
|
+
|
|
31
|
+
module = importlib.import_module(path)
|
|
32
|
+
add_module(module)
|
|
33
|
+
|
|
34
|
+
if is_file or not hasattr(module, '__path__'):
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
for _, module_name, _ in pkgutil.walk_packages(
|
|
38
|
+
module.__path__,
|
|
39
|
+
module.__name__ + '.',
|
|
40
|
+
):
|
|
41
|
+
submodule = importlib.import_module(module_name)
|
|
42
|
+
add_module(submodule)
|
|
43
|
+
|
|
44
|
+
return modules
|
{sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/handlers.py
RENAMED
|
@@ -10,6 +10,9 @@ from .types import Handler
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def __inner(func: Callable, sa_event: SaEvent, model: Type[DeclarativeBase]):
|
|
13
|
+
if not issubclass(model, DeclarativeBase):
|
|
14
|
+
raise RuntimeError('Model must inherit from DeclarativeBase')
|
|
15
|
+
|
|
13
16
|
event_handlers = get_event_handlers()
|
|
14
17
|
trig_name = f'sa_{model.__tablename__}_{sa_event.lower()}_notify'
|
|
15
18
|
func_path = inspect.getsourcefile(func) or inspect.getfile(func)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlalchemy-events-lib
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Event-driven extension for SQLAlchemy that enables listening to database CUD events. This library allows you to react to database changes in real time using a clean, declarative API.
|
|
5
5
|
Author-email: Alexey Kostarev <normjkeeewm@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -96,7 +96,6 @@ ___
|
|
|
96
96
|
session.py
|
|
97
97
|
```python
|
|
98
98
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
99
|
-
|
|
100
99
|
from config import DATABASE_URL
|
|
101
100
|
|
|
102
101
|
engine = create_async_engine(DATABASE_URL)
|
|
@@ -110,16 +109,15 @@ main.py
|
|
|
110
109
|
```python
|
|
111
110
|
import asyncio
|
|
112
111
|
from sqlalchemy_events import SQLAlchemyEvents
|
|
113
|
-
from models import Base
|
|
114
112
|
from session import engine
|
|
115
113
|
|
|
116
114
|
|
|
117
115
|
async def main():
|
|
118
|
-
SQLAlchemyEvents(
|
|
119
|
-
base=Base,
|
|
116
|
+
sa_events = SQLAlchemyEvents(
|
|
120
117
|
engine=engine,
|
|
121
118
|
autodiscover_paths=['services']
|
|
122
119
|
)
|
|
120
|
+
await sa_events()
|
|
123
121
|
while True:
|
|
124
122
|
await asyncio.sleep(9999)
|
|
125
123
|
|
|
@@ -133,22 +131,23 @@ if __name__ == '__main__':
|
|
|
133
131
|
The SQLAlchemyEvents class accepts the following parameters:
|
|
134
132
|
```python
|
|
135
133
|
SQLAlchemyEvents(
|
|
136
|
-
base,
|
|
137
134
|
engine,
|
|
138
135
|
autodiscover_paths,
|
|
139
|
-
logger=None
|
|
136
|
+
logger=None,
|
|
137
|
+
verbose=True
|
|
140
138
|
)
|
|
141
139
|
```
|
|
142
140
|
|
|
143
141
|
Parameters:
|
|
144
|
-
* **base** - SQLAlchemy declarative base class used to discover mapped models.
|
|
145
142
|
* **engine** - SQLAlchemy Engine or AsyncEngine instance.
|
|
146
143
|
* **autodiscover_paths** - List of Python module paths where event handlers are defined.
|
|
147
144
|
These modules are automatically imported so that decorators such as
|
|
148
|
-
`@sa_insert_handler`, `@sa_update_handler`, `@sa_delete_handler` are executed.
|
|
145
|
+
`@sa_insert_handler`, `@sa_update_handler`, `@sa_delete_handler` are executed.
|
|
149
146
|
**Example:**
|
|
150
147
|
`autodiscover_paths=["services", "app.handlers"]`
|
|
151
148
|
|
|
149
|
+
* **verbose** - Enables detailed logging output. When set to True, the library will log additional informational and warning messages to help with debugging and configuration.
|
|
150
|
+
|
|
152
151
|
### Important:
|
|
153
152
|
|
|
154
153
|
All modules containing event handlers must be imported through autodiscover
|
|
@@ -166,16 +165,25 @@ If provided, the library will log internal lifecycle events such as:
|
|
|
166
165
|
**Example:**
|
|
167
166
|
```python
|
|
168
167
|
import logging
|
|
168
|
+
import asyncio
|
|
169
|
+
from sqlalchemy_events import SQLAlchemyEvents
|
|
170
|
+
from session import engine
|
|
169
171
|
|
|
170
172
|
logger = logging.getLogger('sqlalchemy_events')
|
|
171
173
|
logger.setLevel(logging.INFO)
|
|
172
174
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
)
|
|
175
|
+
async def main():
|
|
176
|
+
sa_events = SQLAlchemyEvents(
|
|
177
|
+
engine=engine,
|
|
178
|
+
autodiscover_paths=['services'],
|
|
179
|
+
logger=logger
|
|
180
|
+
)
|
|
181
|
+
await sa_events()
|
|
182
|
+
while True:
|
|
183
|
+
await asyncio.sleep(9999)
|
|
184
|
+
|
|
185
|
+
if __name__ == '__main__':
|
|
186
|
+
asyncio.run(main())
|
|
179
187
|
```
|
|
180
188
|
|
|
181
189
|
## How it works
|
|
@@ -4,6 +4,7 @@ pyproject.toml
|
|
|
4
4
|
src/sqlalchemy_events/__init__.py
|
|
5
5
|
src/sqlalchemy_events/core.py
|
|
6
6
|
src/sqlalchemy_events/decorators.py
|
|
7
|
+
src/sqlalchemy_events/default_logger.py
|
|
7
8
|
src/sqlalchemy_events/discovery.py
|
|
8
9
|
src/sqlalchemy_events/events.py
|
|
9
10
|
src/sqlalchemy_events/exceptions.py
|
|
@@ -24,5 +25,8 @@ src/sqlalchemy_events_lib.egg-info/requires.txt
|
|
|
24
25
|
src/sqlalchemy_events_lib.egg-info/top_level.txt
|
|
25
26
|
src/tests/conftest.py
|
|
26
27
|
src/tests/test_autodiscover.py
|
|
28
|
+
src/tests/test_handlers.py
|
|
27
29
|
src/tests/test_is_created_triggers.py
|
|
28
|
-
src/tests/test_models.py
|
|
30
|
+
src/tests/test_models.py
|
|
31
|
+
src/tests/test_sa_events.py
|
|
32
|
+
src/tests/test_with_events_deco.py
|
|
@@ -32,12 +32,10 @@ async def prepared_db(engine):
|
|
|
32
32
|
await conn.execute(text('CREATE SCHEMA public'))
|
|
33
33
|
await conn.run_sync(Base.metadata.create_all)
|
|
34
34
|
|
|
35
|
-
SQLAlchemyEvents(
|
|
36
|
-
base=Base,
|
|
35
|
+
await SQLAlchemyEvents(
|
|
37
36
|
engine=engine,
|
|
38
37
|
autodiscover_paths=['tests']
|
|
39
|
-
)
|
|
40
|
-
await asyncio.sleep(0.1)
|
|
38
|
+
)()
|
|
41
39
|
IS_DB = True
|
|
42
40
|
|
|
43
41
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import sys
|
|
2
2
|
import types
|
|
3
3
|
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
4
6
|
from sqlalchemy_events.discovery import autodiscover
|
|
5
7
|
from sqlalchemy_events import sa_insert_handler, sa_update_handler, sa_delete_handler
|
|
6
8
|
from sqlalchemy_events.registry import get_event_handlers
|
|
@@ -127,3 +129,20 @@ async def test_discover_handlers():
|
|
|
127
129
|
result_handlers.extend(handler_list)
|
|
128
130
|
|
|
129
131
|
assert len(result_handlers) == len(handlers)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_invalid_autodiscover_path():
|
|
135
|
+
with pytest.raises(ModuleNotFoundError):
|
|
136
|
+
autodiscover(['not.existing.module'])
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_autodiscover_no_duplicates():
|
|
140
|
+
modules = autodiscover(['tests', 'tests.test_autodiscover.py'])
|
|
141
|
+
|
|
142
|
+
names = [m.__name__ for m in modules]
|
|
143
|
+
assert len(names) == len(set(names))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_autodiscover_import_error_is_handled():
|
|
147
|
+
with pytest.raises(Exception):
|
|
148
|
+
autodiscover(['app.broken_module'])
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from sqlalchemy_events import sa_insert_handler
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_invalid_model_in_handler():
|
|
7
|
+
with pytest.raises(RuntimeError):
|
|
8
|
+
@sa_insert_handler(object)
|
|
9
|
+
def test_func():
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_handler_signature():
|
|
14
|
+
with pytest.raises(TypeError):
|
|
15
|
+
@sa_insert_handler()
|
|
16
|
+
def test_func():
|
|
17
|
+
pass
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from sqlalchemy_events import SQLAlchemyEvents
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def test_invalid_engine_type():
|
|
9
|
+
with pytest.raises(RuntimeError):
|
|
10
|
+
await SQLAlchemyEvents(engine='not_engine', autodiscover_paths=[])()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def test_empty_autodiscover_paths_logs_warning(caplog, engine):
|
|
14
|
+
caplog.set_level('WARNING', logger='SQLAlchemyEvents')
|
|
15
|
+
await SQLAlchemyEvents(engine=engine, autodiscover_paths=[], verbose=True)()
|
|
16
|
+
assert 'No autodiscover paths' in caplog.text
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def test_no_logs_when_verbose_disabled(caplog, engine):
|
|
20
|
+
await SQLAlchemyEvents(engine=engine, autodiscover_paths=[], verbose=False)()
|
|
21
|
+
assert caplog.text == ''
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def test_custom_logger_used(caplog, engine):
|
|
25
|
+
logger = logging.getLogger('test')
|
|
26
|
+
await SQLAlchemyEvents(engine=engine, autodiscover_paths=[], logger=logger)()
|
|
27
|
+
assert 'SQLAlchemyEvents' in caplog.text
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from sqlalchemy import Column, Integer, String
|
|
3
|
+
|
|
4
|
+
from sqlalchemy_events import SaEvent, with_events
|
|
5
|
+
from tests.test_models import Base, UserModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_with_events_on_incorrect_class_type():
|
|
9
|
+
with pytest.raises(RuntimeError):
|
|
10
|
+
@with_events([SaEvent.INSERT])
|
|
11
|
+
class Test: ...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_incorrect_event_type():
|
|
15
|
+
with pytest.raises(RuntimeError):
|
|
16
|
+
@with_events(['incorrect_event_type'])
|
|
17
|
+
class UserModel(Base):
|
|
18
|
+
__tablename__ = 'test_model'
|
|
19
|
+
|
|
20
|
+
id = Column(Integer, primary_key=True)
|
|
21
|
+
name = Column(String)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_empty_events():
|
|
25
|
+
with pytest.raises(TypeError):
|
|
26
|
+
@with_events()
|
|
27
|
+
class UserModel(Base):
|
|
28
|
+
__tablename__ = 'test_model'
|
|
29
|
+
|
|
30
|
+
id = Column(Integer, primary_key=True)
|
|
31
|
+
name = Column(String)
|
|
32
|
+
|
|
33
|
+
with pytest.raises(RuntimeError):
|
|
34
|
+
@with_events([])
|
|
35
|
+
class UserModel(Base):
|
|
36
|
+
__tablename__ = 'test_model'
|
|
37
|
+
|
|
38
|
+
id = Column(Integer, primary_key=True)
|
|
39
|
+
name = Column(String)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_events_in_model():
|
|
43
|
+
assert UserModel.events.INSERT
|
|
44
|
+
assert UserModel.events.UPDATE
|
|
45
|
+
assert UserModel.events.DELETE
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import importlib
|
|
2
|
-
import pkgutil
|
|
3
|
-
from types import ModuleType
|
|
4
|
-
from typing import Iterable, List
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def autodiscover(paths: Iterable[str]) -> List[ModuleType]:
|
|
8
|
-
modules: List[ModuleType] = []
|
|
9
|
-
|
|
10
|
-
for path in paths:
|
|
11
|
-
module = importlib.import_module(path)
|
|
12
|
-
modules.append(module)
|
|
13
|
-
|
|
14
|
-
if not hasattr(module, '__path__'):
|
|
15
|
-
continue
|
|
16
|
-
|
|
17
|
-
for _, module_name, _ in pkgutil.walk_packages(
|
|
18
|
-
module.__path__,
|
|
19
|
-
module.__name__ + '.',
|
|
20
|
-
):
|
|
21
|
-
submodule = importlib.import_module(module_name)
|
|
22
|
-
modules.append(submodule)
|
|
23
|
-
|
|
24
|
-
return modules
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/registry.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/tests/test_is_created_triggers.py
RENAMED
|
File without changes
|
|
File without changes
|