Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion livekit-rtc/livekit/rtc/event_emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ def once_callback(*args: Any, **kwargs: Any) -> None:
self.off(event, once_callback)
callback(*args, **kwargs)

# `inspect.signature` follows `__wrapped__`, so `emit` trims the arguments to
# what `callback` accepts. `_once_of` is set only on these wrappers, so `off`
# can match them to `callback` without touching other decorated listeners.
once_callback.__wrapped__ = callback # type: ignore[attr-defined]
once_callback._once_of = callback # type: ignore[attr-defined]
return self.on(event, once_callback)
else:

Expand Down Expand Up @@ -212,4 +217,8 @@ def greet(name):
```
"""
if event in self._events:
self._events[event].pop(callback, None)
handlers = self._events[event]
handlers.pop(callback, None)
# a callback registered with `once` is stored wrapped, possibly more than once
for registered in [h for h in handlers if getattr(h, "_once_of", None) == callback]:
del handlers[registered]
105 changes: 105 additions & 0 deletions tests/rtc/test_emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,3 +287,108 @@ def handler() -> None:
emitter.on("event", handler)
emitter.emit("event")
assert calls == ["x"]


def test_off_removes_a_once_callback() -> None:
emitter = EventEmitter[str]()
calls: list[bool] = []

def on_event() -> None:
calls.append(True)

emitter.once("event", on_event)
emitter.off("event", on_event)
emitter.emit("event")

assert calls == []


def test_off_removes_a_once_bound_method() -> None:
class Handler:
def __init__(self) -> None:
self.calls = 0

def on_event(self) -> None:
self.calls += 1

emitter = EventEmitter[str]()
handler = Handler()

emitter.once("event", handler.on_event)
emitter.off("event", handler.on_event)
emitter.emit("event")

assert handler.calls == 0


def test_once_passes_only_the_arguments_the_callback_accepts() -> None:
emitter = EventEmitter[str]()
calls: list[tuple[Any, ...]] = []

def on_event(first: Any, second: Any) -> None:
calls.append((first, second))

emitter.once("event", on_event)
emitter.emit("event", 1, 2, 3, 4) # only 2 arguments will be passed
emitter.emit("event", 1, 2, 3, 4)

assert calls == [(1, 2)]


def test_once_with_no_parameters_ignores_emitted_arguments() -> None:
emitter = EventEmitter[str]()
calls: list[bool] = []

@emitter.once("event")
def on_event() -> None:
calls.append(True)

emitter.emit("event", 1, 2)

assert calls == [True]


def test_once_passes_all_arguments_to_a_varargs_callback() -> None:
emitter = EventEmitter[str]()
calls: list[tuple[Any, ...]] = []

@emitter.once("event")
def on_event(*args: Any) -> None:
calls.append(args)

emitter.emit("event", 1, 2, 3)

assert calls == [(1, 2, 3)]


def test_off_keeps_a_wraps_decorated_listener_of_another_callback() -> None:
emitter = EventEmitter[str]()
calls: list[bool] = []

def original() -> None:
pass

@functools.wraps(original)
def listener() -> None:
calls.append(True)

emitter.on("event", listener)
emitter.off("event", original)
emitter.emit("event")

assert calls == [True]


def test_off_removes_every_once_registration_of_a_callback() -> None:
emitter = EventEmitter[str]()
calls: list[bool] = []

def handler() -> None:
calls.append(True)

emitter.once("event", handler)
emitter.once("event", handler)
emitter.off("event", handler)
emitter.emit("event")

assert calls == []
Loading