amsdal_utils 0.5.7__py3-none-any.whl → 0.6.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.
amsdal_utils/__about__.py CHANGED
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2023-present
2
2
  #
3
3
  # SPDX-License-Identifier: AMSDAL End User License Agreement
4
- __version__ = '0.5.7'
4
+ __version__ = '0.6.0'
@@ -0,0 +1,21 @@
1
+ from amsdal_utils.events.bus import EventBus
2
+ from amsdal_utils.events.config import ErrorStrategy
3
+ from amsdal_utils.events.context import ContextHistoryEntry
4
+ from amsdal_utils.events.context import EventContext
5
+ from amsdal_utils.events.decorators import listen_to
6
+ from amsdal_utils.events.event import Event
7
+ from amsdal_utils.events.listener import AsyncNextFn
8
+ from amsdal_utils.events.listener import EventListener
9
+ from amsdal_utils.events.listener import NextFn
10
+
11
+ __all__ = [
12
+ 'AsyncNextFn',
13
+ 'ContextHistoryEntry',
14
+ 'ErrorStrategy',
15
+ 'Event',
16
+ 'EventBus',
17
+ 'EventContext',
18
+ 'EventListener',
19
+ 'NextFn',
20
+ 'listen_to',
21
+ ]
@@ -0,0 +1,417 @@
1
+ import importlib
2
+ import logging
3
+ from collections.abc import Awaitable
4
+ from collections.abc import Callable
5
+ from typing import Any
6
+ from typing import ClassVar
7
+ from typing import TypeVar
8
+
9
+ from amsdal_utils.events.config import ErrorStrategy
10
+ from amsdal_utils.events.config import _ListenerRegistration
11
+ from amsdal_utils.events.context import EventContext
12
+ from amsdal_utils.events.event import Event
13
+ from amsdal_utils.events.listener import EventListener
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ TContext = TypeVar('TContext', bound=EventContext)
18
+
19
+
20
+ def _resolve_listener_id(listener: type[EventListener[Any]] | str) -> str:
21
+ """
22
+ Resolve listener to its listener_id string.
23
+
24
+ Args:
25
+ listener: Either a listener class or a fully-qualified module path string
26
+
27
+ Returns:
28
+ The listener_id string
29
+
30
+ Raises:
31
+ ValueError: If string path cannot be imported or is malformed
32
+ TypeError: If the resolved object is not a subclass of EventListener
33
+
34
+ Example:
35
+ _resolve_listener_id(MyListener) # Returns "module.MyListener" (from listener.listener_id)
36
+ _resolve_listener_id("module.path.MyListener") # Validates import and returns listener_id
37
+ """
38
+ if isinstance(listener, str):
39
+ # String path - strict validation by import
40
+ parts = listener.rsplit('.', 1)
41
+ expected_parts_count = 2
42
+ if len(parts) != expected_parts_count:
43
+ msg = f'Invalid listener path "{listener}". Expected format: "module.ClassName"'
44
+ raise ValueError(msg)
45
+
46
+ module_path, class_name = parts
47
+
48
+ try:
49
+ module = importlib.import_module(module_path)
50
+ except ImportError as e:
51
+ msg = f'Cannot import module "{module_path}" from listener path "{listener}". Check for typos: {e}'
52
+ raise ValueError(msg) from e
53
+
54
+ try:
55
+ listener_cls = getattr(module, class_name)
56
+ except AttributeError as e:
57
+ msg = f'Class "{class_name}" not found in module "{module_path}". Check for typos.'
58
+ raise ValueError(msg) from e
59
+
60
+ # Verify it's an EventListener subclass
61
+ if not isinstance(listener_cls, type) or not issubclass(listener_cls, EventListener):
62
+ msg = f'"{listener}" is not a subclass of EventListener'
63
+ raise TypeError(msg)
64
+
65
+ # Successfully validated - return its listener_id
66
+ return listener_cls.listener_id
67
+ else:
68
+ # Class type - return its listener_id
69
+ if not isinstance(listener, type) or not issubclass(listener, EventListener):
70
+ msg = f'Expected EventListener subclass or string path, got {type(listener)}'
71
+ raise TypeError(msg)
72
+ return listener.listener_id
73
+
74
+
75
+ class EventBus:
76
+ """
77
+ Central event bus for middleware-style event handling.
78
+
79
+ Features:
80
+ - Type-safe event/context binding
81
+ - Middleware chain pattern
82
+ - Dependency-based ordering
83
+ - Context history tracking
84
+ - Caching of sorted listeners for performance
85
+
86
+ Example:
87
+ # Register listener
88
+ EventBus.subscribe(MyEvent, MyListener, priority=100)
89
+
90
+ # Emit event
91
+ context = MyContext(value=10)
92
+ result = EventBus.emit(MyEvent, context)
93
+
94
+ # Or async
95
+ result = await EventBus.aemit(MyEvent, context)
96
+ """
97
+
98
+ _listeners: ClassVar[dict[type[Event[Any]], list[_ListenerRegistration]]] = {}
99
+ _sorted_cache: ClassVar[dict[type[Event[Any]], list[_ListenerRegistration]]] = {}
100
+
101
+ @classmethod
102
+ def subscribe(
103
+ cls,
104
+ event: type[Event[TContext]],
105
+ listener: type[EventListener[TContext]],
106
+ after: list[type[EventListener[Any]] | str] | None = None,
107
+ before: list[type[EventListener[Any]] | str] | None = None,
108
+ priority: int = 500,
109
+ error_strategy: ErrorStrategy | None = None,
110
+ ) -> None:
111
+ """
112
+ Subscribe listener to event.
113
+
114
+ Args:
115
+ event: Event class to listen to
116
+ listener: Listener class (will be instantiated per execution)
117
+ after: List of listeners or listener_id strings this should run after.
118
+ Strings must be fully-qualified paths (e.g., "module.ClassName")
119
+ before: List of listeners or listener_id strings this should run before.
120
+ Strings must be fully-qualified paths (e.g., "module.ClassName")
121
+ priority: Numeric priority (0-999, lower = earlier)
122
+ error_strategy: How to handle errors (None = use event's default)
123
+
124
+ Raises:
125
+ ValueError: If string path cannot be imported (typo in listener reference)
126
+ TypeError: If listener reference is not an EventListener subclass
127
+
128
+ Example:
129
+ # Using class references (type-safe)
130
+ EventBus.subscribe(MyEvent, ListenerA, after=[ListenerB])
131
+
132
+ # Using string paths (for cross-module references)
133
+ EventBus.subscribe(MyEvent, ListenerA, after=["other.module.ListenerB"])
134
+
135
+ # Mixed approach
136
+ EventBus.subscribe(MyEvent, ListenerC, after=[ListenerA, "other.module.ListenerB"])
137
+ """
138
+ # Use event's default error strategy if not specified
139
+ if error_strategy is None:
140
+ error_strategy = event.default_error_strategy
141
+
142
+ # Resolve listener references to IDs
143
+ after_ids = [_resolve_listener_id(dep) for dep in (after or [])]
144
+ before_ids = [_resolve_listener_id(dep) for dep in (before or [])]
145
+
146
+ # Check for duplicate registration
147
+ existing_listeners = cls._listeners.get(event, [])
148
+ if any(reg.listener_id == listener.listener_id for reg in existing_listeners):
149
+ return # Already registered, skip
150
+
151
+ registration = _ListenerRegistration(
152
+ listener=listener,
153
+ after=after_ids,
154
+ before=before_ids,
155
+ priority=priority,
156
+ error_strategy=error_strategy,
157
+ )
158
+
159
+ cls._listeners.setdefault(event, []).append(registration)
160
+ # Invalidate cache
161
+ cls._sorted_cache.pop(event, None)
162
+
163
+ @classmethod
164
+ def unsubscribe(cls, event: type[Event[Any]], listener_id: str) -> bool:
165
+ """
166
+ Unsubscribe listener from event.
167
+
168
+ Args:
169
+ event: Event type
170
+ listener_id: Listener ID to remove
171
+
172
+ Returns:
173
+ True if listener was found and removed
174
+ """
175
+ if event not in cls._listeners:
176
+ return False
177
+
178
+ initial_count = len(cls._listeners[event])
179
+ cls._listeners[event] = [reg for reg in cls._listeners[event] if reg.listener_id != listener_id]
180
+
181
+ if len(cls._listeners[event]) < initial_count:
182
+ cls._sorted_cache.pop(event, None)
183
+ return True
184
+ return False
185
+
186
+ @classmethod
187
+ def emit(
188
+ cls,
189
+ event: type[Event[TContext]],
190
+ context: TContext,
191
+ ) -> TContext:
192
+ """
193
+ Emit event synchronously and execute middleware chain.
194
+
195
+ Args:
196
+ event: Event type to emit
197
+ context: Initial context
198
+
199
+ Returns:
200
+ Final context after all listeners
201
+
202
+ Raises:
203
+ Any exception raised by listeners (depends on error_strategy)
204
+ """
205
+ listeners = cls._get_sorted_listeners(event)
206
+
207
+ if not listeners:
208
+ return context
209
+
210
+ chain = cls._build_sync_chain(listeners)
211
+ return chain(context)
212
+
213
+ @classmethod
214
+ async def aemit(
215
+ cls,
216
+ event: type[Event[TContext]],
217
+ context: TContext,
218
+ ) -> TContext:
219
+ """
220
+ Emit event asynchronously and execute middleware chain.
221
+
222
+ Args:
223
+ event: Event type to emit
224
+ context: Initial context
225
+
226
+ Returns:
227
+ Final context after all listeners
228
+
229
+ Raises:
230
+ Any exception raised by listeners (depends on error_strategy)
231
+ """
232
+ listeners = cls._get_sorted_listeners(event)
233
+
234
+ if not listeners:
235
+ return context
236
+
237
+ chain = cls._build_async_chain(listeners)
238
+ return await chain(context)
239
+
240
+ @classmethod
241
+ def _get_sorted_listeners(cls, event: type[Event[Any]]) -> list[_ListenerRegistration]:
242
+ """
243
+ Get listeners sorted by dependencies and priority.
244
+ Uses cache to avoid re-sorting on every emit.
245
+ """
246
+ if event in cls._sorted_cache:
247
+ return cls._sorted_cache[event]
248
+
249
+ listeners = cls._listeners.get(event, [])
250
+ sorted_listeners = cls._sort_listeners(listeners)
251
+
252
+ cls._sorted_cache[event] = sorted_listeners
253
+ return sorted_listeners
254
+
255
+ @classmethod
256
+ def _sort_listeners(cls, registrations: list[_ListenerRegistration]) -> list[_ListenerRegistration]:
257
+ """
258
+ Sort listeners by priority and dependencies (after/before).
259
+
260
+ Uses topological sort with cycle detection.
261
+ Priority is used as tiebreaker when no dependencies exist.
262
+
263
+ Raises:
264
+ ValueError: If circular dependencies detected
265
+ """
266
+ if not registrations:
267
+ return []
268
+
269
+ # Build adjacency graph and in-degree map
270
+ graph: dict[str, list[str]] = {} # listener_id -> [dependencies]
271
+ in_degree: dict[str, int] = {}
272
+ listener_map: dict[str, _ListenerRegistration] = {}
273
+
274
+ # Initialize
275
+ for reg in registrations:
276
+ listener_id = reg.listener_id
277
+ listener_map[listener_id] = reg
278
+ graph[listener_id] = []
279
+ in_degree[listener_id] = 0
280
+
281
+ # Build dependency graph
282
+ for reg in registrations:
283
+ listener_id = reg.listener_id
284
+
285
+ # "after" means this listener depends on others (they must run first)
286
+ for dependency in reg.after:
287
+ if dependency in listener_map:
288
+ graph[dependency].append(listener_id)
289
+ in_degree[listener_id] += 1
290
+
291
+ # "before" means others depend on this listener (this must run first)
292
+ for dependent in reg.before:
293
+ if dependent in listener_map:
294
+ graph[listener_id].append(dependent)
295
+ in_degree[dependent] += 1
296
+
297
+ # Kahn's algorithm for topological sort
298
+ queue: list[str] = []
299
+ for listener_id, degree in in_degree.items():
300
+ if degree == 0:
301
+ queue.append(listener_id)
302
+
303
+ # Sort queue by priority for deterministic ordering
304
+ queue.sort(key=lambda lid: listener_map[lid].priority)
305
+
306
+ result: list[_ListenerRegistration] = []
307
+
308
+ while queue:
309
+ # Pop listener with lowest priority
310
+ queue.sort(key=lambda lid: listener_map[lid].priority)
311
+ current = queue.pop(0)
312
+ result.append(listener_map[current])
313
+
314
+ # Reduce in-degree for neighbors
315
+ for neighbor in graph[current]:
316
+ in_degree[neighbor] -= 1
317
+ if in_degree[neighbor] == 0:
318
+ queue.append(neighbor)
319
+
320
+ # Check for cycles
321
+ if len(result) != len(registrations):
322
+ # Find which listeners are in the cycle
323
+ remaining = [lid for lid, deg in in_degree.items() if deg > 0]
324
+ msg = f'Circular dependency detected among listeners: {remaining}'
325
+ raise ValueError(msg)
326
+
327
+ return result
328
+
329
+ @classmethod
330
+ def _build_sync_chain(
331
+ cls,
332
+ registrations: list[_ListenerRegistration],
333
+ ) -> Callable[[TContext], TContext]:
334
+ """Build synchronous middleware chain"""
335
+
336
+ def create_next(index: int) -> Callable[[TContext], TContext]:
337
+ if index >= len(registrations):
338
+ # End of chain - return context as-is
339
+ return lambda ctx: ctx
340
+
341
+ registration = registrations[index]
342
+ next_fn = create_next(index + 1)
343
+
344
+ def middleware(ctx: TContext) -> TContext:
345
+ listener_instance = registration.listener()
346
+
347
+ try:
348
+ return listener_instance.handle(ctx, next_fn)
349
+
350
+ except Exception as e:
351
+ if registration.error_strategy == ErrorStrategy.PROPAGATE:
352
+ raise
353
+ if registration.error_strategy == ErrorStrategy.LOG_AND_CONTINUE:
354
+ logger.exception(f'Error in listener {registration.listener_id}: {e}')
355
+ return next_fn(ctx)
356
+ # SILENT
357
+ return next_fn(ctx)
358
+
359
+ return middleware
360
+
361
+ return create_next(0)
362
+
363
+ @classmethod
364
+ def _build_async_chain(
365
+ cls,
366
+ registrations: list[_ListenerRegistration],
367
+ ) -> Callable[[TContext], Awaitable[TContext]]:
368
+ """Build asynchronous middleware chain"""
369
+
370
+ def create_next(index: int) -> Callable[[TContext], Awaitable[TContext]]:
371
+ if index >= len(registrations):
372
+ # End of chain - return context as-is
373
+ async def final(ctx: TContext) -> TContext:
374
+ return ctx
375
+
376
+ return final
377
+
378
+ registration = registrations[index]
379
+ next_fn = create_next(index + 1)
380
+
381
+ async def middleware(ctx: TContext) -> TContext:
382
+ listener_instance = registration.listener()
383
+
384
+ try:
385
+ return await listener_instance.ahandle(ctx, next_fn)
386
+ except Exception as e:
387
+ if registration.error_strategy == ErrorStrategy.PROPAGATE:
388
+ raise
389
+ if registration.error_strategy == ErrorStrategy.LOG_AND_CONTINUE:
390
+ logger.exception(f'Error in listener {registration.listener_id}: {e}')
391
+ return await next_fn(ctx)
392
+ # SILENT
393
+ return await next_fn(ctx)
394
+
395
+ return middleware
396
+
397
+ return create_next(0)
398
+
399
+ @classmethod
400
+ def reset(cls) -> None:
401
+ """Clear all listeners and cache (useful for testing)"""
402
+ cls._listeners.clear()
403
+ cls._sorted_cache.clear()
404
+
405
+ @classmethod
406
+ def get_listeners(cls, event: type[Event[Any]]) -> list[str]:
407
+ """
408
+ Get list of listener IDs for event (for introspection).
409
+
410
+ Args:
411
+ event: Event type
412
+
413
+ Returns:
414
+ List of listener IDs in execution order
415
+ """
416
+ configs = cls._get_sorted_listeners(event)
417
+ return [cfg.listener_id for cfg in configs]
@@ -0,0 +1,27 @@
1
+ from dataclasses import dataclass
2
+ from dataclasses import field
3
+ from enum import Enum
4
+ from typing import Any
5
+
6
+
7
+ class ErrorStrategy(Enum):
8
+ """Strategy for handling errors in listeners"""
9
+
10
+ PROPAGATE = 'propagate' # raise exception, stop chain
11
+ LOG_AND_CONTINUE = 'log_and_continue' # log error but continue chain
12
+ SILENT = 'silent' # ignore errors completely
13
+
14
+
15
+ @dataclass
16
+ class _ListenerRegistration:
17
+ """Internal: stores listener with metadata"""
18
+
19
+ listener: type[Any] # type[EventListener] but can't import here
20
+ after: list[str] = field(default_factory=list)
21
+ before: list[str] = field(default_factory=list)
22
+ priority: int = 500
23
+ error_strategy: ErrorStrategy = ErrorStrategy.PROPAGATE
24
+
25
+ @property
26
+ def listener_id(self) -> str:
27
+ return self.listener.listener_id # type: ignore[attr-defined,unused-ignore]
@@ -0,0 +1,150 @@
1
+ from abc import ABC
2
+ from time import time
3
+ from typing import Any
4
+ from typing import TypeVar
5
+
6
+ from pydantic import BaseModel
7
+ from pydantic import ConfigDict
8
+ from pydantic import Field
9
+
10
+
11
+ def _safe_deep_copy(value: Any) -> Any:
12
+ """
13
+ Safely deep copy a value.
14
+
15
+ - Primitives (None, bool, int, float, str, bytes): returned as-is (immutable)
16
+ - Pydantic BaseModel: uses model_copy(deep=True)
17
+ - dict: recursively copies each key-value pair
18
+ - list: recursively copies each item
19
+ - tuple: recursively copies each item and returns tuple
20
+ - set/frozenset: recursively copies each item
21
+ - Other objects (Request, Response, etc.): returned as-is (reference)
22
+ """
23
+ if value is None or isinstance(value, bool | int | float | str | bytes):
24
+ return value
25
+
26
+ if isinstance(value, BaseModel):
27
+ # For Pydantic models, use safe copy on each field
28
+ data = {}
29
+ for field_name in value.__class__.model_fields:
30
+ field_value = getattr(value, field_name)
31
+ data[field_name] = _safe_deep_copy(field_value)
32
+ return value.__class__.model_construct(**data)
33
+
34
+ if isinstance(value, dict):
35
+ return {_safe_deep_copy(k): _safe_deep_copy(v) for k, v in value.items()}
36
+
37
+ if isinstance(value, list):
38
+ return [_safe_deep_copy(item) for item in value]
39
+
40
+ if isinstance(value, tuple):
41
+ return tuple(_safe_deep_copy(item) for item in value)
42
+
43
+ if isinstance(value, set):
44
+ return {_safe_deep_copy(item) for item in value}
45
+
46
+ if isinstance(value, frozenset):
47
+ return frozenset(_safe_deep_copy(item) for item in value)
48
+
49
+ # For anything else (Request, Response, file handles, etc.), keep reference
50
+ return value
51
+
52
+
53
+ class ContextHistoryEntry(BaseModel):
54
+ """Single entry in context history"""
55
+
56
+ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
57
+
58
+ context_snapshot: Any # snapshot of context at this point
59
+ listener_id: str # which listener created this version
60
+ timestamp: float # when created
61
+
62
+
63
+ TContext = TypeVar('TContext', bound='EventContext')
64
+
65
+
66
+ class EventContext(BaseModel, ABC):
67
+ """
68
+ Base class for all event contexts.
69
+
70
+ Each listener creates new immutable version, history is preserved.
71
+ Uses Pydantic with frozen=True to enforce immutability.
72
+
73
+ Example:
74
+ class MyContext(EventContext):
75
+ value: int
76
+ processed: bool = False
77
+
78
+ # In listener - ✅ correct way
79
+ new_context = context.create_next(
80
+ listener_id=self.listener_id,
81
+ value=context.value * 2,
82
+ )
83
+
84
+ # ❌ This will raise ValidationError
85
+ context.value = 42 # pydantic prevents mutation
86
+ """
87
+
88
+ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
89
+
90
+ history_: list[ContextHistoryEntry] = Field(default_factory=list, repr=False)
91
+ current_listener_: str | None = Field(default=None, repr=False)
92
+
93
+ def create_next(self: TContext, listener_id: str, **changes: Any) -> TContext:
94
+ """
95
+ Create new context version with changes.
96
+ Preserves history.
97
+
98
+ Args:
99
+ listener_id: ID of listener creating new version
100
+ **changes: Fields to update in new context
101
+
102
+ Returns:
103
+ New context instance with updated fields
104
+
105
+ Example:
106
+ new_ctx = context.create_next(
107
+ listener_id="my.Listener",
108
+ value=42,
109
+ processed=True,
110
+ )
111
+ """
112
+ # Add snapshot to history
113
+ # Using _safe_deep_copy to avoid issues with non-copyable objects like Request
114
+ new_history = [
115
+ *self.history_,
116
+ ContextHistoryEntry(
117
+ context_snapshot=_safe_deep_copy(self),
118
+ listener_id=listener_id,
119
+ timestamp=time(),
120
+ ),
121
+ ]
122
+
123
+ # Use model_copy to avoid serialization issues with Reference fields
124
+ return self.model_copy(
125
+ update={
126
+ **changes,
127
+ 'history_': new_history,
128
+ 'current_listener_': listener_id,
129
+ }
130
+ )
131
+
132
+ @property
133
+ def history(self) -> list[ContextHistoryEntry]:
134
+ """Get full history of context mutations"""
135
+ return self.history_
136
+
137
+ def get_by_listener(self, listener_id: str) -> 'EventContext | None':
138
+ """
139
+ Get context version created by specific listener.
140
+
141
+ Args:
142
+ listener_id: Listener ID to search for
143
+
144
+ Returns:
145
+ Context snapshot or None if not found
146
+ """
147
+ for entry in self.history_:
148
+ if entry.listener_id == listener_id:
149
+ return entry.context_snapshot
150
+ return None
@@ -0,0 +1,71 @@
1
+ from collections.abc import Callable
2
+ from typing import Any
3
+ from typing import TypeVar
4
+
5
+ from amsdal_utils.events.bus import EventBus
6
+ from amsdal_utils.events.config import ErrorStrategy
7
+ from amsdal_utils.events.context import EventContext
8
+ from amsdal_utils.events.event import Event
9
+ from amsdal_utils.events.listener import EventListener
10
+
11
+ TContext = TypeVar('TContext', bound=EventContext)
12
+
13
+
14
+ def listen_to(
15
+ event: type[Event[TContext]],
16
+ after: list[type[EventListener[Any]] | str] | None = None,
17
+ before: list[type[EventListener[Any]] | str] | None = None,
18
+ priority: int = 500,
19
+ error_strategy: ErrorStrategy | None = None,
20
+ ) -> Callable[[type[EventListener[TContext]]], type[EventListener[TContext]]]:
21
+ """
22
+ Decorator to auto-register listener to event.
23
+
24
+ Args:
25
+ event: Event class to listen to
26
+ after: List of listeners or listener_id strings this should run after.
27
+ Strings must be fully-qualified paths (e.g., "module.ClassName")
28
+ before: List of listeners or listener_id strings this should run before.
29
+ Strings must be fully-qualified paths (e.g., "module.ClassName")
30
+ priority: Numeric priority (0-999, lower = earlier)
31
+ error_strategy: How to handle errors (None = use event's default)
32
+
33
+ Returns:
34
+ Decorated listener class (unchanged)
35
+
36
+ Raises:
37
+ ValueError: If string path cannot be imported (typo in listener reference)
38
+ TypeError: If listener reference is not an EventListener subclass
39
+
40
+ Example:
41
+ # Using class references (type-safe)
42
+ @listen_to(MyEvent, after=[OtherListener], priority=100)
43
+ class MyListener(EventListener[MyContext]):
44
+ def handle(self, context, next_fn):
45
+ return next_fn(context)
46
+
47
+ # Using string paths (for cross-module references)
48
+ @listen_to(MyEvent, after=["other.module.OtherListener"], priority=100)
49
+ class MyListener(EventListener[MyContext]):
50
+ def handle(self, context, next_fn):
51
+ return next_fn(context)
52
+
53
+ # Mixed approach
54
+ @listen_to(MyEvent, after=[LocalListener, "other.module.RemoteListener"])
55
+ class MyListener(EventListener[MyContext]):
56
+ def handle(self, context, next_fn):
57
+ return next_fn(context)
58
+ """
59
+
60
+ def decorator(listener_cls: type[EventListener[TContext]]) -> type[EventListener[TContext]]:
61
+ EventBus.subscribe(
62
+ event=event,
63
+ listener=listener_cls,
64
+ after=after,
65
+ before=before,
66
+ priority=priority,
67
+ error_strategy=error_strategy,
68
+ )
69
+ return listener_cls
70
+
71
+ return decorator
@@ -0,0 +1,43 @@
1
+ from typing import Generic
2
+ from typing import TypeVar
3
+ from typing import get_args
4
+
5
+ from amsdal_utils.events.config import ErrorStrategy
6
+ from amsdal_utils.events.context import EventContext
7
+
8
+ TContext = TypeVar('TContext', bound=EventContext)
9
+
10
+
11
+ class Event(Generic[TContext]):
12
+ """
13
+ Base event class with typed context.
14
+
15
+ Each event type automatically extracts its context type from the generic parameter.
16
+ Optionally define default error strategy for all listeners of this event.
17
+
18
+ Usage:
19
+ class MyEvent(Event[MyContext]):
20
+ pass
21
+
22
+ # With custom error strategy
23
+ class NonCriticalEvent(Event[MyContext]):
24
+ default_error_strategy = ErrorStrategy.LOG_AND_CONTINUE
25
+
26
+ Attributes:
27
+ context_type: Type of context this event uses (auto-detected)
28
+ default_error_strategy: Default error handling strategy for listeners
29
+ """
30
+
31
+ context_type: type[TContext]
32
+ default_error_strategy: ErrorStrategy = ErrorStrategy.PROPAGATE
33
+
34
+ def __init_subclass__(cls, **kwargs) -> None: # type: ignore[no-untyped-def]
35
+ super().__init_subclass__(**kwargs)
36
+ # Auto-detect context type from generic parameter
37
+ if hasattr(cls, '__orig_bases__'):
38
+ for base in cls.__orig_bases__:
39
+ if hasattr(base, '__args__'):
40
+ args = get_args(base)
41
+ if args:
42
+ cls.context_type = args[0]
43
+ break
@@ -0,0 +1,87 @@
1
+ from abc import ABC
2
+ from abc import abstractmethod
3
+ from collections.abc import Awaitable
4
+ from collections.abc import Callable
5
+ from typing import Generic
6
+ from typing import TypeVar
7
+
8
+ from amsdal_utils.events.context import EventContext
9
+
10
+ TContext = TypeVar('TContext', bound=EventContext)
11
+
12
+ # Type aliases for next function
13
+ NextFn = Callable[[TContext], TContext]
14
+ AsyncNextFn = Callable[[TContext], Awaitable[TContext]]
15
+
16
+
17
+ class EventListener(ABC, Generic[TContext]):
18
+ """
19
+ Base event listener acting as middleware.
20
+
21
+ Each listener receives context and next() function.
22
+ Must call next(context) to continue chain or raise exception to stop.
23
+
24
+ The listener_id is auto-generated from module and class name.
25
+
26
+ Example:
27
+ class MyListener(EventListener[MyContext]):
28
+ def handle(self, context: MyContext, next: NextFn) -> MyContext:
29
+ # Your logic
30
+ if not self.is_valid(context):
31
+ raise ValueError("Invalid context")
32
+
33
+ # Modify context
34
+ new_ctx = context.create_next(
35
+ listener_id=self.listener_id,
36
+ some_field="new_value",
37
+ )
38
+
39
+ # Continue chain
40
+ return next(new_ctx)
41
+ """
42
+
43
+ listener_id: str
44
+
45
+ def __init_subclass__(cls, **kwargs) -> None: # type: ignore[no-untyped-def]
46
+ super().__init_subclass__(**kwargs)
47
+ # Auto-generate listener_id from module + class name
48
+ if not hasattr(cls, 'listener_id') or cls.listener_id == EventListener.listener_id:
49
+ cls.listener_id = f'{cls.__module__}.{cls.__name__}'
50
+
51
+ @abstractmethod
52
+ def handle(self, context: TContext, next_fn: NextFn[TContext]) -> TContext:
53
+ """
54
+ Sync handler (default).
55
+
56
+ Args:
57
+ context: Event context (immutable - create new with context.create_next())
58
+ next_fn: Function to call next listener
59
+
60
+ Returns:
61
+ Context (original or modified)
62
+
63
+ Raises:
64
+ Any exception to stop chain execution
65
+ NotImplementedError: If only async is supported
66
+ """
67
+ msg = f'{self.__class__.__name__} does not implement sync handling'
68
+ raise NotImplementedError(msg)
69
+
70
+ @abstractmethod
71
+ async def ahandle(self, context: TContext, next_fn: AsyncNextFn[TContext]) -> TContext:
72
+ """
73
+ Async handler.
74
+
75
+ Args:
76
+ context: Event context (immutable - create new with context.create_next())
77
+ next_fn: Async function to call next listener
78
+
79
+ Returns:
80
+ Context (original or modified)
81
+
82
+ Raises:
83
+ Any exception to stop chain execution
84
+ NotImplementedError: If only sync is supported
85
+ """
86
+ msg = f'{self.__class__.__name__} does not implement async handling'
87
+ raise NotImplementedError(msg)
@@ -26,7 +26,7 @@ class JSONExtendedEncoder(json.JSONEncoder):
26
26
  """Custom JSON encoder to handle additional types."""
