eventspype 0.1.0__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.
- eventspype-0.1.0/LICENSE +21 -0
- eventspype-0.1.0/PKG-INFO +44 -0
- eventspype-0.1.0/README.md +30 -0
- eventspype-0.1.0/eventspype/__init__.py +0 -0
- eventspype-0.1.0/eventspype/publishers/__init__.py +0 -0
- eventspype-0.1.0/eventspype/publishers/multi.py +106 -0
- eventspype-0.1.0/eventspype/publishers/publications.py +19 -0
- eventspype-0.1.0/eventspype/publishers/publisher.py +101 -0
- eventspype-0.1.0/eventspype/subscribers/__init__.py +0 -0
- eventspype-0.1.0/eventspype/subscribers/functional.py +16 -0
- eventspype-0.1.0/eventspype/subscribers/multi.py +84 -0
- eventspype-0.1.0/eventspype/subscribers/reporter.py +80 -0
- eventspype-0.1.0/eventspype/subscribers/subscriber.py +28 -0
- eventspype-0.1.0/eventspype/subscribers/subscriptions.py +110 -0
- eventspype-0.1.0/eventspype/subscribers/tracker.py +111 -0
- eventspype-0.1.0/pyproject.toml +69 -0
eventspype-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Gianluca Pagliara
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: eventspype
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python framework for building event-driven applications with a clean publisher-subscriber pattern implementation.
|
|
5
|
+
Author: Gianluca Pagliara
|
|
6
|
+
Author-email: pagliara.gianluca@gmail.com
|
|
7
|
+
Requires-Python: >=3.13,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
+
Requires-Dist: async-timeout (>=5.0.1,<6.0.0)
|
|
11
|
+
Requires-Dist: pytest-asyncio (>=0.21.1,<0.22.0)
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# Events Pypeline
|
|
15
|
+
|
|
16
|
+
[](https://github.com/gianlucapagliara/eventspype/actions/workflows/ci.yml)
|
|
17
|
+
[](https://codecov.io/gh/gianlucapagliara/eventspype)
|
|
18
|
+
[](https://www.python.org/downloads/)
|
|
19
|
+
|
|
20
|
+
A lightweight and type-safe Python framework for building event-driven applications. eventspype provides a clean publisher-subscriber pattern implementation, making it easy to create decoupled and maintainable event-driven systems.
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- 🎯 Type-safe publisher-subscriber pattern implementation
|
|
25
|
+
- 🔄 Support for multiple publishers and subscribers
|
|
26
|
+
- 🚀 Asynchronous event handling capabilities
|
|
27
|
+
- 🛠️ Easy to use and integrate
|
|
28
|
+
- 📦 Zero dependencies
|
|
29
|
+
- 🔒 Thread-safe event distribution
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Using pip
|
|
35
|
+
pip install eventspype
|
|
36
|
+
|
|
37
|
+
# Using poetry
|
|
38
|
+
poetry add eventspype
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
44
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Events Pypeline
|
|
2
|
+
|
|
3
|
+
[](https://github.com/gianlucapagliara/eventspype/actions/workflows/ci.yml)
|
|
4
|
+
[](https://codecov.io/gh/gianlucapagliara/eventspype)
|
|
5
|
+
[](https://www.python.org/downloads/)
|
|
6
|
+
|
|
7
|
+
A lightweight and type-safe Python framework for building event-driven applications. eventspype provides a clean publisher-subscriber pattern implementation, making it easy to create decoupled and maintainable event-driven systems.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- 🎯 Type-safe publisher-subscriber pattern implementation
|
|
12
|
+
- 🔄 Support for multiple publishers and subscribers
|
|
13
|
+
- 🚀 Asynchronous event handling capabilities
|
|
14
|
+
- 🛠️ Easy to use and integrate
|
|
15
|
+
- 📦 Zero dependencies
|
|
16
|
+
- 🔒 Thread-safe event distribution
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Using pip
|
|
22
|
+
pip install eventspype
|
|
23
|
+
|
|
24
|
+
# Using poetry
|
|
25
|
+
poetry add eventspype
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## License
|
|
29
|
+
|
|
30
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from ..subscribers.functional import FunctionalEventSubscriber
|
|
4
|
+
from ..subscribers.subscriber import EventSubscriber
|
|
5
|
+
from .publications import EventPublication
|
|
6
|
+
from .publisher import EventPublisher
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MultiPublisher:
|
|
10
|
+
"""
|
|
11
|
+
A publisher that can handle multiple event types through different publications.
|
|
12
|
+
Each publication is handled by its own EventPublisher instance.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
# Map of publications to their dedicated publishers
|
|
17
|
+
self._publishers: dict[EventPublication, EventPublisher] = {}
|
|
18
|
+
# Keep references to functional subscribers to prevent garbage collection
|
|
19
|
+
self._functional_subscribers: dict[Any, FunctionalEventSubscriber] = {}
|
|
20
|
+
|
|
21
|
+
# === Class Methods ===
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def get_event_definitions(cls) -> dict[str, EventPublication]:
|
|
25
|
+
"""Get all event publications defined in the class."""
|
|
26
|
+
result = {}
|
|
27
|
+
for name, value in cls.__dict__.items():
|
|
28
|
+
if isinstance(value, EventPublication):
|
|
29
|
+
result[name] = value
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
def _get_or_create_publisher(self, publication: EventPublication) -> EventPublisher:
|
|
33
|
+
"""Get or create a dedicated publisher for a publication."""
|
|
34
|
+
if publication not in self._publishers:
|
|
35
|
+
self._publishers[publication] = EventPublisher(publication)
|
|
36
|
+
return self._publishers[publication]
|
|
37
|
+
|
|
38
|
+
# === Subscriptions ===
|
|
39
|
+
|
|
40
|
+
def add_subscriber(
|
|
41
|
+
self, publication: EventPublication, subscriber: EventSubscriber
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Add a subscriber for a specific publication."""
|
|
44
|
+
if publication not in self.get_event_definitions().values():
|
|
45
|
+
raise ValueError(f"Invalid publication: {publication}")
|
|
46
|
+
|
|
47
|
+
publisher = self._get_or_create_publisher(publication)
|
|
48
|
+
publisher.add_subscriber(subscriber)
|
|
49
|
+
|
|
50
|
+
def remove_subscriber(
|
|
51
|
+
self, publication: EventPublication, subscriber: EventSubscriber
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Remove a subscriber for a specific publication."""
|
|
54
|
+
if publication not in self.get_event_definitions().values():
|
|
55
|
+
raise ValueError(f"Invalid publication: {publication}")
|
|
56
|
+
|
|
57
|
+
if publication not in self._publishers:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
publisher = self._publishers[publication]
|
|
61
|
+
publisher.remove_subscriber(subscriber)
|
|
62
|
+
|
|
63
|
+
# Clean up empty publishers
|
|
64
|
+
if not publisher.get_subscribers():
|
|
65
|
+
del self._publishers[publication]
|
|
66
|
+
|
|
67
|
+
def add_subscriber_with_callback(
|
|
68
|
+
self, publication: EventPublication, callback: Any
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Add a callback function as a subscriber for a specific publication."""
|
|
71
|
+
if publication not in self.get_event_definitions().values():
|
|
72
|
+
raise ValueError(f"Invalid publication: {publication}")
|
|
73
|
+
|
|
74
|
+
subscriber = FunctionalEventSubscriber(callback)
|
|
75
|
+
# Keep a reference to the subscriber
|
|
76
|
+
self._functional_subscribers[callback] = subscriber
|
|
77
|
+
self.add_subscriber(publication, subscriber)
|
|
78
|
+
|
|
79
|
+
def remove_subscriber_with_callback(
|
|
80
|
+
self, publication: EventPublication, callback: Any
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Remove a callback function subscriber for a specific publication."""
|
|
83
|
+
if publication not in self.get_event_definitions().values():
|
|
84
|
+
raise ValueError(f"Invalid publication: {publication}")
|
|
85
|
+
|
|
86
|
+
if publication not in self._publishers:
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
# Get the subscriber from our references
|
|
90
|
+
if callback in self._functional_subscribers:
|
|
91
|
+
subscriber = self._functional_subscribers[callback]
|
|
92
|
+
self.remove_subscriber(publication, subscriber)
|
|
93
|
+
del self._functional_subscribers[callback]
|
|
94
|
+
|
|
95
|
+
# === Events ===
|
|
96
|
+
|
|
97
|
+
def trigger_event(self, publication: EventPublication, event: Any) -> None:
|
|
98
|
+
"""Trigger an event for a specific publication."""
|
|
99
|
+
if publication not in self.get_event_definitions().values():
|
|
100
|
+
raise ValueError(f"Invalid publication: {publication}")
|
|
101
|
+
|
|
102
|
+
if publication not in self._publishers:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# Use the dedicated publisher to trigger the event
|
|
106
|
+
self._publishers[publication].trigger_event(event)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class EventPublication:
|
|
6
|
+
def __init__(self, event_tag: Enum | int | str, event_class: Any) -> None:
|
|
7
|
+
self.original_tag = event_tag
|
|
8
|
+
self.event_class = event_class
|
|
9
|
+
|
|
10
|
+
if isinstance(event_tag, Enum):
|
|
11
|
+
event_tag = event_tag.value
|
|
12
|
+
if isinstance(event_tag, str):
|
|
13
|
+
event_tag = hash(event_tag.upper()) % 10**8
|
|
14
|
+
if not isinstance(event_tag, int):
|
|
15
|
+
raise ValueError(f"Invalid event tag: {event_tag}")
|
|
16
|
+
self.event_tag: int = event_tag
|
|
17
|
+
|
|
18
|
+
def __hash__(self) -> int:
|
|
19
|
+
return hash((self.event_tag, self.event_class))
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import random
|
|
3
|
+
import weakref
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..subscribers.subscriber import EventSubscriber
|
|
7
|
+
from .publications import EventPublication
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EventPublisher:
|
|
11
|
+
"""
|
|
12
|
+
EventPublisher with weak references for a single event type. This avoids the lapsed subscriber problem by periodically
|
|
13
|
+
performing GC on dead event subscribers.
|
|
14
|
+
|
|
15
|
+
Dead subscriber cleanup is done by calling _remove_dead_subscribers(), which checks whether the subscriber weak references are
|
|
16
|
+
alive or not, and removes the dead ones. Each call to _remove_dead_subscribers() takes O(n).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
ADD_SUBSCRIBER_GC_PROBABILITY = 0.005
|
|
20
|
+
|
|
21
|
+
def __init__(self, publication: EventPublication) -> None:
|
|
22
|
+
self._publication = publication
|
|
23
|
+
self._subscribers: set[weakref.ReferenceType[EventSubscriber]] = set()
|
|
24
|
+
self._logger: logging.Logger | None = None
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def name(self) -> str:
|
|
28
|
+
return self.__class__.__name__
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def logger(self) -> logging.Logger:
|
|
32
|
+
if self._logger is None:
|
|
33
|
+
self._logger = logging.getLogger(__name__)
|
|
34
|
+
return self._logger
|
|
35
|
+
|
|
36
|
+
def add_subscriber(self, subscriber: EventSubscriber) -> None:
|
|
37
|
+
"""Add a subscriber for this publisher's event."""
|
|
38
|
+
# Create weak reference to the subscriber
|
|
39
|
+
subscriber_ref = weakref.ref(subscriber)
|
|
40
|
+
self._subscribers.add(subscriber_ref)
|
|
41
|
+
|
|
42
|
+
# Randomly perform garbage collection
|
|
43
|
+
if random.random() < self.ADD_SUBSCRIBER_GC_PROBABILITY:
|
|
44
|
+
self._remove_dead_subscribers()
|
|
45
|
+
|
|
46
|
+
def remove_subscriber(self, subscriber: EventSubscriber) -> None:
|
|
47
|
+
"""Remove a subscriber."""
|
|
48
|
+
# Create a temporary weak reference for comparison
|
|
49
|
+
subscriber_ref = weakref.ref(subscriber)
|
|
50
|
+
|
|
51
|
+
# Remove the subscriber if it exists
|
|
52
|
+
self._subscribers.discard(subscriber_ref)
|
|
53
|
+
|
|
54
|
+
# Clean up dead subscribers
|
|
55
|
+
self._remove_dead_subscribers()
|
|
56
|
+
|
|
57
|
+
def get_subscribers(self) -> list[EventSubscriber]:
|
|
58
|
+
"""Get all active subscribers."""
|
|
59
|
+
self._remove_dead_subscribers()
|
|
60
|
+
|
|
61
|
+
# Return only the subscribers that are still alive
|
|
62
|
+
return [
|
|
63
|
+
subscriber
|
|
64
|
+
for subscriber in (ref() for ref in self._subscribers)
|
|
65
|
+
if subscriber is not None
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
def trigger_event(self, message: Any) -> None:
|
|
69
|
+
"""Trigger an event, notifying all subscribers with the given message."""
|
|
70
|
+
# Validate event type
|
|
71
|
+
if not isinstance(message, self._publication.event_class):
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"Invalid event type: expected {self._publication.event_class}, got {type(message)}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
self._remove_dead_subscribers()
|
|
77
|
+
|
|
78
|
+
# Make a copy of the subscribers to avoid modification during iteration
|
|
79
|
+
subscribers = self._subscribers.copy()
|
|
80
|
+
|
|
81
|
+
for subscriber_ref in subscribers:
|
|
82
|
+
subscriber = subscriber_ref()
|
|
83
|
+
if subscriber is None:
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
subscriber(message, self._publication.event_tag, self)
|
|
88
|
+
except Exception:
|
|
89
|
+
self._log_exception(message)
|
|
90
|
+
|
|
91
|
+
def _remove_dead_subscribers(self) -> None:
|
|
92
|
+
"""Remove any dead subscribers."""
|
|
93
|
+
# Remove any dead references
|
|
94
|
+
self._subscribers = {ref for ref in self._subscribers if ref() is not None}
|
|
95
|
+
|
|
96
|
+
def _log_exception(self, arg: Any) -> None:
|
|
97
|
+
"""Log any exceptions that occur during event processing."""
|
|
98
|
+
self.logger.error(
|
|
99
|
+
f"Unexpected error while processing event {self._publication.event_tag}.",
|
|
100
|
+
exc_info=True,
|
|
101
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from .subscriber import EventSubscriber
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FunctionalEventSubscriber(EventSubscriber):
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
callback: Callable[[Any, int, Any], Any],
|
|
11
|
+
) -> None:
|
|
12
|
+
super().__init__()
|
|
13
|
+
self._callback = callback
|
|
14
|
+
|
|
15
|
+
def call(self, arg: Any, current_event_tag: int, current_event_caller: Any) -> None:
|
|
16
|
+
self._callback(arg, current_event_tag, current_event_caller)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from abc import abstractmethod
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
from ..publishers.publisher import EventPublisher
|
|
9
|
+
from .subscriptions import EventSubscription
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MultiSubscriber:
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._subscribers: dict[EventPublisher, dict[EventSubscription, Any]] = (
|
|
17
|
+
defaultdict(dict)
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# === Class Methods ===
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def get_event_definitions(cls) -> dict[str, EventSubscription]:
|
|
24
|
+
result = {}
|
|
25
|
+
for name, value in cls.__dict__.items():
|
|
26
|
+
if isinstance(value, EventSubscription):
|
|
27
|
+
result[name] = value
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
# === Properties ===
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def subscribers(self) -> dict[EventPublisher, dict[EventSubscription, Any]]:
|
|
34
|
+
return self._subscribers
|
|
35
|
+
|
|
36
|
+
# === Subscriptions ===
|
|
37
|
+
|
|
38
|
+
def add_subscription(
|
|
39
|
+
self, subscription: EventSubscription, publisher: EventPublisher
|
|
40
|
+
) -> None:
|
|
41
|
+
if subscription not in self.get_event_definitions().values():
|
|
42
|
+
raise ValueError("Subscription not defined in event definitions")
|
|
43
|
+
|
|
44
|
+
if subscription in self._subscribers[publisher]:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
# Save the subscriber to prevent it from being garbage collected
|
|
48
|
+
self._subscribers[publisher][subscription] = subscription(publisher, self)
|
|
49
|
+
|
|
50
|
+
def remove_subscription(
|
|
51
|
+
self, subscription: EventSubscription, publisher: EventPublisher
|
|
52
|
+
) -> None:
|
|
53
|
+
if subscription not in self.get_event_definitions().values():
|
|
54
|
+
raise ValueError("Subscription not defined in event definitions")
|
|
55
|
+
|
|
56
|
+
if subscription not in self._subscribers[publisher]:
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
subscribers = list(self._subscribers[publisher][subscription])
|
|
60
|
+
for subscriber in subscribers:
|
|
61
|
+
subscription.unsubscribe(publisher, subscriber)
|
|
62
|
+
self._subscribers[publisher][subscription].remove(subscriber)
|
|
63
|
+
|
|
64
|
+
del self._subscribers[publisher][subscription]
|
|
65
|
+
|
|
66
|
+
# === Decorators ===
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def logger(self) -> logging.Logger:
|
|
70
|
+
raise NotImplementedError
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def log_event(
|
|
74
|
+
log_level: int = logging.INFO, log_prefix: str = "Event"
|
|
75
|
+
) -> Callable[..., Callable[..., Any]]:
|
|
76
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
77
|
+
@wraps(func)
|
|
78
|
+
def wrapper(self: "MultiSubscriber", event: Any) -> Any:
|
|
79
|
+
self.logger().log(log_level, f"[{log_prefix}] {event}")
|
|
80
|
+
return func(self, event)
|
|
81
|
+
|
|
82
|
+
return wrapper
|
|
83
|
+
|
|
84
|
+
return decorator
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Protocol, TypeVar, runtime_checkable
|
|
4
|
+
|
|
5
|
+
from .subscriber import EventSubscriber
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class HasAsDict(Protocol):
|
|
10
|
+
def _asdict(self) -> dict[str, Any]: ...
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ReportingEventSubscriber(EventSubscriber):
|
|
17
|
+
"""
|
|
18
|
+
Event subscriber that logs events to a logger.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
_logger: logging.Logger | None = None
|
|
22
|
+
|
|
23
|
+
def __init__(self, event_source: str | None = None) -> None:
|
|
24
|
+
super().__init__()
|
|
25
|
+
self.event_source = event_source
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def logger(cls) -> logging.Logger:
|
|
29
|
+
if cls._logger is None:
|
|
30
|
+
cls._logger = logging.getLogger(__name__)
|
|
31
|
+
return cls._logger
|
|
32
|
+
|
|
33
|
+
def _get_event_name(self, obj: Any) -> str:
|
|
34
|
+
"""Get the event name from an object safely."""
|
|
35
|
+
try:
|
|
36
|
+
return type(obj).__name__
|
|
37
|
+
except Exception:
|
|
38
|
+
return "UnknownEvent"
|
|
39
|
+
|
|
40
|
+
def call(
|
|
41
|
+
self,
|
|
42
|
+
event_object: Any,
|
|
43
|
+
current_event_tag: int,
|
|
44
|
+
current_event_caller: Any,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Process and log an event.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
event_object: The event object to log
|
|
51
|
+
current_event_tag: The tag of the current event
|
|
52
|
+
current_event_caller: The publisher that triggered the event
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
# Convert event object to dictionary
|
|
56
|
+
if dataclasses.is_dataclass(type(event_object)):
|
|
57
|
+
event_dict = dataclasses.asdict(event_object)
|
|
58
|
+
elif isinstance(event_object, HasAsDict):
|
|
59
|
+
event_dict = event_object._asdict()
|
|
60
|
+
else:
|
|
61
|
+
event_dict = {"value": str(event_object)}
|
|
62
|
+
|
|
63
|
+
# Add event metadata
|
|
64
|
+
metadata: dict[str, Any] = {
|
|
65
|
+
"event_name": self._get_event_name(event_object),
|
|
66
|
+
"event_source": self.event_source,
|
|
67
|
+
"event_tag": current_event_tag,
|
|
68
|
+
}
|
|
69
|
+
event_dict.update(metadata)
|
|
70
|
+
|
|
71
|
+
# Log the event at INFO level
|
|
72
|
+
self.logger().info(
|
|
73
|
+
f"Event received: {event_dict}", extra={"event_data": event_dict}
|
|
74
|
+
)
|
|
75
|
+
except Exception:
|
|
76
|
+
self.logger().error(
|
|
77
|
+
"Error logging event.",
|
|
78
|
+
exc_info=True,
|
|
79
|
+
extra={"event_source": self.event_source},
|
|
80
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from abc import abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class EventSubscriber:
|
|
6
|
+
def __call__(
|
|
7
|
+
self, arg: Any, current_event_tag: int, current_event_caller: Any
|
|
8
|
+
) -> None:
|
|
9
|
+
self.call(arg, current_event_tag, current_event_caller)
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def call(
|
|
13
|
+
self,
|
|
14
|
+
arg: Any,
|
|
15
|
+
current_event_tag: int,
|
|
16
|
+
current_event_caller: Any,
|
|
17
|
+
) -> None:
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OwnedEventSubscriber(EventSubscriber):
|
|
22
|
+
def __init__(self, owner: Any) -> None:
|
|
23
|
+
super().__init__()
|
|
24
|
+
self._owner = owner
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def owner(self) -> Any:
|
|
28
|
+
return self._owner
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from functools import partial
|
|
4
|
+
from typing import Any, TypeVar
|
|
5
|
+
|
|
6
|
+
from ..publishers.publisher import EventPublisher
|
|
7
|
+
from .functional import FunctionalEventSubscriber
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EventSubscription:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
publisher_class: Any,
|
|
16
|
+
event_tag: Any | list[Any],
|
|
17
|
+
callback: Callable[..., Any],
|
|
18
|
+
callback_with_subscriber: bool = True,
|
|
19
|
+
) -> None:
|
|
20
|
+
self._publisher_class = publisher_class
|
|
21
|
+
self._event_tag = event_tag
|
|
22
|
+
self._callback = callback
|
|
23
|
+
self._callback_with_subscriber = callback_with_subscriber
|
|
24
|
+
|
|
25
|
+
def __call__(
|
|
26
|
+
self,
|
|
27
|
+
publisher: EventPublisher,
|
|
28
|
+
subscriber: Any | None = None,
|
|
29
|
+
) -> list[FunctionalEventSubscriber]:
|
|
30
|
+
return self.subscribe(publisher, subscriber)
|
|
31
|
+
|
|
32
|
+
def __hash__(self) -> int:
|
|
33
|
+
return hash((self.publisher_class, self.event_tag_str, self.callback))
|
|
34
|
+
|
|
35
|
+
# === Properties ===
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def publisher_class(self) -> Any:
|
|
39
|
+
return self._publisher_class
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def event_tag(self) -> Any:
|
|
43
|
+
return self._event_tag
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def callback(self) -> Callable[..., Any]:
|
|
47
|
+
return self._callback
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def callback_with_subscriber(self) -> bool:
|
|
51
|
+
return self._callback_with_subscriber
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def event_tag_str(self) -> str:
|
|
55
|
+
tags = str(self.event_tag)
|
|
56
|
+
if isinstance(self.event_tag, list):
|
|
57
|
+
tags = ", ".join(sorted([str(tag) for tag in self.event_tag]))
|
|
58
|
+
tags = f"[{tags}]"
|
|
59
|
+
return tags
|
|
60
|
+
|
|
61
|
+
# === Subscriptions ===
|
|
62
|
+
|
|
63
|
+
def subscribe(
|
|
64
|
+
self, publisher: EventPublisher, subscriber: Any
|
|
65
|
+
) -> list[FunctionalEventSubscriber]:
|
|
66
|
+
subscribers = []
|
|
67
|
+
tags = self._get_event_tags(self.event_tag)
|
|
68
|
+
for event_tag in tags:
|
|
69
|
+
subscribers.append(self._subscribe(publisher, event_tag, subscriber))
|
|
70
|
+
return subscribers
|
|
71
|
+
|
|
72
|
+
def unsubscribe(
|
|
73
|
+
self, publisher: EventPublisher, subscriber: FunctionalEventSubscriber
|
|
74
|
+
) -> None:
|
|
75
|
+
tags = self._get_event_tags(self.event_tag)
|
|
76
|
+
for event_tag in tags:
|
|
77
|
+
self._unsubscribe(publisher, subscriber, event_tag)
|
|
78
|
+
|
|
79
|
+
def _get_event_tags(self, event_tag: Any) -> list[int | Enum]:
|
|
80
|
+
tags = event_tag if isinstance(event_tag, list) else [event_tag]
|
|
81
|
+
return [tag if isinstance(tag, Enum | int) else hash(self) for tag in tags]
|
|
82
|
+
|
|
83
|
+
def _subscribe(
|
|
84
|
+
self,
|
|
85
|
+
publisher: EventPublisher,
|
|
86
|
+
event_tag: Any,
|
|
87
|
+
subscriber: Any | None = None,
|
|
88
|
+
) -> FunctionalEventSubscriber:
|
|
89
|
+
if not isinstance(publisher, self.publisher_class):
|
|
90
|
+
raise ValueError("Publisher type mismatch")
|
|
91
|
+
|
|
92
|
+
callback = self.callback
|
|
93
|
+
if self.callback_with_subscriber:
|
|
94
|
+
if subscriber is None:
|
|
95
|
+
raise ValueError("Subscriber is required for callback with subscriber")
|
|
96
|
+
callback = partial(self.callback, subscriber)
|
|
97
|
+
|
|
98
|
+
subscriber = FunctionalEventSubscriber(callback)
|
|
99
|
+
publisher.add_subscriber(subscriber)
|
|
100
|
+
return subscriber
|
|
101
|
+
|
|
102
|
+
def _unsubscribe(
|
|
103
|
+
self,
|
|
104
|
+
publisher: EventPublisher,
|
|
105
|
+
subscriber: FunctionalEventSubscriber,
|
|
106
|
+
event_tag: Any,
|
|
107
|
+
) -> None:
|
|
108
|
+
if not isinstance(publisher, self.publisher_class):
|
|
109
|
+
raise ValueError("Publisher type mismatch")
|
|
110
|
+
publisher.remove_subscriber(subscriber)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections import deque
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from async_timeout import timeout
|
|
6
|
+
|
|
7
|
+
from .subscriber import EventSubscriber
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TrackingEventSubscriber(EventSubscriber):
|
|
11
|
+
"""
|
|
12
|
+
A subscriber that collects events and provides async waiting functionality.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, event_source: str | None = None, max_len: int = 50) -> None:
|
|
16
|
+
"""
|
|
17
|
+
Initialize the event logger.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
event_source: Optional source identifier for the events
|
|
21
|
+
max_len: Maximum number of events to keep in the log (default: 50)
|
|
22
|
+
"""
|
|
23
|
+
super().__init__()
|
|
24
|
+
self._event_source = event_source
|
|
25
|
+
self._generic_collected_events: deque[Any] = deque(maxlen=max_len)
|
|
26
|
+
self._collected_events: dict[type[Any], deque[Any]] = {}
|
|
27
|
+
self._waiting: dict[asyncio.Event, type[Any]] = {}
|
|
28
|
+
self._wait_returns: dict[asyncio.Event, Any] = {}
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def event_log(self) -> list[Any]:
|
|
32
|
+
"""Get all collected events as a list."""
|
|
33
|
+
return list(self._generic_collected_events)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def event_source(self) -> str | None:
|
|
37
|
+
"""Get the event source identifier."""
|
|
38
|
+
return self._event_source
|
|
39
|
+
|
|
40
|
+
def clear(self) -> None:
|
|
41
|
+
"""Clear all collected events."""
|
|
42
|
+
self._generic_collected_events.clear()
|
|
43
|
+
self._collected_events.clear()
|
|
44
|
+
|
|
45
|
+
async def wait_for(
|
|
46
|
+
self, event_type: type[Any], timeout_seconds: float = 180
|
|
47
|
+
) -> Any:
|
|
48
|
+
"""
|
|
49
|
+
Wait for an event of a specific type to occur.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
event_type: The type of event to wait for
|
|
53
|
+
timeout_seconds: How long to wait before timing out (default: 180 seconds)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The event object when it occurs
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
TimeoutError: If the event doesn't occur within timeout_seconds
|
|
60
|
+
"""
|
|
61
|
+
notifier = asyncio.Event()
|
|
62
|
+
self._waiting[notifier] = event_type
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
async with timeout(timeout_seconds):
|
|
66
|
+
await notifier.wait()
|
|
67
|
+
|
|
68
|
+
retval = self._wait_returns.get(notifier)
|
|
69
|
+
if notifier in self._wait_returns:
|
|
70
|
+
del self._wait_returns[notifier]
|
|
71
|
+
return retval
|
|
72
|
+
finally:
|
|
73
|
+
# Always clean up, even on timeout
|
|
74
|
+
if notifier in self._waiting:
|
|
75
|
+
del self._waiting[notifier]
|
|
76
|
+
if notifier in self._wait_returns:
|
|
77
|
+
del self._wait_returns[notifier]
|
|
78
|
+
|
|
79
|
+
def call(
|
|
80
|
+
self,
|
|
81
|
+
event_object: Any,
|
|
82
|
+
current_event_tag: int,
|
|
83
|
+
current_event_caller: Any,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Process an event by logging it and notifying any waiters.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
event_object: The event to process
|
|
90
|
+
current_event_tag: The tag of the current event
|
|
91
|
+
current_event_caller: The publisher that triggered the event
|
|
92
|
+
"""
|
|
93
|
+
# Get the appropriate deque for this event type
|
|
94
|
+
event_type = type(event_object)
|
|
95
|
+
event_deque = self._collected_events.get(event_type)
|
|
96
|
+
if event_deque is None:
|
|
97
|
+
event_deque = self._generic_collected_events
|
|
98
|
+
|
|
99
|
+
# Log the event
|
|
100
|
+
event_deque.append(event_object)
|
|
101
|
+
|
|
102
|
+
# Notify any waiters for this event type
|
|
103
|
+
should_notify = []
|
|
104
|
+
for notifier, waiting_event_type in self._waiting.items():
|
|
105
|
+
if event_type is waiting_event_type:
|
|
106
|
+
should_notify.append(notifier)
|
|
107
|
+
self._wait_returns[notifier] = event_object
|
|
108
|
+
|
|
109
|
+
# Set the events after collecting them all
|
|
110
|
+
for notifier in should_notify:
|
|
111
|
+
notifier.set()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "eventspype"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A Python framework for building event-driven applications with a clean publisher-subscriber pattern implementation."
|
|
5
|
+
authors = ["Gianluca Pagliara <pagliara.gianluca@gmail.com>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
|
|
8
|
+
[tool.poetry.dependencies]
|
|
9
|
+
python = "^3.13"
|
|
10
|
+
async-timeout = "^5.0.1"
|
|
11
|
+
pytest-asyncio = "^0.21.1"
|
|
12
|
+
|
|
13
|
+
[tool.poetry.group.dev.dependencies]
|
|
14
|
+
pytest = "^7.4.3"
|
|
15
|
+
pytest-cov = "^4.1.0"
|
|
16
|
+
black = "^23.11.0"
|
|
17
|
+
isort = "^5.12.0"
|
|
18
|
+
mypy = "^1.7.0"
|
|
19
|
+
safety = "^2.3.5"
|
|
20
|
+
pre-commit = "^3.5.0"
|
|
21
|
+
ruff = "^0.8.4"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["poetry-core"]
|
|
25
|
+
build-backend = "poetry.core.masonry.api"
|
|
26
|
+
|
|
27
|
+
[tool.black]
|
|
28
|
+
line-length = 88
|
|
29
|
+
target-version = ['py312']
|
|
30
|
+
include = '\.pyi?$'
|
|
31
|
+
|
|
32
|
+
[tool.isort]
|
|
33
|
+
profile = "black"
|
|
34
|
+
multi_line_output = 3
|
|
35
|
+
line_length = 88
|
|
36
|
+
|
|
37
|
+
[tool.mypy]
|
|
38
|
+
python_version = "3.13"
|
|
39
|
+
warn_return_any = true
|
|
40
|
+
warn_unused_configs = true
|
|
41
|
+
disallow_untyped_defs = true
|
|
42
|
+
check_untyped_defs = true
|
|
43
|
+
strict = true
|
|
44
|
+
disallow_untyped_decorators = false
|
|
45
|
+
ignore_missing_imports = true
|
|
46
|
+
disable_error_code = ["misc"]
|
|
47
|
+
exclude = ["tests/.*"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 88
|
|
51
|
+
target-version = "py312"
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = [
|
|
55
|
+
"E", # pycodestyle errors
|
|
56
|
+
"W", # pycodestyle warnings
|
|
57
|
+
"F", # pyflakes
|
|
58
|
+
"I", # isort
|
|
59
|
+
"C", # flake8-comprehensions
|
|
60
|
+
"B", # flake8-bugbear
|
|
61
|
+
"UP" # pyupgrade
|
|
62
|
+
]
|
|
63
|
+
ignore = [
|
|
64
|
+
"E203", # See https://github.com/psf/black/issues/315
|
|
65
|
+
"E501" # Line too long (handled by black)
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
[tool.ruff.lint.per-file-ignores]
|
|
69
|
+
"__init__.py" = ["F401"] # Ignore unused imports in __init__ files
|