Repository: hikari-py/hikari (python)

Issue:
iscoroutinecheck check is more restrictive than documented types
### Steps to reproduce
https://github.com/hikari-py/hikari/blob/c956f6c70ac9f6b12c34e0ab26d143b42f7c66fe/hikari/impl/event_manager_base.py#L278

This line errors if an object with an async `__call__` is passed or if a normal function which returns a coroutine is passed even though the documented types allow both cases.


Pull request:
Allow class listeners
Closes #780


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ce355fde..7cf52380 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -179,7 +179,7 @@ jobs:
           python-version: 3.9
 
       - name: Setup node
-        uses: actions/setup-node@v3
+        uses: actions/setup-node@v4
 
       - name: Build documentation
         run: |
diff --git a/changes/1661.feature.md b/changes/1661.feature.md
new file mode 100644
index 00000000..793ed543
--- /dev/null
+++ b/changes/1661.feature.md
@@ -0,0 +1,1 @@
+Allow class listeners
diff --git a/dev-requirements/codespell.txt b/dev-requirements/codespell.txt
index ddff4546..e0a43e13 100644
--- a/dev-requirements/codespell.txt
+++ b/dev-requirements/codespell.txt
@@ -1,1 +1,1 @@
-codespell==2.2.5
+codespell==2.2.6
diff --git a/dev-requirements/coverage.txt b/dev-requirements/coverage.txt
index 15c3f67b..d3bf39f7 100644
--- a/dev-requirements/coverage.txt
+++ b/dev-requirements/coverage.txt
@@ -1,1 +1,1 @@
-coverage[toml]==7.3.1
+coverage[toml]==7.3.2
diff --git a/dev-requirements/formatting.txt b/dev-requirements/formatting.txt
index d5af1ce5..996ed1b9 100644
--- a/dev-requirements/formatting.txt
+++ b/dev-requirements/formatting.txt
@@ -1,2 +1,2 @@
-black==23.9.1
+black==23.10.1
 isort==5.12.0
diff --git a/dev-requirements/pyright.txt b/dev-requirements/pyright.txt
index 8059f588..f3a3b12b 100644
--- a/dev-requirements/pyright.txt
+++ b/dev-requirements/pyright.txt
@@ -1,1 +1,1 @@
-pyright==1.1.329
+pyright==1.1.333
diff --git a/dev-requirements/towncrier.txt b/dev-requirements/towncrier.txt
index 98573b5b..65670dc4 100644
--- a/dev-requirements/towncrier.txt
+++ b/dev-requirements/towncrier.txt
@@ -1,1 +1,1 @@
-towncrier==23.6.0
+towncrier==23.10.0
diff --git a/hikari/errors.py b/hikari/errors.py
index e8bfd35b..1bdacaf7 100644
--- a/hikari/errors.py
+++ b/hikari/errors.py
@@ -66,7 +66,7 @@
     from hikari.internal import routes
 
 
-# The standard exceptions are all unsloted so slotting here would be a waste of time.
+# The standard exceptions are all unslotted so slotting here would be a waste of time.
 @attrs_extensions.with_copy
 @attrs.define(auto_exc=True, repr=False, init=False, slots=False)
 class HikariError(RuntimeError):
@@ -79,7 +79,7 @@ class HikariError(RuntimeError):
     """
 
 
-# The standard warnings are all unsloted so slotting here would be a waste of time.
+# The standard warnings are all unslotted so slotting here would be a waste of time.
 @attrs_extensions.with_copy
 @attrs.define(auto_exc=True, repr=False, init=False, slots=False)
 class HikariWarning(RuntimeWarning):
diff --git a/hikari/events/base_events.py b/hikari/events/base_events.py
index 66e71cc3..f26ba0d6 100644
--- a/hikari/events/base_events.py
+++ b/hikari/events/base_events.py
@@ -228,7 +228,7 @@ def shard(self) -> typing.Optional[gateway_shard.GatewayShard]:
     def exc_info(self) -> typing.Tuple[typing.Type[Exception], Exception, typing.Optional[types.TracebackType]]:
         """Exception triplet that follows the same format as `sys.exc_info`.
 
