Skip to content

Commit d1a17db

Browse files
authored
Add option to add event handlers which accept no arguments (#84)
Right now, you can add actor event handlers which accept exactly one argument - event data - even if the event itself does not convey any data (like the migration event). If you try to pass an event handler which accepts no arguments, the event handler will throw when the event happens. Meaning that this does not work: ```python def migration_handler(): print('Migration happened') Actor.on(ActorEventTypes.MIGRATING, migration_handler) ``` But this works, because the `MIGRATING` event never has any data: ```python def migration_handler(event_data: None): assert event_data is None print('Migration happened') Actor.on(ActorEventTypes.MIGRATING, migration_handler) ``` One user was struggling with this, and it's pretty hard to test, especially for the migrations, so I changed the logic so that the first thing works, and the event handler is passed the `event_data` argument only if it can accept it.
1 parent b4ed2ec commit d1a17db

File tree

4 files changed

+77
-8
lines changed

4 files changed

+77
-8
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
Changelog
22
=========
33

4+
[1.1.0](../../releases/tag/v1.1.0) - Unreleased
5+
-----------------------------------------------
6+
7+
### Added
8+
9+
- option to add event handlers which accept no arguments
10+
411
[1.0.0](../../releases/tag/v1.0.0) - 2022-03-13
512
-----------------------------------------------
613

src/apify/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '1.0.0'
1+
__version__ = '1.1.0'

src/apify/event_manager.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import inspect
33
import json
44
from collections import defaultdict
5-
from typing import Any, Callable, Dict, List, Optional, Set
5+
from typing import Any, Callable, Coroutine, Dict, List, Optional, Set, Union
66

77
import websockets.client
88
from pyee.asyncio import AsyncIOEventEmitter
@@ -12,6 +12,8 @@
1212
from .consts import ActorEventTypes
1313
from .log import logger
1414

15+
ListenerType = Union[Callable[[], None], Callable[[Any], None], Callable[[], Coroutine[Any, Any, None]], Callable[[Any], Coroutine[Any, Any, None]]]
16+
1517

1618
@ignore_docs
1719
class EventManager:
@@ -87,26 +89,37 @@ async def close(self, event_listeners_timeout_secs: Optional[float] = None) -> N
8789

8890
self._initialized = False
8991

90-
def on(self, event_name: ActorEventTypes, listener: Callable) -> Callable:
92+
def on(self, event_name: ActorEventTypes, listener: ListenerType) -> Callable:
9193
"""Add an event listener to the event manager.
9294
9395
Args:
9496
event_name (ActorEventTypes): The actor event for which to listen to.
9597
listener (Callable): The function which is to be called when the event is emitted (can be async).
98+
Must accept either zero or one arguments (the first argument will be the event data).
9699
"""
97100
if not self._initialized:
98101
raise RuntimeError('EventManager was not initialized!')
99102

103+
listener_argument_count = len(inspect.signature(listener).parameters)
104+
if listener_argument_count > 1:
105+
raise ValueError('The "listener" argument must be a callable which accepts 0 or 1 arguments!')
106+
100107
event_name = _maybe_extract_enum_member_value(event_name)
101108

102-
async def inner_wrapper(*args: Any, **kwargs: Any) -> None:
109+
async def inner_wrapper(event_data: Any) -> None:
103110
if inspect.iscoroutinefunction(listener):
104-
await listener(*args, **kwargs)
111+
if listener_argument_count == 0:
112+
await listener()
113+
else:
114+
await listener(event_data)
105115
else:
106-
listener(*args, **kwargs)
116+
if listener_argument_count == 0:
117+
listener() # type: ignore[call-arg]
118+
else:
119+
listener(event_data) # type: ignore[call-arg]
107120

108-
async def outer_wrapper(*args: Any, **kwargs: Any) -> None:
109-
listener_task = asyncio.create_task(inner_wrapper(*args, **kwargs))
121+
async def outer_wrapper(event_data: Any) -> None:
122+
listener_task = asyncio.create_task(inner_wrapper(event_data))
110123
self._listener_tasks.add(listener_task)
111124
try:
112125
await listener_task

tests/unit/test_event_manager.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,55 @@ def event_handler(data: Any) -> None:
105105

106106
await event_manager.close()
107107

108+
async def test_event_handler_argument_counts_local(self) -> None:
109+
config = Configuration()
110+
event_manager = EventManager(config)
111+
112+
await event_manager.init()
113+
114+
event_calls = []
115+
116+
def sync_no_arguments() -> None:
117+
nonlocal event_calls
118+
event_calls.append(('sync_no_arguments', None))
119+
120+
async def async_no_arguments() -> None:
121+
nonlocal event_calls
122+
event_calls.append(('async_no_arguments', None))
123+
124+
def sync_one_argument(event_data: Any) -> None:
125+
nonlocal event_calls
126+
event_calls.append(('sync_one_argument', event_data))
127+
128+
async def async_one_argument(event_data: Any) -> None:
129+
nonlocal event_calls
130+
event_calls.append(('async_one_argument', event_data))
131+
132+
def sync_two_arguments(_arg1: Any, _arg2: Any) -> None:
133+
pass
134+
135+
async def async_two_arguments(_arg1: Any, _arg2: Any) -> None:
136+
pass
137+
138+
event_manager.on(ActorEventTypes.SYSTEM_INFO, sync_no_arguments)
139+
event_manager.on(ActorEventTypes.SYSTEM_INFO, async_no_arguments)
140+
event_manager.on(ActorEventTypes.SYSTEM_INFO, sync_one_argument)
141+
event_manager.on(ActorEventTypes.SYSTEM_INFO, async_one_argument)
142+
143+
with pytest.raises(ValueError, match='The "listener" argument must be a callable which accepts 0 or 1 arguments!'):
144+
event_manager.on(ActorEventTypes.SYSTEM_INFO, sync_two_arguments) # type: ignore[arg-type]
145+
with pytest.raises(ValueError, match='The "listener" argument must be a callable which accepts 0 or 1 arguments!'):
146+
event_manager.on(ActorEventTypes.SYSTEM_INFO, async_two_arguments) # type: ignore[arg-type]
147+
148+
event_manager.emit(ActorEventTypes.SYSTEM_INFO, 'DUMMY_SYSTEM_INFO')
149+
await asyncio.sleep(0.1)
150+
151+
assert len(event_calls) == 4
152+
assert ('sync_no_arguments', None) in event_calls
153+
assert ('async_no_arguments', None) in event_calls
154+
assert ('sync_one_argument', 'DUMMY_SYSTEM_INFO') in event_calls
155+
assert ('async_one_argument', 'DUMMY_SYSTEM_INFO') in event_calls
156+
108157
async def test_event_async_handling_local(self) -> None:
109158
config = Configuration()
110159
event_manager = EventManager(config)

0 commit comments

Comments
 (0)