27
27
 
28
28
  def default(self, obj: Any) -> Any:
29
- if isinstance(obj, (Decimal, datetime, date, time, bytes)):
29
+ if isinstance(obj, Decimal | datetime | date | time | bytes):
30
30
  return {
31
31
  '__type__': type(obj).__name__,
32
32
  'value': base64.b64encode(obj).decode('utf-8') if isinstance(obj, bytes) else str(obj),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amsdal_utils
3
- Version: 0.5.7
3
+ Version: 0.6.0
4
4
  Summary: Utils for AMSDAL data framework
5
5
  Project-URL: Documentation, https://pypi.org/project/amsdal_utils/#readme
6
6
  Project-URL: Issues, https://pypi.org/project/amsdal_utils/#issues
@@ -118,9 +118,10 @@ Classifier: Development Status :: 4 - Beta
118
118
  Classifier: Programming Language :: Python
119
119
  Classifier: Programming Language :: Python :: 3.11
120
120
  Classifier: Programming Language :: Python :: 3.12
121
+ Classifier: Programming Language :: Python :: 3.13
121
122
  Classifier: Programming Language :: Python :: Implementation :: CPython
122
123
  Classifier: Programming Language :: Python :: Implementation :: PyPy
123
- Requires-Python: >=3.11
124
+ Requires-Python: <3.14,>=3.11
124
125
  Requires-Dist: pydantic~=2.12
125
126
  Requires-Dist: pyyaml~=6.0
126
127
  Description-Content-Type: text/markdown
@@ -1,5 +1,5 @@
1
1
  amsdal_utils/Third-Party Materials - AMSDAL Dependencies - License Notices.md,sha256=blZRsT9Qg4u4LzZuxlWQOzlWGpLAg7DugWS6nz7s_yw,62595
2
- amsdal_utils/__about__.py,sha256=6iW8h6OIk_sNDiVyp7NxCzL_PEkI5J0OHZQ6VEA84jQ,124
2
+ amsdal_utils/__about__.py,sha256=L9I3u2z3UsLTmwP99KJ8u9WMvIGgp6dH1-e7eZsHvlE,124
3
3
  amsdal_utils/__init__.py,sha256=EQCJ5OevmkkIpIULumPNIbWk3UI7afDfRzIsZN5mfwg,73
4
4
  amsdal_utils/errors.py,sha256=P90oGdKczHZzHukWnzJfbsSxMC-Sc8dUXvHzP7b1IIA,129
5
5
  amsdal_utils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -16,6 +16,13 @@ amsdal_utils/config/data_models/connection_config.py,sha256=bUb0dvWVVOXsLV-A37Wv
16
16
  amsdal_utils/config/data_models/integration_config.py,sha256=UhFUhEchDY5PPrGiFfmisLkslVtKfiac3BMiU77FoT4,387
17
17
  amsdal_utils/config/data_models/repository_config.py,sha256=Mrqzki0cYMNJI2mywD4BTNSl438B-Jlmy7LqyNZ6I_o,383
18
18
  amsdal_utils/config/data_models/resources_config.py,sha256=RrCCThE7hj8Tr5Nn_ItsRAAnkMBuest2kNWnRL84exk,844
19
+ amsdal_utils/events/__init__.py,sha256=5NRRBtQAaAeKAehX4uwgP3hT_VT7rG9Clsejcffo9u4,647
20
+ amsdal_utils/events/bus.py,sha256=AfTxWqKhbemYHkWAt9xRPv9hUv43qCC0HFrS667QEis,14406
21
+ amsdal_utils/events/config.py,sha256=INNXKprSmGWz0oGDAm1cxKbR-YT4zKhf67-lMyMiWOE,857
22
+ amsdal_utils/events/context.py,sha256=4_xjK5TubDy6cOD2hXoQQO619ailzhF6tr7q1PU31S0,4710
23
+ amsdal_utils/events/decorators.py,sha256=Py7UnxUkAIZtjzFYEA1nXa412aZfUzPzyajmUPF4ggU,2686
24
+ amsdal_utils/events/event.py,sha256=fNZqNRAeAiqwsoWnJD8CK20P14EZhvR_6OL11O_FmYk,1462
25
+ amsdal_utils/events/listener.py,sha256=bc1_a8kSB1bs0xicQ66e7DEVJPEGi7oTtE0CBlVEFQY,2866
19
26
  amsdal_utils/lifecycle/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
27
  amsdal_utils/lifecycle/consumer.py,sha256=IzA4peJwAQ5knaU11TwkHsLX7KIq9EOdPF2kEzu8DJI,739
21
28
  amsdal_utils/lifecycle/enum.py,sha256=0bmmU1XmyJaxztxVnYIzwzT9Iyzc-s3jUvmk5JcuaYw,500
@@ -24,7 +31,7 @@ amsdal_utils/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSu
24
31
  amsdal_utils/models/base.py,sha256=jcY7ZE6n0lANGjg87sEwlz6val_8ZRHWaCmNZo9Oxnc,1642
25
32
  amsdal_utils/models/enums.py,sha256=1iuixjmfHMIMDM3rEQ1YUjOPF3aV_OEoPq61I61KvGI,745
26
33
  amsdal_utils/models/data_models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
- amsdal_utils/models/data_models/address.py,sha256=zKAGlXjw8oGJrADyhwE8BhYzCEUTSChMSApMYOL7Bjk,6585
34
+ amsdal_utils/models/data_models/address.py,sha256=Sjdny-5XcpSJEv_kQ6lDq7dBIvftIzPRoIkhiFizxLc,6587
28
35
  amsdal_utils/models/data_models/core.py,sha256=GdOkd-WU5Grifl3f4tyr1Kpv56NQjec2lud1iKo2hAI,8686
29
36
  amsdal_utils/models/data_models/enums.py,sha256=qDJDPU5oxPlSNENFNf3PPgxiFJYbTM8OOE8stMmYYHc,4130
30
37
  amsdal_utils/models/data_models/metadata.py,sha256=z0u7kJzzNj8FxFXozVCcFfwyJFdto6_CgwXnW5TicB4,2996
@@ -57,7 +64,7 @@ amsdal_utils/utils/identifier.py,sha256=oP3CHd2i8EBHMVUC8DWLr9pFZBt1IdH7Falaa3M2
57
64
  amsdal_utils/utils/lazy_object.py,sha256=eIVHBDNCSHKQopzYjviPCuh0svbRJQOCZ8KzqFLiS-s,2115
58
65
  amsdal_utils/utils/singleton.py,sha256=O42jKH0jOdfcPGz-OHqfm7kShEZQwUanu3rMHQJSsb0,722
59
66
  amsdal_utils/utils/text.py,sha256=4L5aICJGbmFZwNUhMrACb5thqmcIaWb3aVD24lW24a0,1962
60
- amsdal_utils-0.5.7.dist-info/METADATA,sha256=fUhjsCqBtctVDepD3eLZYUDRojKP62UM1cVNhIz0jvM,57432
61
- amsdal_utils-0.5.7.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
62
- amsdal_utils-0.5.7.dist-info/licenses/LICENSE.txt,sha256=hG-541PFYfNJi9WRZi_hno91UyqNg7YLK8LR3vLblZA,27355
63
- amsdal_utils-0.5.7.dist-info/RECORD,,
67
+ amsdal_utils-0.6.0.dist-info/METADATA,sha256=iowOWyYBT0qEzIeylBs-ONvvVele-7bJT72RkRU0pH8,57489
68
+ amsdal_utils-0.6.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
69
+ amsdal_utils-0.6.0.dist-info/licenses/LICENSE.txt,sha256=hG-541PFYfNJi9WRZi_hno91UyqNg7YLK8LR3vLblZA,27355
70
+ amsdal_utils-0.6.0.dist-info/RECORD,,