Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[flake8]
exclude =
.git,
__pycache__,
.venv,
env,
gen_resources,
build,
extend-ignore=F401,E704,E226
36 changes: 36 additions & 0 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Python package

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
build:

runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.12"]

steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -e .[testing]
- name: Lint with flake8
run: |
flake8 . --count --max-complexity=10 --max-line-length=180 --show-source --statistics
- name: Type check with mypy
run: |
mypy src
- name: Test with pytest
run: |
pytest
7 changes: 6 additions & 1 deletion src/pybind/emitters.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
from abc import ABC, abstractmethod
from typing import TypeVar, Generic


T = TypeVar("T")
U = TypeVar("U")
S = TypeVar("S")


class Emitter(ABC):
@abstractmethod
def __call__(self) -> None: ...


class ValueEmitter(Generic[T], ABC):
@abstractmethod
def __call__(self, value0: T) -> None: ...


class BiEmitter(Generic[T, U], ABC):
@abstractmethod
def __call__(self, value0: T, value1: U) -> None: ...


class TriEmitter(Generic[T, U, S], ABC):
@abstractmethod
def __call__(self, value0: T, value1: U, value2: S) -> None: ...
def __call__(self, value0: T, value1: U, value2: S) -> None: ...
45 changes: 28 additions & 17 deletions src/pybind/event.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import inspect
from inspect import Parameter
from typing import Callable, TypeVar, Generic

from pybind.emitters import Emitter, TriEmitter, BiEmitter, ValueEmitter
Expand All @@ -16,32 +17,42 @@ def trim_and_call(listener: Callable, *parameters):
listener(*trimmed_parameters)


def _is_required_parameter(param: Parameter) -> bool:
return param.default == param.empty and param.kind in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD)


def count_non_default_parameters(function: Callable) -> int:
parameters = inspect.signature(function).parameters
return sum(1 for param in parameters.values() if param.default == param.empty)
return sum(1 for param in parameters.values() if _is_required_parameter(param))


def assert_parameter_max_count(callable_: Callable, max_count: int) -> None:
if count_non_default_parameters(callable_) > max_count:
raise ValueError(f"Callable {callable_.__name__} has too many non-default parameters: "
if hasattr(callable_, '__name__'):
callable_name = callable_.__name__
elif hasattr(callable_, '__class__'):
callable_name = callable_.__class__.__name__
else:
callable_name = str(callable_)
raise ValueError(f"Callable {callable_name} has too many non-default parameters: "
f"{count_non_default_parameters(callable_)} > {max_count}")


class Event(Observable, Emitter):
_observers: list[Observer]

def __init__(self):
self.listeners = []
self._observers = []

def observe(self, observer: Observer):
self.listeners.append(observer)
def observe(self, observer: Observer) -> None:
self._observers.append(observer)
assert_parameter_max_count(observer, 0)
return self

def unobserve(self, observer: Observer):
self.listeners.remove(observer)
return self
def unobserve(self, observer: Observer) -> None:
self._observers.remove(observer)

def __call__(self) -> None:
for listener in self.listeners:
for listener in self._observers:
listener()


Expand All @@ -52,11 +63,11 @@ def __init__(self):
self._observers = []
super().__init__()

def observe(self, observer: Observer | ValueObserver[_T]) -> None:
def observe(self, observer: Observer | ValueObserver[_S]) -> None:
self._observers.append(observer)
assert_parameter_max_count(observer, 1)

def unobserve(self, observer: Observer | ValueObserver[_T]) -> None:
def unobserve(self, observer: Observer | ValueObserver[_S]) -> None:
self._observers.remove(observer)

def __call__(self, value: _S) -> None:
Expand All @@ -70,11 +81,11 @@ class BiEvent(Generic[_S, _T], BiObservable[_S, _T], BiEmitter[_S, _T]):
def __init__(self):
self._observers = []

def observe(self, observer: Observer | ValueObserver[_T] | BiObserver[_T, _U]) -> None:
def observe(self, observer: Observer | ValueObserver[_S] | BiObserver[_S, _T]) -> None:
self._observers.append(observer)
assert_parameter_max_count(observer, 2)

def unobserve(self, observer: Observer | ValueObserver[_T] | BiObserver[_T, _U]) -> None:
def unobserve(self, observer: Observer | ValueObserver[_S] | BiObserver[_S, _T]) -> None:
self._observers.remove(observer)

def __call__(self, value_0: _S, value_1: _T) -> None:
Expand All @@ -83,16 +94,16 @@ def __call__(self, value_0: _S, value_1: _T) -> None:


class TriEvent(Generic[_S, _T, _U], TriObservable[_S, _T, _U], TriEmitter[_S, _T, _U]):
_observers: list[ValueObserver[_S] | BiObserver[_S, _T] | TriObserver[_S, _T, _U]]
_observers: list[Observer | ValueObserver[_S] | BiObserver[_S, _T] | TriObserver[_S, _T, _U]]

def __init__(self):
self._observers = []

def observe(self, observer: Observer | ValueObserver[_T] | BiObserver[_T, _U] | TriObserver[_T, _U, _S]) -> None:
def observe(self, observer: Observer | ValueObserver[_S] | BiObserver[_S, _T] | TriObserver[_S, _T, _U]) -> None:
self._observers.append(observer)
assert_parameter_max_count(observer, 3)

def unobserve(self, observer: Observer | ValueObserver[_T] | BiObserver[_T, _U] | TriObserver[_T, _U, _S]) -> None:
def unobserve(self, observer: Observer | ValueObserver[_S] | BiObserver[_S, _T] | TriObserver[_S, _T, _U]) -> None:
self._observers.remove(observer)

def __call__(self, value_0: _S, value_1: _T, value_2: _U) -> None:
Expand Down
37 changes: 21 additions & 16 deletions src/pybind/observables.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
from abc import ABC, abstractmethod
from typing import TypeVar, Callable, Generic, Protocol


_SC = TypeVar("_SC", contravariant=True)
_TC = TypeVar("_TC", contravariant=True)
_UC = TypeVar("_UC", contravariant=True)

_S = TypeVar("_S")
_T = TypeVar("_T")
_U = TypeVar("_U")
Expand All @@ -10,16 +15,16 @@ class Observer(Protocol):
def __call__(self) -> None: ...


class ValueObserver(Protocol[_T]):
def __call__(self, arg: _T) -> None: ...
class ValueObserver(Protocol[_TC]):
def __call__(self, arg: _TC) -> None: ...


class BiObserver(Protocol[_T, _U]):
def __call__(self, arg1: _T, arg2: _U) -> None: ...
class BiObserver(Protocol[_TC, _UC]):
def __call__(self, arg1: _TC, arg2: _UC) -> None: ...


class TriObserver(Protocol[_T, _U, _S]):
def __call__(self, arg1: _T, arg2: _U, arg3: _S) -> None: ...
class TriObserver(Protocol[_TC, _UC, _SC]):
def __call__(self, arg1: _TC, arg2: _UC, arg3: _SC) -> None: ...


class Observable(ABC):
Expand All @@ -32,31 +37,31 @@ def unobserve(self, observer: Observer) -> None:
raise NotImplementedError


class ValueObservable(Generic[_T], ABC):
class ValueObservable(Generic[_S], ABC):
@abstractmethod
def observe(self, observer: Observer | ValueObserver[_T]) -> None:
def observe(self, observer: Observer | ValueObserver[_S]) -> None:
raise NotImplementedError

@abstractmethod
def unobserve(self, observer: Observer | ValueObserver[_T]) -> None:
def unobserve(self, observer: Observer | ValueObserver[_S]) -> None:
raise NotImplementedError


class BiObservable(Generic[_T, _U], ABC):
class BiObservable(Generic[_S, _T], ABC):
@abstractmethod
def observe(self, observer: Observer | ValueObserver[_T] | BiObserver[_T, _U]) -> None:
def observe(self, observer: Observer | ValueObserver[_S] | BiObserver[_S, _T]) -> None:
raise NotImplementedError

@abstractmethod
def unobserve(self, observer: Observer | ValueObserver[_T] | BiObserver[_T, _U]) -> None:
def unobserve(self, observer: Observer | ValueObserver[_S] | BiObserver[_S, _T]) -> None:
raise NotImplementedError


class TriObservable(Generic[_T, _U, _S], ABC):
class TriObservable(Generic[_S, _T, _U], ABC):
@abstractmethod
def observe(self, observer: Observer | ValueObserver[_T] | BiObserver[_T, _U] | TriObserver[_T, _U, _S]) -> None:
def observe(self, observer: Observer | ValueObserver[_S] | BiObserver[_S, _T] | TriObserver[_S, _T, _U]) -> None:
raise NotImplementedError

@abstractmethod
def unobserve(self, observer: Observer | ValueObserver[_T] | BiObserver[_T, _U] | TriObserver[_T, _U, _S]) -> None:
raise NotImplementedError
def unobserve(self, observer: Observer | ValueObserver[_S] | BiObserver[_S, _T] | TriObserver[_S, _T, _U]) -> None:
raise NotImplementedError
Empty file added src/pybind/py.typed
Empty file.
125 changes: 125 additions & 0 deletions tests/test_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import pytest
from unittest.mock import Mock

from pybind.event import Event


class Observer(Mock):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)


