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 +1 -1
- amsdal_utils/events/__init__.py +21 -0
- amsdal_utils/events/bus.py +417 -0
- amsdal_utils/events/config.py +27 -0
- amsdal_utils/events/context.py +150 -0
- amsdal_utils/events/decorators.py +71 -0
- amsdal_utils/events/event.py +43 -0
- amsdal_utils/events/listener.py +87 -0
- amsdal_utils/models/data_models/address.py +1 -1
- {amsdal_utils-0.5.7.dist-info → amsdal_utils-0.6.0.dist-info}/METADATA +3 -2
- {amsdal_utils-0.5.7.dist-info → amsdal_utils-0.6.0.dist-info}/RECORD +13 -6
- {amsdal_utils-0.5.7.dist-info → amsdal_utils-0.6.0.dist-info}/WHEEL +0 -0
- {amsdal_utils-0.5.7.dist-info → amsdal_utils-0.6.0.dist-info}/licenses/LICENSE.txt +0 -0
amsdal_utils/__about__.py
CHANGED
|
@@ -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,
|
|
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.
|
|
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:
|
|
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=
|
|
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=
|
|
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.
|
|
61
|
-
amsdal_utils-0.
|
|
62
|
-
amsdal_utils-0.
|
|
63
|
-
amsdal_utils-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|