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.
@@ -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
+ [![CI](https://github.com/gianlucapagliara/eventspype/actions/workflows/ci.yml/badge.svg)](https://github.com/gianlucapagliara/eventspype/actions/workflows/ci.yml)
17
+ [![codecov](https://codecov.io/gh/gianlucapagliara/eventspype/branch/main/graph/badge.svg)](https://codecov.io/gh/gianlucapagliara/eventspype)
18
+ [![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](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
+ [![CI](https://github.com/gianlucapagliara/eventspype/actions/workflows/ci.yml/badge.svg)](https://github.com/gianlucapagliara/eventspype/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/gianlucapagliara/eventspype/branch/main/graph/badge.svg)](https://codecov.io/gh/gianlucapagliara/eventspype)
5
+ [![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](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