class NoParametersObserver(Observer):
def __call__(self):
super().__call__()


class OneParameterObserver(Observer):
def __call__(self, param0):
super().__call__(param0)


class OneDefaultParameterObserver(Observer):
def __call__(self, param0="default"):
super().__call__(param0=param0)


def test_event_initialization():
event = Event()
assert event._observers == []


def test_mock_observe_adds_observer():
event = Event()
observer = NoParametersObserver()
observer.__name__ = "test_observer"

event.observe(observer)

assert observer in event._observers


def test_mock_observe_validates_parameter_count():
event = Event()

with pytest.raises(ValueError):
event.observe(OneParameterObserver())


def test_mock_unobserve_removes_observer():
event = Event()
observer = NoParametersObserver()
event.observe(observer)

event.unobserve(observer)

assert observer not in event._observers


def test_mock_unobserve_nonexistent_observer_raises():
event = Event()

with pytest.raises(ValueError):
event.unobserve(NoParametersObserver())


def test_mock_unobserved_observer_not_called():
event = Event()
observer = NoParametersObserver()
event.observe(observer)
event.unobserve(observer)

event()

observer.assert_not_called()


def test_mock_call_invokes_all_observers():
event = Event()
observer0 = NoParametersObserver()
observer1 = NoParametersObserver()
event.observe(observer0)
event.observe(observer1)

event()

observer0.assert_called_once_with()
observer1.assert_called_once_with()


def test_mock_observer_with_default_parameter():
event = Event()
observer = OneDefaultParameterObserver()

event.observe(observer)
event()

observer.assert_called_once_with(param0="default")


def test_call_with_no_observers():
event = Event()
event()


def test_function_observer_with_default_parameter():
event = Event()

calls = []

def observer_with_default(param="default"):
calls.append(param)

event.observe(observer_with_default)
event()
assert calls == ["default"]


def test_lambda_observer():
event = Event()
calls = []

event.observe(lambda: calls.append(True))
event()

assert calls == [True]