-        The `sys.exc_info` tiplet consists of the exception type, the exception
+        The `sys.exc_info` triplet consists of the exception type, the exception
         instance, and the traceback of the exception.
         """
         return type(self.exception), self.exception, self.exception.__traceback__
diff --git a/hikari/events/scheduled_events.py b/hikari/events/scheduled_events.py
index 97d54aee..19d1136d 100644
--- a/hikari/events/scheduled_events.py
+++ b/hikari/events/scheduled_events.py
@@ -51,7 +51,7 @@
 
 @base_events.requires_intents(intents.Intents.GUILD_SCHEDULED_EVENTS)
 class ScheduledEventEvent(shard_events.ShardEvent, abc.ABC):
-    """Event bassed for any scheduled event related events."""
+    """Event base for any scheduled event related events."""
 
     __slots__: typing.Sequence[str] = ()
 
diff --git a/hikari/impl/event_manager_base.py b/hikari/impl/event_manager_base.py
index 8a4e5f60..f944890d 100644
--- a/hikari/impl/event_manager_base.py
+++ b/hikari/impl/event_manager_base.py
@@ -421,7 +421,9 @@ def consume_raw_event(
     def subscribe(
         self, event_type: typing.Type[typing.Any], callback: event_manager_.CallbackT[typing.Any], *, _nested: int = 0
     ) -> None:
-        if not inspect.iscoroutinefunction(callback):
+        if not (
+            inspect.iscoroutinefunction(callback) or inspect.iscoroutinefunction(getattr(callback, "__call__", None))
+        ):
             raise TypeError("Cannot subscribe a non-coroutine function callback")
 
         # `_nested` is used to show the correct source code snippet if an intent
@@ -620,8 +622,11 @@ async def _invoke_callback(
         try:
             await callback(event)
         except Exception as ex:
-            # Skip the first frame in logs, we don't care for it.
-            trio = type(ex), ex, ex.__traceback__.tb_next if ex.__traceback__ is not None else None
+            # Skip the first frame in logs if it exists, as it means it wasn't our fault
+            trio: typing.Union[
+                typing.Tuple[typing.Type[Exception], Exception, typing.Optional[types.TracebackType]], Exception
+            ]
+            trio = (type(ex), ex, ex.__traceback__.tb_next) if ex.__traceback__ else ex
 
             if base_events.is_no_recursive_throw_event(event):
                 _LOGGER.error(
diff --git a/hikari/internal/enums.py b/hikari/internal/enums.py
index 7394e099..9307e8ef 100644
--- a/hikari/internal/enums.py
+++ b/hikari/internal/enums.py
@@ -565,18 +565,18 @@ class Flag(metaclass=_FlagMeta):
     -----------------------------
     * `e1 & e2` :
         Bitwise `AND` operation. Will return a member that contains all flags
-        that are common between both oprands on the values. This also works with
-        one of the oprands being an `int`eger. You may instead use
+        that are common between both operands on the values. This also works with
+        one of the operands being an `int`eger. You may instead use
         the `intersection` method.
     * `e1 | e2` :
         Bitwise `OR` operation. Will return a member that contains all flags
-        that appear on at least one of the oprands. This also works with
-        one of the oprands being an `int`eger. You may instead use
+        that appear on at least one of the operands. This also works with
+        one of the operands being an `int`eger. You may instead use
         the `union` method.
     * `e1 ^ e2` :
         Bitwise `XOR` operation. Will return a member that contains all flags
-        that only appear on at least one and at most one of the oprands.
-        This also works with one of the oprands being an `int`eger.
+        that only appear on at least one and at most one of the operands.
+        This also works with one of the operands being an `int`eger.
         You may instead use the `symmetric_difference` method.
     * `~e` :
         Return the inverse of this value. This is equivalent to disabling all
diff --git a/hikari/locales.py b/hikari/locales.py
index 988477c6..76d5affa 100644
--- a/hikari/locales.py
+++ b/hikari/locales.py
@@ -77,10 +77,10 @@ class Locale(str, enums.Enum):
     """Polish."""
 
     PT_BR = "pt-BR"
-    """Portuguese, Bralizian."""
+    """Portuguese, Brazilian."""
 
     RO = "ro"
-    """Romian."""
+    """Romanian."""
 
     FI = "fi"
     """Finnish."""
diff --git a/hikari/scheduled_events.py b/hikari/scheduled_events.py
index 5ffb662a..17086143 100644
--- a/hikari/scheduled_events.py
+++ b/hikari/scheduled_events.py
@@ -67,7 +67,7 @@ class ScheduledEventType(int, enums.Enum):
     """A scheduled stage instance."""
 
     VOICE = 2
-    """A scheculed voice chat event."""
+    """A scheduled voice chat event."""
 
     EXTERNAL = 3
     """A scheduled event which takes part outside of Discord."""
@@ -80,7 +80,7 @@ class ScheduledEventStatus(int, enums.Enum):
     """Indicates that the scheduled event hasn't occurred yet."""
 
     ACTIVE = 2
-    """Indicates an eventis on-going."""
+    """Indicates an event is on-going."""
 
     COMPLETED = 3
     """Indicates an event has finished."""


Test patch:
diff --git a/dev-requirements/pytest.txt b/dev-requirements/pytest.txt
index eca23ade..be98f135 100644
--- a/dev-requirements/pytest.txt
+++ b/dev-requirements/pytest.txt
@@ -1,7 +1,7 @@
 # mock in the stdlib changes between versions of Python, so are not consistent in behaviour like the backport is
 mock==5.1.0
 
-pytest==7.4.2
+pytest==7.4.3
 pytest-asyncio==0.21.1
 pytest-cov==4.1.0
 pytest-randomly==3.15.0
diff --git a/tests/hikari/impl/test_event_manager_base.py b/tests/hikari/impl/test_event_manager_base.py
index bd242941..ebba1c2e 100644
--- a/tests/hikari/impl/test_event_manager_base.py
+++ b/tests/hikari/impl/test_event_manager_base.py
@@ -601,6 +601,18 @@ async def test_handle_dispatch_invokes_when_consumer_not_enabled(self, event_man
         consumer.callback.assert_not_called()
         error_handler.assert_not_called()
 
+    def test_subscribe_when_class_call(self, event_manager):
+        class Foo:
+            async def __call__(self) -> None:
+                ...
+
+        foo = Foo()
+        event_manager._check_event = mock.Mock()
+
+        event_manager.subscribe(member_events.MemberCreateEvent, foo)
+
+        assert event_manager._listeners[member_events.MemberCreateEvent] == [foo]
+
     def test_subscribe_when_callback_is_not_coroutine(self, event_manager):
         def test():
             ...