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.
Files changed (36) hide show
  1. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/PKG-INFO +23 -15
  2. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/README.md +22 -14
  3. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/pyproject.toml +1 -1
  4. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/__init__.py +1 -1
  5. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/core.py +54 -20
  6. sqlalchemy_events_lib-0.3.1/src/sqlalchemy_events/decorators.py +25 -0
  7. sqlalchemy_events_lib-0.3.1/src/sqlalchemy_events/default_logger.py +25 -0
  8. sqlalchemy_events_lib-0.3.1/src/sqlalchemy_events/discovery.py +44 -0
  9. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/handlers.py +3 -0
  10. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events_lib.egg-info/PKG-INFO +23 -15
  11. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events_lib.egg-info/SOURCES.txt +5 -1
  12. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/tests/conftest.py +2 -4
  13. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/tests/test_autodiscover.py +19 -0
  14. sqlalchemy_events_lib-0.3.1/src/tests/test_handlers.py +17 -0
  15. sqlalchemy_events_lib-0.3.1/src/tests/test_sa_events.py +27 -0
  16. sqlalchemy_events_lib-0.3.1/src/tests/test_with_events_deco.py +45 -0
  17. sqlalchemy_events_lib-0.2.0/src/sqlalchemy_events/decorators.py +0 -14
  18. sqlalchemy_events_lib-0.2.0/src/sqlalchemy_events/discovery.py +0 -24
  19. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/LICENSE +0 -0
  20. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/setup.cfg +0 -0
  21. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/callbacks_strategies/__init__.py +0 -0
  22. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/callbacks_strategies/base.py +0 -0
  23. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/callbacks_strategies/postgres_callback.py +0 -0
  24. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/events.py +0 -0
  25. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/exceptions.py +0 -0
  26. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/init_triggers_strategies/__init__.py +0 -0
  27. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/init_triggers_strategies/base.py +0 -0
  28. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/init_triggers_strategies/postgres_init_triggers.py +0 -0
  29. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/registry.py +0 -0
  30. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/types.py +0 -0
  31. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events/utils.py +0 -0
  32. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events_lib.egg-info/dependency_links.txt +0 -0
  33. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events_lib.egg-info/requires.txt +0 -0
  34. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/sqlalchemy_events_lib.egg-info/top_level.txt +0 -0
  35. {sqlalchemy_events_lib-0.2.0 → sqlalchemy_events_lib-0.3.1}/src/tests/test_is_created_triggers.py +0 -0
  36. {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.2.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
- SQLAlchemyEvents(
174
- base=Base,
175
- engine=engine,
176
- autodiscover_paths=['services'],
177
- logger=logger
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
- SQLAlchemyEvents(
152
- base=Base,
153
- engine=engine,
154
- autodiscover_paths=['services'],
155
- logger=logger
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.2.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 = [
@@ -11,4 +11,4 @@ __all__ = [
11
11
  'with_events',
12
12
  'SaEvent'
13
13
  ]
14
- __version__ = '0.2.0'
14
+ __version__ = '0.3.1'
@@ -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, Type, Union
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
- asyncio.create_task(self.__init())
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
- async def __init(self) -> None:
33
- if not await self.__find_handlers():
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.logger:
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
- if self.logger:
68
- self.logger.info(f'[SQLAlchemyEvents] Registered handler {handler.func.__name__} '
69
- f'from {file_func.parent.name}/{file_name}')
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=self.base.__subclasses__(),
117
+ model_list=base.__subclasses__(),
84
118
  conn=conn,
85
119
  logger=self.logger
86
120
  )
87
- if self.logger:
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=self.base.__subclasses__(),
140
+ model_list=base.__subclasses__(),
107
141
  conn=conn,
108
142
  logger=self.logger
109
143
  )
110
- if self.logger:
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.logger:
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
@@ -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.2.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
- SQLAlchemyEvents(
174
- base=Base,
175
- engine=engine,
176
- autodiscover_paths=['services'],
177
- logger=logger
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,14 +0,0 @@
1
- from .types import SaEvent
2
-
3
-
4
- def with_events(events: list[SaEvent]):
5
- def wrapper(cls):
6
- cls.__events__ = set(events)
7
-
8
- class Events:
9
- for e in events:
10
- locals()[e.name] = e.value
11
-
12
- cls.events = Events
13
- return cls
14
- return wrapper
@@ -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