New Upstream Release - blinker

Ready changes

Summary

Merged new upstream version: 1.6.2 (was: 1.5).

Diff

diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000..c7af9ee
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,24 @@
+[flake8]
+extend-select =
+    # bugbear
+    B
+    # bugbear opinions
+    B9
+    # implicit str concat
+    ISC
+extend-ignore =
+    # slice notation whitespace, invalid
+    E203
+    # import at top, too many circular import fixes
+    E402
+    # line length, handled by bugbear B950
+    E501
+    # bare except, handled by bugbear B001
+    E722
+    # zip with strict=, requires python >= 3.10
+    B905
+    # string formatting opinion, B028 renamed to B907
+    B028
+    B907
+# up to 88 allowed by bugbear B950
+max-line-length = 80
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index 521431d..9141cd7 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -3,9 +3,19 @@ on:
   push:
     branches:
       - main
+      - '*.x'
+    paths-ignore:
+      - 'docs/**'
+      - '*.md'
+      - '*.rst'
   pull_request:
     branches:
       - main
+      - '*.x'
+    paths-ignore:
+      - 'docs/**'
+      - '*.md'
+      - '*.rst'
 jobs:
   tests:
     name: ${{ matrix.name }}
@@ -14,23 +24,27 @@ jobs:
       fail-fast: false
       matrix:
         include:
-          - {name: '3.11', python: '3.11-dev', tox: py311}
-          - {name: '3.10', python: '3.10', tox: py310}
-          - {name: '3.9', python: '3.9', tox: py39}
-          - {name: '3.8', python: '3.8', tox: py38}
-          - {name: '3.7', python: '3.7', tox: py37}
-          - {name: 'PyPy', python: 'pypy-3.9', tox: pypy39}
+          - {name: Linux, python: '3.11', os: ubuntu-latest, tox: py311}
+          - {name: Windows, python: '3.11', os: windows-latest, tox: py311}
+          - {name: Mac, python: '3.11', os: macos-latest, tox: py311}
+          - {name: '3.12-dev', python: '3.12-dev', os: ubuntu-latest, tox: py312}
+          - {name: '3.10', python: '3.10', os: ubuntu-latest, tox: py310}
+          - {name: '3.9', python: '3.9', os: ubuntu-latest, tox: py39}
+          - {name: '3.8', python: '3.8', os: ubuntu-latest, tox: py38}
+          - {name: '3.7', python: '3.7', os: ubuntu-latest, tox: py37}
+          - {name: 'PyPy', python: 'pypy-3.9', os: ubuntu-latest, tox: pypy39}
+          - {name: Typing, python: '3.11', os: ubuntu-latest, tox: typing}
     steps:
       - uses: actions/checkout@v3
       - uses: actions/setup-python@v4
         with:
           python-version: ${{ matrix.python }}
           cache: 'pip'
-          cache-dependency-path: 'tests/requirements.txt'
+          cache-dependency-path: 'requirements/*.txt'
       - name: update pip
         run: |
           pip install -U wheel
           pip install -U setuptools
           python -m pip install -U pip
       - run: pip install tox
-      - run: tox -e ${{ matrix.tox }} -s false
+      - run: tox run -e ${{ matrix.tox }}
diff --git a/.gitignore b/.gitignore
index 1ca9c16..7e222fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,9 @@ __pycache__/
 *.pyc
 /.pytest_cache/
 /.tox/
-/*.egg-info/
+**.egg-info/
 /dist/
 /docs/_build/
+.python-version
+# prefix for devs private stuff kept with the project
+private_*
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..5391626
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,49 @@
+ci:
+  autoupdate_schedule: monthly
+repos:
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v4.3.0
+    hooks:
+      - id: fix-byte-order-marker
+      - id: trailing-whitespace
+      - id: end-of-file-fixer
+
+  - repo: https://github.com/psf/black
+    rev: 22.12.0
+    hooks:
+      - id: black
+        args: ["--target-version", "py37"]
+
+  - repo: https://github.com/PyCQA/flake8
+    rev: 6.0.0
+    hooks:
+      - id: flake8
+        additional_dependencies:
+          - flake8-bugbear
+          - flake8-implicit-str-concat
+
+  - repo: https://github.com/asottile/pyupgrade
+    rev: v3.3.1
+    hooks:
+      - id: pyupgrade
+        args: ["--py37-plus"]
+
+  - repo: https://github.com/peterdemin/pip-compile-multi
+    rev: v2.6.1
+    hooks:
+      - id: pip-compile-multi-verify
+
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v4.4.0
+    hooks:
+      - id: fix-byte-order-marker
+      - id: trailing-whitespace
+      - id: end-of-file-fixer
+        exclude: "^tests/.*.http$"
+
+  - repo: https://github.com/asottile/reorder_python_imports
+    rev: v3.9.0
+    hooks:
+      - id: reorder-python-imports
+        name: Reorder Python imports (src, tests)
+        args: ["--application-directories", ".:src"]
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index db1749d..20ef9d8 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -5,9 +5,10 @@ build:
     python: "3.10"
 python:
   install:
-    - requirements: docs/requirements.txt
+    - requirements: requirements/docs.txt
     - method: pip
       path: .
+
 sphinx:
   builder: dirhtml
   fail_on_warning: true
diff --git a/CHANGES.rst b/CHANGES.rst
index d278ca0..463736a 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,3 +1,35 @@
+Version 1.6.2
+-------------
+
+Released 2023-04-12
+
+-   Type annotations are not evaluated at runtime. typing-extensions is not a runtime
+    dependency. :pr:`94`
+
+Version 1.6.1
+-------------
+
+Released 2023-04-09
+
+-   Ensure that py.typed is present in the distributions (to enable other
+    projects to use blinker's typing).
+-   Require typing-extensions > 4.2 to ensure it includes
+    ParamSpec. :issue:`90`
+
+Version 1.6
+-----------
+
+Released 2023-04-02
+
+-   Add a muted context manager to temporarily turn off a
+    signal. :pr:`84`
+-   Allow int senders (alongside existing string senders). :pr:`83`
+-   Add a send_async method to the Signal to allow signals to send to
+    coroutine receivers. :pr:`76`
+-   Update and modernise the project structure to match that used by the
+    pallets projects. :pr:`77`
+-   Add an intial set of type hints for the project.
+
 Version 1.5
 -----------
 
diff --git a/MANIFEST.in b/MANIFEST.in
index cab5663..41180b4 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,5 +1,6 @@
 include CHANGES.rst
 include tox.ini
+include src/blinker/py.typed
 graft docs
 prune docs/_build
 graft tests
diff --git a/README.rst b/README.rst
index 883dded..e3bc6d4 100644
--- a/README.rst
+++ b/README.rst
@@ -12,12 +12,12 @@ sent by any sender.
     >>> from blinker import signal
     >>> started = signal('round-started')
     >>> def each(round):
-    ...     print "Round %s!" % round
+    ...     print(f"Round {round}")
     ...
     >>> started.connect(each)
 
     >>> def round_two(round):
-    ...     print "This is round two."
+    ...     print("This is round two.")
     ...
     >>> started.connect(round_two, sender=2)
 
diff --git a/blinker/__init__.py b/blinker/__init__.py
deleted file mode 100644
index bed55ca..0000000
--- a/blinker/__init__.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from blinker.base import (
-    ANY,
-    NamedSignal,
-    Namespace,
-    Signal,
-    WeakNamespace,
-    receiver_connected,
-    signal,
-)
-
-__all__ = [
-    'ANY',
-    'NamedSignal',
-    'Namespace',
-    'Signal',
-    'WeakNamespace',
-    'receiver_connected',
-    'signal',
-    ]
-
-
-__version__ = '1.5'
diff --git a/blinker/_utilities.py b/blinker/_utilities.py
deleted file mode 100644
index 133c57a..0000000
--- a/blinker/_utilities.py
+++ /dev/null
@@ -1,153 +0,0 @@
-from weakref import ref
-
-from blinker._saferef import BoundMethodWeakref
-
-
-try:
-    callable
-except NameError:
-    def callable(object):
-        return hasattr(object, '__call__')
-
-
-try:
-    from collections import defaultdict
-except:
-    class defaultdict(dict):
-
-        def __init__(self, default_factory=None, *a, **kw):
-            if (default_factory is not None and
-                not hasattr(default_factory, '__call__')):
-                raise TypeError('first argument must be callable')
-            dict.__init__(self, *a, **kw)
-            self.default_factory = default_factory
-
-        def __getitem__(self, key):
-            try:
-                return dict.__getitem__(self, key)
-            except KeyError:
-                return self.__missing__(key)
-
-        def __missing__(self, key):
-            if self.default_factory is None:
-                raise KeyError(key)
-            self[key] = value = self.default_factory()
-            return value
-
-        def __reduce__(self):
-            if self.default_factory is None:
-                args = ()
-            else:
-                args = self.default_factory,
-            return type(self), args, None, None, self.items()
-
-        def copy(self):
-            return self.__copy__()
-
-        def __copy__(self):
-            return type(self)(self.default_factory, self)
-
-        def __deepcopy__(self, memo):
-            import copy
-            return type(self)(self.default_factory,
-                              copy.deepcopy(self.items()))
-
-        def __repr__(self):
-            return 'defaultdict({}, {})'.format(self.default_factory,
-                                            dict.__repr__(self))
-
-
-class _symbol(object):
-
-    def __init__(self, name):
-        """Construct a new named symbol."""
-        self.__name__ = self.name = name
-
-    def __reduce__(self):
-        return symbol, (self.name,)
-
-    def __repr__(self):
-        return self.name
-_symbol.__name__ = 'symbol'
-
-
-class symbol(object):
-    """A constant symbol.
-
-    >>> symbol('foo') is symbol('foo')
-    True
-    >>> symbol('foo')
-    foo
-
-    A slight refinement of the MAGICCOOKIE=object() pattern.  The primary
-    advantage of symbol() is its repr().  They are also singletons.
-
-    Repeated calls of symbol('name') will all return the same instance.
-
-    """
-    symbols = {}
-
-    def __new__(cls, name):
-        try:
-            return cls.symbols[name]
-        except KeyError:
-            return cls.symbols.setdefault(name, _symbol(name))
-
-
-try:
-    text = (str, unicode)
-except NameError:
-    text = str
-
-
-def hashable_identity(obj):
-    if hasattr(obj, '__func__'):
-        return (id(obj.__func__), id(obj.__self__))
-    elif hasattr(obj, 'im_func'):
-        return (id(obj.im_func), id(obj.im_self))
-    elif isinstance(obj, text):
-        return obj
-    else:
-        return id(obj)
-
-
-WeakTypes = (ref, BoundMethodWeakref)
-
-
-class annotatable_weakref(ref):
-    """A weakref.ref that supports custom instance attributes."""
-
-
-def reference(object, callback=None, **annotations):
-    """Return an annotated weak ref."""
-    if callable(object):
-        weak = callable_reference(object, callback)
-    else:
-        weak = annotatable_weakref(object, callback)
-    for key, value in annotations.items():
-        setattr(weak, key, value)
-    return weak
-
-
-def callable_reference(object, callback=None):
-    """Return an annotated weak ref, supporting bound instance methods."""
-    if hasattr(object, 'im_self') and object.im_self is not None:
-        return BoundMethodWeakref(target=object, on_delete=callback)
-    elif hasattr(object, '__self__') and object.__self__ is not None:
-        return BoundMethodWeakref(target=object, on_delete=callback)
-    return annotatable_weakref(object, callback)
-
-
-class lazy_property(object):
-    """A @property that is only evaluated once."""
-
-    def __init__(self, deferred):
-        self._deferred = deferred
-        self.__doc__ = deferred.__doc__
-
-    def __get__(self, obj, cls):
-        if obj is None:
-            return self
-        value = self._deferred(obj)
-        setattr(obj, self._deferred.__name__, value)
-        return value
diff --git a/debian/changelog b/debian/changelog
index 7b2966e..3ee142a 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+blinker (1.6.2-1) UNRELEASED; urgency=low
+
+  * New upstream release.
+
+ -- Debian Janitor <janitor@jelmer.uk>  Wed, 07 Jun 2023 14:09:39 -0000
+
 blinker (1.5-1) unstable; urgency=medium
 
   * Team upload.
diff --git a/docs/conf.py b/docs/conf.py
index 5bbd200..3f09b72 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -8,10 +8,12 @@ release, version = get_version("blinker", placeholder=None)
 extensions = [
     "sphinx.ext.autodoc",
     "pallets_sphinx_themes",
+    "sphinx_issues",
 ]
 
 autoclass_content = "both"
 autodoc_member_order = "groupwise"
+issues_github_path = "pallets-eco/blinker"
 
 html_theme = "flask"
 html_theme_options = {"index_sidebar_logo": False}
diff --git a/docs/index.rst b/docs/index.rst
index 2853115..c2f111a 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -21,8 +21,7 @@ The core of Blinker is quite small but provides powerful features:
  - thread safety
 
 Blinker was written by Jason Kirtand and is provided under the MIT
-License. The library supports Python 2.7 and Python 3.5 or later;
-or Jython 2.7 or later; or PyPy 2.7 or later.
+License. The library supports Python 3.7 or later; or PyPy3.9 or later.
 
 
 Decoupling With Named Signals
@@ -53,7 +52,7 @@ object that caused the signal to be emitted.
 .. code-block:: python
 
   >>> def subscriber(sender):
-  ...     print("Got a signal sent by %r" % sender)
+  ...     print(f"Got a signal sent by {sender!r}")
   ...
   >>> ready = signal('ready')
   >>> ready.connect(subscriber)
@@ -85,7 +84,7 @@ that particular instance was responsible for emitting the signal.
   ...        complete.send(self)
   ...
   ...    def __repr__(self):
-  ...        return '<Processor %s>' % self.name
+  ...        return f'<Processor {self.name}>'
   ...
   >>> processor_a = Processor('a')
   >>> processor_a.go()
@@ -142,7 +141,7 @@ These will in turn be passed to the connected functions:
   >>> send_data = signal('send-data')
   >>> @send_data.connect
   ... def receive_data(sender, **kw):
-  ...     print("Caught signal from %r, data %r" % (sender, kw))
+  ...     print(f"Caught signal from {sender!r}, data {kw!r}")
   ...     return 'received!'
   ...
   >>> result = send_data.send('anonymous', abc=123)
@@ -158,6 +157,19 @@ value``) pairs:
   [(<function receive_data at 0x...>, 'received!')]
 
 
+Muting signals
+--------------
+
+To mute a signal, as may be required when testing, the
+:meth:`~Signal.muted` can be used as a context decorator:
+
+.. code-block:: python
+
+  sig = signal('send-data')
+  with sig.muted():
+       ...
+
+
 Anonymous Signals
 -----------------
 
@@ -182,7 +194,7 @@ processing signals as class attributes:
   ...        self.on_complete.send(self)
   ...
   ...    def __repr__(self):
-  ...        return '<AltProcessor %s>' % self.name
+  ...        return f'<AltProcessor {self.name}>'
   ...
 
 ``connect`` as a Decorator
@@ -197,7 +209,7 @@ be used as a decorator on functions:
   >>> apc = AltProcessor('c')
   >>> @apc.on_complete.connect
   ... def completed(sender):
-  ...     print "AltProcessor %s completed!" % sender.name
+  ...     print f"AltProcessor {sender.name} completed!"
   ...
   >>> apc.go()
   Alternate processing.
@@ -214,7 +226,7 @@ function.  For this, :meth:`~Signal.connect_via` can be used:
   ... @dice_roll.connect_via(3)
   ... @dice_roll.connect_via(5)
   ... def odd_subscriber(sender):
-  ...     print("Observed dice roll %r." % sender)
+  ...     print(f"Observed dice roll {sender!r}.")
   ...
   >>> result = dice_roll.send(3)
   Observed dice roll 3.
@@ -260,6 +272,69 @@ See the documentation of the :obj:`receiver_connected` built-in signal
 for an example.
 
 
+Async receivers
+---------------
+
+Receivers can be coroutine functions which can be called and awaited
+via the :meth:`~Signal.send_async` method:
+
+.. code-block:: python
+
+   sig = blinker.Signal()
+
+   async def receiver():
+       ...
+
+   sig.connect(receiver)
+   await sig.send_async()
+
+This however requires that all receivers are awaitable which then
+precludes the usage of :meth:`~Signal.send`. To mix and match the
+:meth:`~Signal.send_async` method takes a ``_sync_wrapper`` argument
+such as:
+
+.. code-block:: python
+
+   sig = blinker.Signal()
+
+   def receiver():
+       ...
+
+   sig.connect(receiver)
+
+   def wrapper(func):
+
+       async def inner(*args, **kwargs):
+           func(*args, **kwargs)
+
+       return inner
+
+   await sig.send_async(_sync_wrapper=wrapper)
+
+The equivalent usage for :meth:`~Signal.send` is via the
+``_async_wrapper`` argument. This usage is will depend on your event
+loop, and in the simple case whereby you aren't running within an
+event loop the following example can be used:
+
+.. code-block:: python
+
+   sig = blinker.Signal()
+
+   async def receiver():
+       ...
+
+   sig.connect(receiver)
+
+   def wrapper(func):
+
+       def inner(*args, **kwargs):
+           asyncio.run(func(*args, **kwargs))
+
+       return inner
+
+   await sig.send(_async_wrapper=wrapper)
+
+
 API Documentation
 -----------------
 
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..c58c26d
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,69 @@
+[build-system]
+requires = ["setuptools>=61.2"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "blinker"
+license = {text = "MIT License"}
+authors = [{name = "Jason Kirtland", email = "jek@discorporate.us"}]
+maintainers = [{name = "Pallets Ecosystem", email = "contact@palletsprojects.com"}]
+description = "Fast, simple object-to-object and broadcast signaling"
+keywords = [
+    "signal",
+    "emit",
+    "events",
+    "broadcast",
+]
+classifiers = [
+    "Development Status :: 5 - Production/Stable",
+    "Intended Audience :: Developers",
+    "License :: OSI Approved :: MIT License",
+    "Operating System :: OS Independent",
+    "Programming Language :: Python",
+    "Topic :: Software Development :: Libraries",
+]
+requires-python = ">= 3.7"
+dynamic = ["version"]
+
+[project.urls]
+Homepage = "https://blinker.readthedocs.io"
+Documentation = "https://blinker.readthedocs.io"
+"Source Code" = "https://github.com/pallets-eco/blinker/"
+"Issue Tracker" = "https://github.com/pallets-eco/blinker/issues/"
+Chat = "https://discord.gg/pallets"
+
+[project.readme]
+file = "README.rst"
+content-type = "text/x-rst"
+
+[tool.mypy]
+python_version = "3.7"
+files = ["src/blinker"]
+show_error_codes = true
+pretty = true
+#strict = true
+allow_redefinition = true
+disallow_subclassing_any = true
+#disallow_untyped_calls = true
+#disallow_untyped_defs = true
+disallow_incomplete_defs = true
+no_implicit_optional = true
+local_partial_types = true
+no_implicit_reexport = true
+strict_equality = true
+warn_redundant_casts = true
+warn_unused_configs = true
+warn_unused_ignores = true
+warn_return_any = true
+#warn_unreachable = True
+
+[tool.setuptools]
+license-files = ["LICENSE.rst"]
+
+[tool.setuptools.dynamic]
+version = {attr = "blinker.__version__"}
+
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+testpaths = ["tests"]
+filterwarnings = ["error"]
diff --git a/requirements/dev.in b/requirements/dev.in
new file mode 100644
index 0000000..95fefe0
--- /dev/null
+++ b/requirements/dev.in
@@ -0,0 +1,2 @@
+tox
+pre-commit
diff --git a/requirements/dev.txt b/requirements/dev.txt
new file mode 100644
index 0000000..1e67c09
--- /dev/null
+++ b/requirements/dev.txt
@@ -0,0 +1,67 @@
+# SHA1:6af58729dc01f2483688d6bb82d851a0975313fb
+#
+# This file is autogenerated by pip-compile-multi
+# To update, run:
+#
+#    pip-compile-multi
+#
+cachetools==5.2.1
+    # via tox
+cfgv==3.3.1
+    # via pre-commit
+chardet==5.1.0
+    # via tox
+colorama==0.4.6
+    # via tox
+distlib==0.3.6
+    # via virtualenv
+filelock==3.9.0
+    # via
+    #   tox
+    #   virtualenv
+identify==2.5.13
+    # via pre-commit
+importlib-metadata==6.0.0
+    # via
+    #   pluggy
+    #   pre-commit
+    #   tox
+    #   virtualenv
+nodeenv==1.7.0
+    # via pre-commit
+packaging==23.0
+    # via
+    #   pyproject-api
+    #   tox
+platformdirs==2.6.2
+    # via
+    #   tox
+    #   virtualenv
+pluggy==1.0.0
+    # via tox
+pre-commit==2.21.0
+    # via -r requirements/dev.in
+pyproject-api==1.5.0
+    # via tox
+pyyaml==6.0
+    # via pre-commit
+tomli==2.0.1
+    # via
+    #   pyproject-api
+    #   tox
+tox==4.3.5
+    # via -r requirements/dev.in
+typing-extensions==4.4.0
+    # via
+    #   importlib-metadata
+    #   platformdirs
+    #   tox
+virtualenv==20.17.1
+    # via
+    #   pre-commit
+    #   tox
+zipp==3.11.0
+    # via importlib-metadata
+
+# The following packages are considered to be unsafe in a requirements file:
+# setuptools
diff --git a/docs/requirements.in b/requirements/docs.in
similarity index 67%
rename from docs/requirements.in
rename to requirements/docs.in
index 6bb1491..0ace8b2 100644
--- a/docs/requirements.in
+++ b/requirements/docs.in
@@ -1,2 +1,3 @@
-Sphinx
 Pallets-Sphinx-Themes
+Sphinx
+sphinx-issues
diff --git a/docs/requirements.txt b/requirements/docs.txt
similarity index 52%
rename from docs/requirements.txt
rename to requirements/docs.txt
index ab73fb8..e038098 100644
--- a/docs/requirements.txt
+++ b/requirements/docs.txt
@@ -1,52 +1,54 @@
+# SHA1:443ee977856867aeaba570acaa0e03086aca933c
 #
-# This file is autogenerated by pip-compile with python 3.10
+# This file is autogenerated by pip-compile-multi
 # To update, run:
 #
-#    pip-compile docs/requirements.in
+#    pip-compile-multi
 #
-alabaster==0.7.12
+alabaster==0.7.13
     # via sphinx
-babel==2.10.3
+babel==2.11.0
     # via sphinx
-certifi==2022.6.15
+certifi==2022.12.7
     # via requests
-charset-normalizer==2.1.0
+charset-normalizer==3.0.1
     # via requests
-docutils==0.18.1
+docutils==0.19
     # via sphinx
-idna==3.3
+idna==3.4
     # via requests
 imagesize==1.4.1
     # via sphinx
 jinja2==3.1.2
     # via sphinx
-markupsafe==2.1.1
+markupsafe==2.1.2
     # via jinja2
-packaging==21.3
+packaging==23.0
     # via
     #   pallets-sphinx-themes
     #   sphinx
-pallets-sphinx-themes==2.0.2
-    # via -r docs/requirements.in
-pygments==2.12.0
+pallets-sphinx-themes==2.0.3
+    # via -r requirements/docs.in
+pygments==2.14.0
     # via sphinx
-pyparsing==3.0.9
-    # via packaging
-pytz==2022.1
+pytz==2022.7.1
     # via babel
-requests==2.28.1
+requests==2.28.2
     # via sphinx
 snowballstemmer==2.2.0
     # via sphinx
-sphinx==5.0.2
+sphinx==6.1.3
     # via
-    #   -r docs/requirements.in
+    #   -r requirements/docs.in
     #   pallets-sphinx-themes
-sphinxcontrib-applehelp==1.0.2
+    #   sphinx-issues
+sphinx-issues==3.0.1
+    # via -r requirements/docs.in
+sphinxcontrib-applehelp==1.0.4
     # via sphinx
 sphinxcontrib-devhelp==1.0.2
     # via sphinx
-sphinxcontrib-htmlhelp==2.0.0
+sphinxcontrib-htmlhelp==2.0.1
     # via sphinx
 sphinxcontrib-jsmath==1.0.1
     # via sphinx
@@ -54,5 +56,5 @@ sphinxcontrib-qthelp==1.0.3
     # via sphinx
 sphinxcontrib-serializinghtml==1.1.5
     # via sphinx
-urllib3==1.26.10
+urllib3==1.26.14
     # via requests
diff --git a/requirements/tests.in b/requirements/tests.in
new file mode 100644
index 0000000..ee4ba01
--- /dev/null
+++ b/requirements/tests.in
@@ -0,0 +1,2 @@
+pytest
+pytest-asyncio
diff --git a/requirements/tests.txt b/requirements/tests.txt
new file mode 100644
index 0000000..edcf4fb
--- /dev/null
+++ b/requirements/tests.txt
@@ -0,0 +1,35 @@
+# SHA1:738f1ea95febe383951f6eb64bdad13fefc1a97a
+#
+# This file is autogenerated by pip-compile-multi
+# To update, run:
+#
+#    pip-compile-multi
+#
+attrs==22.2.0
+    # via pytest
+exceptiongroup==1.1.0
+    # via pytest
+importlib-metadata==6.0.0
+    # via
+    #   pluggy
+    #   pytest
+iniconfig==2.0.0
+    # via pytest
+packaging==23.0
+    # via pytest
+pluggy==1.0.0
+    # via pytest
+pytest==7.2.1
+    # via
+    #   -r requirements/tests.in
+    #   pytest-asyncio
+pytest-asyncio==0.20.3
+    # via -r requirements/tests.in
+tomli==2.0.1
+    # via pytest
+typing-extensions==4.4.0
+    # via
+    #   importlib-metadata
+    #   pytest-asyncio
+zipp==3.11.0
+    # via importlib-metadata
diff --git a/requirements/typing.in b/requirements/typing.in
new file mode 100644
index 0000000..f0aa93a
--- /dev/null
+++ b/requirements/typing.in
@@ -0,0 +1 @@
+mypy
diff --git a/requirements/typing.txt b/requirements/typing.txt
new file mode 100644
index 0000000..074101b
--- /dev/null
+++ b/requirements/typing.txt
@@ -0,0 +1,13 @@
+# SHA1:7983aaa01d64547827c20395d77e248c41b2572f
+#
+# This file is autogenerated by pip-compile-multi
+# To update, run:
+#
+#    pip-compile-multi
+#
+mypy==1.0.1
+    # via -r requirements/typing.in
+mypy-extensions==1.0.0
+    # via mypy
+typing-extensions==4.5.0
+    # via mypy
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 01aaea5..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,7 +0,0 @@
-[bdist_wheel]
-universal = 1
-
-[tool:pytest]
-testpaths = tests
-filterwarnings =
-    error
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 462ccd3..0000000
--- a/setup.py
+++ /dev/null
@@ -1,35 +0,0 @@
-try:
-    from setuptools import setup
-except ImportError:
-    from distutils.core import setup
-
-readme = open('README.rst').read()
-import blinker
-version = blinker.__version__
-
-setup(name="blinker",
-      version=version,
-      packages=['blinker'],
-      author='Jason Kirtland',
-      author_email='jek@discorporate.us',
-      maintainer="Pallets Ecosystem",
-      maintainer_email="contact@palletsprojects.com",
-      description='Fast, simple object-to-object and broadcast signaling',
-      keywords='signal emit events broadcast',
-      long_description=readme,
-      long_description_content_type="text/x-rst",
-      license='MIT License',
-      url='https://blinker.readthedocs.io',
-      project_urls={
-        'Source': 'https://github.com/pallets-eco/blinker',
-      },
-      python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*',
-      classifiers=[
-          'Development Status :: 5 - Production/Stable',
-          'Intended Audience :: Developers',
-          'License :: OSI Approved :: MIT License',
-          'Operating System :: OS Independent',
-          'Programming Language :: Python',
-          'Topic :: Software Development :: Libraries',
-          ],
-)
diff --git a/src/blinker/__init__.py b/src/blinker/__init__.py
new file mode 100644
index 0000000..71d66d3
--- /dev/null
+++ b/src/blinker/__init__.py
@@ -0,0 +1,19 @@
+from blinker.base import ANY
+from blinker.base import NamedSignal
+from blinker.base import Namespace
+from blinker.base import receiver_connected
+from blinker.base import Signal
+from blinker.base import signal
+from blinker.base import WeakNamespace
+
+__all__ = [
+    "ANY",
+    "NamedSignal",
+    "Namespace",
+    "Signal",
+    "WeakNamespace",
+    "receiver_connected",
+    "signal",
+]
+
+__version__ = "1.6.2"
diff --git a/blinker/_saferef.py b/src/blinker/_saferef.py
similarity index 88%
rename from blinker/_saferef.py
rename to src/blinker/_saferef.py
index 081173d..dcb70c1 100644
--- a/blinker/_saferef.py
+++ b/src/blinker/_saferef.py
@@ -33,26 +33,14 @@
 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 #
 """Refactored 'safe reference from dispatcher.py"""
-
 import operator
 import sys
 import traceback
 import weakref
 
 
-try:
-    callable
-except NameError:
-    def callable(object):
-        return hasattr(object, '__call__')
-
-
-if sys.version_info < (3,):
-    get_self = operator.attrgetter('im_self')
-    get_func = operator.attrgetter('im_func')
-else:
-    get_self = operator.attrgetter('__self__')
-    get_func = operator.attrgetter('__func__')
+get_self = operator.attrgetter("__self__")
+get_func = operator.attrgetter("__func__")
 
 
 def safe_ref(target, on_delete=None):
@@ -78,14 +66,15 @@ def safe_ref(target, on_delete=None):
         if im_self is not None:
             # Turn a bound method into a BoundMethodWeakref instance.
             # Keep track of these instances for lookup by disconnect().
-            assert hasattr(target, 'im_func') or hasattr(target, '__func__'), (
-                "safe_ref target %r has im_self, but no im_func, "
-                "don't know how to create reference" % target)
+            assert hasattr(target, "im_func") or hasattr(target, "__func__"), (
+                f"safe_ref target {target!r} has im_self, but no im_func, "
+                "don't know how to create reference"
+            )
             reference = BoundMethodWeakref(target=target, on_delete=on_delete)
             return reference
 
 
-class BoundMethodWeakref(object):
+class BoundMethodWeakref:
     """'Safe' and reusable weak references to instance methods.
 
     BoundMethodWeakref objects provide a mechanism for referencing a
@@ -119,13 +108,13 @@ class BoundMethodWeakref(object):
       produce the same BoundMethodWeakref instance.
     """
 
-    _all_instances = weakref.WeakValueDictionary()
+    _all_instances = weakref.WeakValueDictionary()  # type: ignore[var-annotated]
 
     def __new__(cls, target, on_delete=None, *arguments, **named):
         """Create new instance or return current instance.
 
         Basically this method of construction allows us to
-        short-circuit creation of references to already- referenced
+        short-circuit creation of references to already-referenced
         instance methods.  The key corresponding to the target is
         calculated, and if there is already an existing reference,
         that is returned, with its deletion_methods attribute updated.
@@ -138,7 +127,7 @@ class BoundMethodWeakref(object):
             current.deletion_methods.append(on_delete)
             return current
         else:
-            base = super(BoundMethodWeakref, cls).__new__(cls)
+            base = super().__new__(cls)
             cls._all_instances[key] = base
             base.__init__(target, on_delete, *arguments, **named)
             return base
@@ -159,6 +148,7 @@ class BoundMethodWeakref(object):
           single argument, which will be passed a pointer to this
           object.
         """
+
         def remove(weak, self=self):
             """Set self.isDead to True when method or instance is destroyed."""
             methods = self.deletion_methods[:]
@@ -176,8 +166,11 @@ class BoundMethodWeakref(object):
                         traceback.print_exc()
                     except AttributeError:
                         e = sys.exc_info()[1]
-                        print ('Exception during saferef %s '
-                               'cleanup function %s: %s' % (self, function, e))
+                        print(
+                            f"Exception during saferef {self} "
+                            f"cleanup function {function}: {e}"
+                        )
+
         self.deletion_methods = [on_delete]
         self.key = self.calculate_key(target)
         im_self = get_self(target)
@@ -187,6 +180,7 @@ class BoundMethodWeakref(object):
         self.self_name = str(im_self)
         self.func_name = str(im_func.__name__)
 
+    @classmethod
     def calculate_key(cls, target):
         """Calculate the reference key for this reference.
 
@@ -194,7 +188,6 @@ class BoundMethodWeakref(object):
         object and the target function respectively.
         """
         return (id(get_self(target)), id(get_func(target)))
-    calculate_key = classmethod(calculate_key)
 
     def __str__(self):
         """Give a friendly representation of the object."""
@@ -202,19 +195,22 @@ class BoundMethodWeakref(object):
             self.__class__.__name__,
             self.self_name,
             self.func_name,
-            )
+        )
 
     __repr__ = __str__
 
+    def __hash__(self):
+        return hash((self.self_name, self.key))
+
     def __nonzero__(self):
         """Whether we are still a valid reference."""
         return self() is not None
 
-    def __cmp__(self, other):
+    def __eq__(self, other):
         """Compare with another reference."""
         if not isinstance(other, self.__class__):
-            return cmp(self.__class__, type(other))
-        return cmp(self.key, other.key)
+            return operator.eq(self.__class__, type(other))
+        return operator.eq(self.key, other.key)
 
     def __call__(self):
         """Return a strong reference to the bound method.
diff --git a/src/blinker/_utilities.py b/src/blinker/_utilities.py
new file mode 100644
index 0000000..068d94c
--- /dev/null
+++ b/src/blinker/_utilities.py
@@ -0,0 +1,142 @@
+from __future__ import annotations
+
+import asyncio
+import inspect
+import sys
+import typing as t
+from functools import partial
+from weakref import ref
+
+from blinker._saferef import BoundMethodWeakref
+
+IdentityType = t.Union[t.Tuple[int, int], str, int]
+
+
+class _symbol:
+    def __init__(self, name):
+        """Construct a new named symbol."""
+        self.__name__ = self.name = name
+
+    def __reduce__(self):
+        return symbol, (self.name,)
+
+    def __repr__(self):
+        return self.name
+
+
+_symbol.__name__ = "symbol"
+
+
+class symbol:
+    """A constant symbol.
+
+    >>> symbol('foo') is symbol('foo')
+    True
+    >>> symbol('foo')
+    foo
+
+    A slight refinement of the MAGICCOOKIE=object() pattern.  The primary
+    advantage of symbol() is its repr().  They are also singletons.
+
+    Repeated calls of symbol('name') will all return the same instance.
+
+    """
+
+    symbols = {}  # type: ignore[var-annotated]
+
+    def __new__(cls, name):
+        try:
+            return cls.symbols[name]
+        except KeyError:
+            return cls.symbols.setdefault(name, _symbol(name))
+
+
+def hashable_identity(obj: object) -> IdentityType:
+    if hasattr(obj, "__func__"):
+        return (id(obj.__func__), id(obj.__self__))  # type: ignore[attr-defined]
+    elif hasattr(obj, "im_func"):
+        return (id(obj.im_func), id(obj.im_self))  # type: ignore[attr-defined]
+    elif isinstance(obj, (int, str)):
+        return obj
+    else:
+        return id(obj)
+
+
+WeakTypes = (ref, BoundMethodWeakref)
+
+
+class annotatable_weakref(ref):
+    """A weakref.ref that supports custom instance attributes."""
+
+    receiver_id: t.Optional[IdentityType]
+    sender_id: t.Optional[IdentityType]
+
+
+def reference(  # type: ignore[no-untyped-def]
+    object, callback=None, **annotations
+) -> annotatable_weakref:
+    """Return an annotated weak ref."""
+    if callable(object):
+        weak = callable_reference(object, callback)
+    else:
+        weak = annotatable_weakref(object, callback)
+    for key, value in annotations.items():
+        setattr(weak, key, value)
+    return weak  # type: ignore[no-any-return]
+
+
+def callable_reference(object, callback=None):
+    """Return an annotated weak ref, supporting bound instance methods."""
+    if hasattr(object, "im_self") and object.im_self is not None:
+        return BoundMethodWeakref(target=object, on_delete=callback)
+    elif hasattr(object, "__self__") and object.__self__ is not None:
+        return BoundMethodWeakref(target=object, on_delete=callback)
+    return annotatable_weakref(object, callback)
+
+
+class lazy_property:
+    """A @property that is only evaluated once."""
+
+    def __init__(self, deferred):
+        self._deferred = deferred
+        self.__doc__ = deferred.__doc__
+
+    def __get__(self, obj, cls):
+        if obj is None:
+            return self
+        value = self._deferred(obj)
+        setattr(obj, self._deferred.__name__, value)
+        return value
+
+
+def is_coroutine_function(func: t.Any) -> bool:
+    # Python < 3.8 does not correctly determine partially wrapped
+    # coroutine functions are coroutine functions, hence the need for
+    # this to exist. Code taken from CPython.
+    if sys.version_info >= (3, 8):
+        return asyncio.iscoroutinefunction(func)
+    else:
+        # Note that there is something special about the AsyncMock
+        # such that it isn't determined as a coroutine function
+        # without an explicit check.
+        try:
+            from unittest.mock import AsyncMock  # type: ignore[attr-defined]
+
+            if isinstance(func, AsyncMock):
+                return True
+        except ImportError:
+            # Not testing, no asynctest to import
+            pass
+
+        while inspect.ismethod(func):
+            func = func.__func__
+        while isinstance(func, partial):
+            func = func.func
+        if not inspect.isfunction(func):
+            return False
+
+        if func.__code__.co_flags & inspect.CO_COROUTINE:
+            return True
+
+        acic = asyncio.coroutines._is_coroutine  # type: ignore[attr-defined]
+        return getattr(func, "_is_coroutine", None) is acic
diff --git a/blinker/base.py b/src/blinker/base.py
similarity index 64%
rename from blinker/base.py
rename to src/blinker/base.py
index 8a3e4f9..80e24e2 100644
--- a/blinker/base.py
+++ b/src/blinker/base.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8; fill-column: 76 -*-
 """Signals and events.
 
 A small implementation of signals, inspired by a snippet of Django signal
@@ -8,26 +7,40 @@ each manages its own receivers and message emission.
 The :func:`signal` function provides singleton behavior for named signals.
 
 """
+from __future__ import annotations
+
+import typing as t
+from collections import defaultdict
 from contextlib import contextmanager
 from warnings import warn
 from weakref import WeakValueDictionary
 
-from blinker._utilities import (
-    WeakTypes,
-    defaultdict,
-    hashable_identity,
-    lazy_property,
-    reference,
-    symbol,
-    )
+from blinker._utilities import annotatable_weakref
+from blinker._utilities import hashable_identity
+from blinker._utilities import IdentityType
+from blinker._utilities import is_coroutine_function
+from blinker._utilities import lazy_property
+from blinker._utilities import reference
+from blinker._utilities import symbol
+from blinker._utilities import WeakTypes
+
+if t.TYPE_CHECKING:
+    import typing_extensions as te
+
+    T_callable = t.TypeVar("T_callable", bound=t.Callable[..., t.Any])
+
+    T = t.TypeVar("T")
+    P = te.ParamSpec("P")
 
+    AsyncWrapperType = t.Callable[[t.Callable[P, T]], t.Callable[P, t.Awaitable[T]]]
+    SyncWrapperType = t.Callable[[t.Callable[P, t.Awaitable[T]]], t.Callable[P, T]]
 
-ANY = symbol('ANY')
+ANY = symbol("ANY")
 ANY.__doc__ = 'Token for "any sender".'
 ANY_ID = 0
 
 
-class Signal(object):
+class Signal:
     """A notification emitter."""
 
     #: An :obj:`ANY` convenience synonym, allows ``Signal.ANY``
@@ -35,7 +48,7 @@ class Signal(object):
     ANY = ANY
 
     @lazy_property
-    def receiver_connected(self):
+    def receiver_connected(self) -> Signal:
         """Emitted after each :meth:`connect`.
 
         The signal sender is the signal instance, and the :meth:`connect`
@@ -47,7 +60,7 @@ class Signal(object):
         return Signal(doc="Emitted after a receiver connects.")
 
     @lazy_property
-    def receiver_disconnected(self):
+    def receiver_disconnected(self) -> Signal:
         """Emitted after :meth:`disconnect`.
 
         The sender is the signal instance, and the :meth:`disconnect` arguments
@@ -70,7 +83,7 @@ class Signal(object):
         """
         return Signal(doc="Emitted after a receiver disconnects.")
 
-    def __init__(self, doc=None):
+    def __init__(self, doc: str | None = None) -> None:
         """
         :param doc: optional.  If provided, will be assigned to the signal's
           __doc__ attribute.
@@ -84,12 +97,15 @@ class Signal(object):
         #: internal :class:`Signal` implementation, however the boolean value
         #: of the mapping is useful as an extremely efficient check to see if
         #: any receivers are connected to the signal.
-        self.receivers = {}
-        self._by_receiver = defaultdict(set)
-        self._by_sender = defaultdict(set)
-        self._weak_senders = {}
-
-    def connect(self, receiver, sender=ANY, weak=True):
+        self.receivers: dict[IdentityType, t.Callable | annotatable_weakref] = {}
+        self.is_muted = False
+        self._by_receiver: dict[IdentityType, set[IdentityType]] = defaultdict(set)
+        self._by_sender: dict[IdentityType, set[IdentityType]] = defaultdict(set)
+        self._weak_senders: dict[IdentityType, annotatable_weakref] = {}
+
+    def connect(
+        self, receiver: T_callable, sender: t.Any = ANY, weak: bool = True
+    ) -> T_callable:
         """Connect *receiver* to signal events sent by *sender*.
 
         :param receiver: A callable.  Will be invoked by :meth:`send` with
@@ -109,11 +125,14 @@ class Signal(object):
 
         """
         receiver_id = hashable_identity(receiver)
+        receiver_ref: T_callable | annotatable_weakref
+
         if weak:
             receiver_ref = reference(receiver, self._cleanup_receiver)
             receiver_ref.receiver_id = receiver_id
         else:
             receiver_ref = receiver
+        sender_id: IdentityType
         if sender is ANY:
             sender_id = ANY_ID
         else:
@@ -136,28 +155,27 @@ class Signal(object):
                 del sender_ref
 
         # broadcast this connection.  if receivers raise, disconnect.
-        if ('receiver_connected' in self.__dict__ and
-            self.receiver_connected.receivers):
+        if "receiver_connected" in self.__dict__ and self.receiver_connected.receivers:
             try:
-                self.receiver_connected.send(self,
-                                             receiver=receiver,
-                                             sender=sender,
-                                             weak=weak)
-            except:
+                self.receiver_connected.send(
+                    self, receiver=receiver, sender=sender, weak=weak
+                )
+            except TypeError as e:
                 self.disconnect(receiver, sender)
-                raise
+                raise e
         if receiver_connected.receivers and self is not receiver_connected:
             try:
-                receiver_connected.send(self,
-                                        receiver_arg=receiver,
-                                        sender_arg=sender,
-                                        weak_arg=weak)
-            except:
+                receiver_connected.send(
+                    self, receiver_arg=receiver, sender_arg=sender, weak_arg=weak
+                )
+            except TypeError as e:
                 self.disconnect(receiver, sender)
-                raise
+                raise e
         return receiver
 
-    def connect_via(self, sender, weak=False):
+    def connect_via(
+        self, sender: t.Any, weak: bool = False
+    ) -> t.Callable[[T_callable], T_callable]:
         """Connect the decorated function as a receiver for *sender*.
 
         :param sender: Any object or :obj:`ANY`.  The decorated function
@@ -178,13 +196,17 @@ class Signal(object):
         .. versionadded:: 1.1
 
         """
-        def decorator(fn):
+
+        def decorator(fn: T_callable) -> T_callable:
             self.connect(fn, sender, weak)
             return fn
+
         return decorator
 
     @contextmanager
-    def connected_to(self, receiver, sender=ANY):
+    def connected_to(
+        self, receiver: t.Callable, sender: t.Any = ANY
+    ) -> t.Generator[None, None, None]:
         """Execute a block with the signal temporarily connected to *receiver*.
 
         :param receiver: a receiver callable
@@ -207,13 +229,28 @@ class Signal(object):
         self.connect(receiver, sender=sender, weak=False)
         try:
             yield None
-        except:
+        except Exception as e:
             self.disconnect(receiver)
-            raise
+            raise e
         else:
             self.disconnect(receiver)
 
-    def temporarily_connected_to(self, receiver, sender=ANY):
+    @contextmanager
+    def muted(self) -> t.Generator[None, None, None]:
+        """Context manager for temporarily disabling signal.
+        Useful for test purposes.
+        """
+        self.is_muted = True
+        try:
+            yield None
+        except Exception as e:
+            raise e
+        finally:
+            self.is_muted = False
+
+    def temporarily_connected_to(
+        self, receiver: t.Callable, sender: t.Any = ANY
+    ) -> t.ContextManager[None]:
         """An alias for :meth:`connected_to`.
 
         :param receiver: a receiver callable
@@ -226,12 +263,18 @@ class Signal(object):
           deprecated in 1.2 and will be removed in a subsequent version.
 
         """
-        warn("temporarily_connected_to is deprecated; "
-             "use connected_to instead.",
-             DeprecationWarning)
+        warn(
+            "temporarily_connected_to is deprecated; use connected_to instead.",
+            DeprecationWarning,
+        )
         return self.connected_to(receiver, sender)
 
-    def send(self, *sender, **kwargs):
+    def send(
+        self,
+        *sender: t.Any,
+        _async_wrapper: AsyncWrapperType | None = None,
+        **kwargs: t.Any,
+    ) -> list[tuple[t.Callable, t.Any]]:
         """Emit this signal on behalf of *sender*, passing on ``kwargs``.
 
         Returns a list of 2-tuples, pairing receivers with their return
@@ -239,15 +282,65 @@ class Signal(object):
 
         :param sender: Any object or ``None``.  If omitted, synonymous
           with ``None``.  Only accepts one positional argument.
+        :param _async_wrapper: A callable that should wrap a coroutine
+          receiver and run it when called synchronously.
+
+        :param kwargs: Data to be sent to receivers.
+        """
+        if self.is_muted:
+            return []
+
+        sender = self._extract_sender(sender)
+        results = []
+        for receiver in self.receivers_for(sender):
+            if is_coroutine_function(receiver):
+                if _async_wrapper is None:
+                    raise RuntimeError("Cannot send to a coroutine function")
+                receiver = _async_wrapper(receiver)
+            result = receiver(sender, **kwargs)  # type: ignore[call-arg]
+            results.append((receiver, result))
+        return results
+
+    async def send_async(
+        self,
+        *sender: t.Any,
+        _sync_wrapper: SyncWrapperType | None = None,
+        **kwargs: t.Any,
+    ) -> list[tuple[t.Callable, t.Any]]:
+        """Emit this signal on behalf of *sender*, passing on ``kwargs``.
+
+        Returns a list of 2-tuples, pairing receivers with their return
+        value. The ordering of receiver notification is undefined.
+
+        :param sender: Any object or ``None``.  If omitted, synonymous
+          with ``None``. Only accepts one positional argument.
+        :param _sync_wrapper: A callable that should wrap a synchronous
+          receiver and run it when awaited.
 
         :param kwargs: Data to be sent to receivers.
         """
+        if self.is_muted:
+            return []
+
+        sender = self._extract_sender(sender)
+        results = []
+        for receiver in self.receivers_for(sender):
+            if not is_coroutine_function(receiver):
+                if _sync_wrapper is None:
+                    raise RuntimeError("Cannot send to a non-coroutine function")
+                receiver = _sync_wrapper(receiver)  # type: ignore[arg-type]
+            result = await receiver(sender, **kwargs)  # type: ignore[call-arg, misc]
+            results.append((receiver, result))
+        return results
+
+    def _extract_sender(self, sender: t.Any) -> t.Any:
         if not self.receivers:
             # Ensure correct signature even on no-op sends, disable with -O
             # for lowest possible cost.
             if __debug__ and sender and len(sender) > 1:
-                raise TypeError('send() accepts only one positional '
-                                'argument, %s given' % len(sender))
+                raise TypeError(
+                    f"send() accepts only one positional argument, {len(sender)} given"
+                )
             return []
 
         # Using '*sender' rather than 'sender=None' allows 'sender' to be
@@ -256,14 +349,14 @@ class Signal(object):
         if len(sender) == 0:
             sender = None
         elif len(sender) > 1:
-            raise TypeError('send() accepts only one positional argument, '
-                            '%s given' % len(sender))
+            raise TypeError(
+                f"send() accepts only one positional argument, {len(sender)} given"
+            )
         else:
             sender = sender[0]
-        return [(receiver, receiver(sender, **kwargs))
-                for receiver in self.receivers_for(sender)]
+        return sender
 
-    def has_receivers_for(self, sender):
+    def has_receivers_for(self, sender: t.Any) -> bool:
         """True if there is probably a receiver for *sender*.
 
         Performs an optimistic check only.  Does not guarantee that all
@@ -279,14 +372,15 @@ class Signal(object):
             return False
         return hashable_identity(sender) in self._by_sender
 
-    def receivers_for(self, sender):
+    def receivers_for(
+        self, sender: t.Any
+    ) -> t.Generator[t.Callable | annotatable_weakref, None, None]:
         """Iterate all live receivers listening for *sender*."""
         # TODO: test receivers_for(ANY)
         if self.receivers:
             sender_id = hashable_identity(sender)
             if sender_id in self._by_sender:
-                ids = (self._by_sender[ANY_ID] |
-                       self._by_sender[sender_id])
+                ids = self._by_sender[ANY_ID] | self._by_sender[sender_id]
             else:
                 ids = self._by_sender[ANY_ID].copy()
             for receiver_id in ids:
@@ -299,9 +393,9 @@ class Signal(object):
                         self._disconnect(receiver_id, ANY_ID)
                         continue
                     receiver = strong
-                yield receiver
+                yield receiver  # type: ignore[misc]
 
-    def disconnect(self, receiver, sender=ANY):
+    def disconnect(self, receiver: t.Callable, sender: t.Any = ANY) -> None:
         """Disconnect *receiver* from this signal's events.
 
         :param receiver: a previously :meth:`connected<connect>` callable
@@ -310,6 +404,7 @@ class Signal(object):
           to disconnect from all senders.  Defaults to ``ANY``.
 
         """
+        sender_id: IdentityType
         if sender is ANY:
             sender_id = ANY_ID
         else:
@@ -317,13 +412,13 @@ class Signal(object):
         receiver_id = hashable_identity(receiver)
         self._disconnect(receiver_id, sender_id)
 
-        if ('receiver_disconnected' in self.__dict__ and
-            self.receiver_disconnected.receivers):
-            self.receiver_disconnected.send(self,
-                                            receiver=receiver,
-                                            sender=sender)
+        if (
+            "receiver_disconnected" in self.__dict__
+            and self.receiver_disconnected.receivers
+        ):
+            self.receiver_disconnected.send(self, receiver=receiver, sender=sender)
 
-    def _disconnect(self, receiver_id, sender_id):
+    def _disconnect(self, receiver_id: IdentityType, sender_id: IdentityType) -> None:
         if sender_id == ANY_ID:
             if self._by_receiver.pop(receiver_id, False):
                 for bucket in self._by_sender.values():
@@ -333,19 +428,19 @@ class Signal(object):
             self._by_sender[sender_id].discard(receiver_id)
             self._by_receiver[receiver_id].discard(sender_id)
 
-    def _cleanup_receiver(self, receiver_ref):
+    def _cleanup_receiver(self, receiver_ref: annotatable_weakref) -> None:
         """Disconnect a receiver from all senders."""
-        self._disconnect(receiver_ref.receiver_id, ANY_ID)
+        self._disconnect(t.cast(IdentityType, receiver_ref.receiver_id), ANY_ID)
 
-    def _cleanup_sender(self, sender_ref):
+    def _cleanup_sender(self, sender_ref: annotatable_weakref) -> None:
         """Disconnect all receivers from a sender."""
-        sender_id = sender_ref.sender_id
+        sender_id = t.cast(IdentityType, sender_ref.sender_id)
         assert sender_id != ANY_ID
         self._weak_senders.pop(sender_id, None)
         for receiver_id in self._by_sender.pop(sender_id, ()):
             self._by_receiver[receiver_id].discard(sender_id)
 
-    def _cleanup_bookkeeping(self):
+    def _cleanup_bookkeeping(self) -> None:
         """Prune unused sender/receiver bookkeeping. Not threadsafe.
 
         Connecting & disconnecting leave behind a small amount of bookkeeping
@@ -371,7 +466,7 @@ class Signal(object):
                 if not bucket:
                     mapping.pop(_id, None)
 
-    def _clear_state(self):
+    def _clear_state(self) -> None:
         """Throw away all signal state.  Useful for unit tests."""
         self._weak_senders.clear()
         self.receivers.clear()
@@ -379,7 +474,8 @@ class Signal(object):
         self._by_receiver.clear()
 
 
-receiver_connected = Signal("""\
+receiver_connected = Signal(
+    """\
 Sent by a :class:`Signal` after a receiver connects.
 
 :argument: the Signal that was connected to
@@ -394,36 +490,38 @@ As of 1.2, individual signals have their own private
 :attr:`~Signal.receiver_disconnected` signals with a slightly simplified
 call signature.  This global signal is planned to be removed in 1.6.
 
-""")
+"""
+)
 
 
 class NamedSignal(Signal):
     """A named generic notification emitter."""
 
-    def __init__(self, name, doc=None):
+    def __init__(self, name: str, doc: str | None = None) -> None:
         Signal.__init__(self, doc)
 
         #: The name of this signal.
         self.name = name
 
-    def __repr__(self):
+    def __repr__(self) -> str:
         base = Signal.__repr__(self)
-        return "{}; {!r}>".format(base[:-1], self.name)
+        return f"{base[:-1]}; {self.name!r}>"
 
 
 class Namespace(dict):
     """A mapping of signal names to signals."""
 
-    def signal(self, name, doc=None):
+    def signal(self, name: str, doc: str | None = None) -> NamedSignal:
         """Return the :class:`NamedSignal` *name*, creating it if required.
 
         Repeated calls to this function will return the same signal object.
 
         """
         try:
-            return self[name]
+            return self[name]  # type: ignore[no-any-return]
         except KeyError:
-            return self.setdefault(name, NamedSignal(name, doc))
+            result = self.setdefault(name, NamedSignal(name, doc))
+            return result  # type: ignore[no-any-return]
 
 
 class WeakNamespace(WeakValueDictionary):
@@ -437,16 +535,17 @@ class WeakNamespace(WeakValueDictionary):
 
     """
 
-    def signal(self, name, doc=None):
+    def signal(self, name: str, doc: str | None = None) -> NamedSignal:
         """Return the :class:`NamedSignal` *name*, creating it if required.
 
         Repeated calls to this function will return the same signal object.
 
         """
         try:
-            return self[name]
+            return self[name]  # type: ignore[no-any-return]
         except KeyError:
-            return self.setdefault(name, NamedSignal(name, doc))
+            result = self.setdefault(name, NamedSignal(name, doc))
+            return result  # type: ignore[no-any-return]
 
 
 signal = Namespace().signal
diff --git a/src/blinker/py.typed b/src/blinker/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/tests/requirements.in b/tests/requirements.in
deleted file mode 100644
index e079f8a..0000000
--- a/tests/requirements.in
+++ /dev/null
@@ -1 +0,0 @@
-pytest
diff --git a/tests/requirements.txt b/tests/requirements.txt
deleted file mode 100644
index 07ddd98..0000000
--- a/tests/requirements.txt
+++ /dev/null
@@ -1,22 +0,0 @@
-#
-# This file is autogenerated by pip-compile with python 3.10
-# To update, run:
-#
-#    pip-compile tests/requirements.in
-#
-attrs==21.4.0
-    # via pytest
-iniconfig==1.1.1
-    # via pytest
-packaging==21.3
-    # via pytest
-pluggy==1.0.0
-    # via pytest
-py==1.11.0
-    # via pytest
-pyparsing==3.0.9
-    # via packaging
-pytest==7.1.2
-    # via -r tests/requirements.in
-tomli==2.0.1
-    # via pytest
diff --git a/tests/test_context.py b/tests/test_context.py
index 7521ac3..f45cba7 100644
--- a/tests/test_context.py
+++ b/tests/test_context.py
@@ -1,5 +1,3 @@
-from __future__ import with_statement
-
 from blinker import Signal
 
 
@@ -7,7 +5,9 @@ def test_temp_connection():
     sig = Signal()
 
     canary = []
-    receiver = lambda sender: canary.append(sender)
+
+    def receiver(sender):
+        canary.append(sender)
 
     sig.send(1)
     with sig.connected_to(receiver):
@@ -22,7 +22,9 @@ def test_temp_connection_for_sender():
     sig = Signal()
 
     canary = []
-    receiver = lambda sender: canary.append(sender)
+
+    def receiver(sender):
+        canary.append(sender)
 
     with sig.connected_to(receiver, sender=2):
         sig.send(1)
@@ -36,7 +38,9 @@ def test_temp_connection_failure():
     sig = Signal()
 
     canary = []
-    receiver = lambda sender: canary.append(sender)
+
+    def receiver(sender):
+        canary.append(sender)
 
     class Failure(Exception):
         pass
diff --git a/tests/test_saferef.py b/tests/test_saferef.py
index 0517fe5..123183b 100644
--- a/tests/test_saferef.py
+++ b/tests/test_saferef.py
@@ -37,7 +37,7 @@ import unittest
 from blinker._saferef import safe_ref
 
 
-class _Sample1(object):
+class _Sample1:
     def x(self):
         pass
 
@@ -46,7 +46,7 @@ def _sample2(obj):
     pass
 
 
-class _Sample3(object):
+class _Sample3:
     def __call__(self, obj):
         pass
 
@@ -60,14 +60,14 @@ class TestSaferef(unittest.TestCase):
     def setUp(self):
         ts = []
         ss = []
-        for x in range(100):
+        for _ in range(100):
             t = _Sample1()
             ts.append(t)
             s = safe_ref(t.x, self._closure)
             ss.append(s)
         ts.append(_sample2)
         ss.append(safe_ref(_sample2, self._closure))
-        for x in range(30):
+        for _ in range(30):
             t = _Sample3()
             ts.append(t)
             s = safe_ref(t, self._closure)
@@ -77,9 +77,9 @@ class TestSaferef(unittest.TestCase):
         self.closure_count = 0
 
     def tearDown(self):
-        if hasattr(self, 'ts'):
+        if hasattr(self, "ts"):
             del self.ts
-        if hasattr(self, 'ss'):
+        if hasattr(self, "ss"):
             del self.ss
 
     def test_In(self):
@@ -98,7 +98,7 @@ class TestSaferef(unittest.TestCase):
         for s in self.ss:
             sd[s] = 1
         for t in self.ts:
-            if hasattr(t, 'x'):
+            if hasattr(t, "x"):
                 assert safe_ref(t.x) in sd
             else:
                 assert safe_ref(t) in sd
diff --git a/tests/test_signals.py b/tests/test_signals.py
index e74db47..f06ebc0 100644
--- a/tests/test_signals.py
+++ b/tests/test_signals.py
@@ -2,13 +2,13 @@ import gc
 import sys
 import time
 
-import blinker
-
 import pytest
 
+import blinker
+
 
-jython = sys.platform.startswith('java')
-pypy = hasattr(sys, 'pypy_version_info')
+jython = sys.platform.startswith("java")
+pypy = hasattr(sys, "pypy_version_info")
 
 
 def collect_acyclic_refs():
@@ -28,14 +28,17 @@ class Sentinel(list):
         When connected to a signal, appends (key, sender, kw) to the Sentinel.
 
         """
+
         def receiver(*sentby, **kw):
             self.append((key, sentby[0], kw))
-        receiver.func_name = 'receiver_%s' % key
+
+        receiver.func_name = "receiver_%s" % key
         return receiver
 
 
 def test_meta_connect():
     sentinel = []
+
     def meta_received(sender, **kw):
         sentinel.append(dict(kw, sender=sender))
 
@@ -45,13 +48,18 @@ def test_meta_connect():
 
     def receiver(sender, **kw):
         pass
+
     sig = blinker.Signal()
     sig.connect(receiver)
 
-    assert sentinel == [{'sender': sig,
-                         'receiver_arg': receiver,
-                         'sender_arg': blinker.ANY,
-                         'weak_arg': True}]
+    assert sentinel == [
+        {
+            "sender": sig,
+            "receiver_arg": receiver,
+            "sender_arg": blinker.ANY,
+            "weak_arg": True,
+        }
+    ]
 
     blinker.receiver_connected._clear_state()
 
@@ -60,10 +68,10 @@ def _test_signal_signals(sender):
     sentinel = Sentinel()
     sig = blinker.Signal()
 
-    connected = sentinel.make_receiver('receiver_connected')
-    disconnected = sentinel.make_receiver('receiver_disconnected')
-    receiver1 = sentinel.make_receiver('receiver1')
-    receiver2 = sentinel.make_receiver('receiver2')
+    connected = sentinel.make_receiver("receiver_connected")
+    disconnected = sentinel.make_receiver("receiver_disconnected")
+    receiver1 = sentinel.make_receiver("receiver1")
+    receiver2 = sentinel.make_receiver("receiver2")
 
     assert not sig.receiver_connected.receivers
     assert not sig.receiver_disconnected.receivers
@@ -76,25 +84,27 @@ def _test_signal_signals(sender):
     for receiver, weak in [(receiver1, True), (receiver2, False)]:
         sig.connect(receiver, sender=sender, weak=weak)
 
-        expected = ('receiver_connected',
-                    sig,
-                    {'receiver': receiver, 'sender': sender, 'weak': weak})
+        expected = (
+            "receiver_connected",
+            sig,
+            {"receiver": receiver, "sender": sender, "weak": weak},
+        )
 
         assert sentinel[-1] == expected
 
     # disconnect from explicit sender
     sig.disconnect(receiver1, sender=sender)
 
-    expected = ('receiver_disconnected',
-                sig,
-                {'receiver': receiver1, 'sender': sender})
+    expected = ("receiver_disconnected", sig, {"receiver": receiver1, "sender": sender})
     assert sentinel[-1] == expected
 
     # disconnect from ANY and all senders (implicit disconnect signature)
     sig.disconnect(receiver2)
-    assert sentinel[-1] == ('receiver_disconnected',
-                            sig,
-                            {'receiver': receiver2, 'sender': blinker.ANY})
+    assert sentinel[-1] == (
+        "receiver_disconnected",
+        sig,
+        {"receiver": receiver2, "sender": blinker.ANY},
+    )
 
 
 def test_signal_signals_any_sender():
@@ -111,10 +121,10 @@ def test_signal_weak_receiver_vanishes():
     sentinel = Sentinel()
     sig = blinker.Signal()
 
-    connected = sentinel.make_receiver('receiver_connected')
-    disconnected = sentinel.make_receiver('receiver_disconnected')
-    receiver1 = sentinel.make_receiver('receiver1')
-    receiver2 = sentinel.make_receiver('receiver2')
+    connected = sentinel.make_receiver("receiver_connected")
+    disconnected = sentinel.make_receiver("receiver_disconnected")
+    receiver1 = sentinel.make_receiver("receiver1")
+    receiver2 = sentinel.make_receiver("receiver2")
 
     sig.receiver_connected.connect(connected)
     sig.receiver_disconnected.connect(disconnected)
@@ -124,7 +134,7 @@ def test_signal_weak_receiver_vanishes():
     sig.disconnect(receiver1)
 
     assert len(sentinel) == 2
-    assert sentinel[-1][2]['receiver'] is receiver1
+    assert sentinel[-1][2]["receiver"] is receiver1
 
     del sentinel[:]
     sig.connect(receiver2, weak=True)
@@ -138,7 +148,7 @@ def test_signal_weak_receiver_vanishes():
     assert len(sentinel) == 0
 
     # and everything really is disconnected
-    sig.send('abc')
+    sig.send("abc")
     assert len(sentinel) == 0
 
 
@@ -146,12 +156,12 @@ def test_signal_signals_weak_sender():
     sentinel = Sentinel()
     sig = blinker.Signal()
 
-    connected = sentinel.make_receiver('receiver_connected')
-    disconnected = sentinel.make_receiver('receiver_disconnected')
-    receiver1 = sentinel.make_receiver('receiver1')
-    receiver2 = sentinel.make_receiver('receiver2')
+    connected = sentinel.make_receiver("receiver_connected")
+    disconnected = sentinel.make_receiver("receiver_disconnected")
+    receiver1 = sentinel.make_receiver("receiver1")
+    receiver2 = sentinel.make_receiver("receiver2")
 
-    class Sender(object):
+    class Sender:
         """A weakref-able object."""
 
     sig.receiver_connected.connect(connected)
@@ -176,22 +186,29 @@ def test_signal_signals_weak_sender():
     assert len(sentinel) == 1
 
     # and everything really is disconnected
-    sig.send('abc')
+    sig.send("abc")
     assert len(sentinel) == 1
 
 
 def test_empty_bucket_growth():
+    def senders():
+        return (
+            len(sig._by_sender),
+            sum(len(i) for i in sig._by_sender.values()),
+        )
+
+    def receivers():
+        return (
+            len(sig._by_receiver),
+            sum(len(i) for i in sig._by_receiver.values()),
+        )
+
     sentinel = Sentinel()
     sig = blinker.Signal()
-    senders = lambda: (len(sig._by_sender),
-                       sum(len(i) for i in sig._by_sender.values()))
-    receivers = lambda: (len(sig._by_receiver),
-                         sum(len(i) for i in sig._by_receiver.values()))
-
-    receiver1 = sentinel.make_receiver('receiver1')
-    receiver2 = sentinel.make_receiver('receiver2')
+    receiver1 = sentinel.make_receiver("receiver1")
+    receiver2 = sentinel.make_receiver("receiver2")
 
-    class Sender(object):
+    class Sender:
         """A weakref-able object."""
 
     sender = Sender()
@@ -218,13 +235,14 @@ def test_empty_bucket_growth():
 
 def test_meta_connect_failure():
     def meta_received(sender, **kw):
-        raise TypeError('boom')
+        raise TypeError("boom")
 
     assert not blinker.receiver_connected.receivers
     blinker.receiver_connected.connect(meta_received)
 
     def receiver(sender, **kw):
         pass
+
     sig = blinker.Signal()
 
     pytest.raises(TypeError, sig.connect, receiver)
@@ -238,37 +256,38 @@ def test_meta_connect_failure():
 def test_weak_namespace():
     ns = blinker.WeakNamespace()
     assert not ns
-    s1 = ns.signal('abc')
-    assert s1 is ns.signal('abc')
-    assert s1 is not ns.signal('def')
-    assert 'abc' in ns
+    s1 = ns.signal("abc")
+    assert s1 is ns.signal("abc")
+    assert s1 is not ns.signal("def")
+    assert "abc" in ns
     collect_acyclic_refs()
 
     # weak by default, already out of scope
-    assert 'def' not in ns
+    assert "def" not in ns
     del s1
     collect_acyclic_refs()
 
-    assert 'abc' not in ns
+    assert "abc" not in ns
 
 
 def test_namespace():
     ns = blinker.Namespace()
     assert not ns
-    s1 = ns.signal('abc')
-    assert s1 is ns.signal('abc')
-    assert s1 is not ns.signal('def')
-    assert 'abc' in ns
+    s1 = ns.signal("abc")
+    assert s1 is ns.signal("abc")
+    assert s1 is not ns.signal("def")
+    assert "abc" in ns
 
     del s1
     collect_acyclic_refs()
 
-    assert 'def' in ns
-    assert 'abc' in ns
+    assert "def" in ns
+    assert "abc" in ns
 
 
 def test_weak_receiver():
     sentinel = []
+
     def received(sender, **kw):
         sentinel.append(kw)
 
@@ -295,8 +314,10 @@ def test_weak_receiver():
 
 def test_strong_receiver():
     sentinel = []
+
     def received(sender):
         sentinel.append(sender)
+
     fn_id = id(received)
 
     sig = blinker.Signal()
@@ -311,12 +332,39 @@ def test_strong_receiver():
     assert [id(fn) for fn in sig.receivers.values()] == [fn_id]
 
 
+async def test_async_receiver():
+    sentinel = []
+
+    async def received_async(sender):
+        sentinel.append(sender)
+
+    def received(sender):
+        sentinel.append(sender)
+
+    def wrapper(func):
+        async def inner(*args, **kwargs):
+            func(*args, **kwargs)
+
+        return inner
+
+    sig = blinker.Signal()
+    sig.connect(received)
+    sig.connect(received_async)
+
+    await sig.send_async(_sync_wrapper=wrapper)
+    assert len(sentinel) == 2
+
+    with pytest.raises(RuntimeError):
+        sig.send()
+
+
 def test_instancemethod_receiver():
     sentinel = []
 
-    class Receiver(object):
+    class Receiver:
         def __init__(self, bucket):
             self.bucket = bucket
+
         def received(self, sender):
             self.bucket.append(sender)
 
@@ -337,6 +385,7 @@ def test_instancemethod_receiver():
 
 def test_filtered_receiver():
     sentinel = []
+
     def received(sender):
         sentinel.append(sender)
 
@@ -367,11 +416,13 @@ def test_filtered_receiver():
 
 def test_filtered_receiver_weakref():
     sentinel = []
+
     def received(sender):
         sentinel.append(sender)
 
-    class Object(object):
+    class Object:
         pass
+
     obj = Object()
 
     sig = blinker.Signal()
@@ -394,8 +445,9 @@ def test_filtered_receiver_weakref():
 def test_decorated_receiver():
     sentinel = []
 
-    class Object(object):
+    class Object:
         pass
+
     obj = Object()
 
     sig = blinker.Signal()
@@ -419,6 +471,7 @@ def test_decorated_receiver():
 
 def test_no_double_send():
     sentinel = []
+
     def received(sender):
         sentinel.append(sender)
 
@@ -437,19 +490,21 @@ def test_no_double_send():
 
 
 def test_has_receivers():
-    received = lambda sender: None
+    def received(_):
+        return None
 
     sig = blinker.Signal()
     assert not sig.has_receivers_for(None)
     assert not sig.has_receivers_for(blinker.ANY)
 
-    sig.connect(received, 'xyz')
+    sig.connect(received, "xyz")
     assert not sig.has_receivers_for(None)
     assert not sig.has_receivers_for(blinker.ANY)
-    assert sig.has_receivers_for('xyz')
+    assert sig.has_receivers_for("xyz")
 
-    class Object(object):
+    class Object:
         pass
+
     o = Object()
 
     sig.connect(received, o)
@@ -458,31 +513,63 @@ def test_has_receivers():
     del received
     collect_acyclic_refs()
 
-    assert not sig.has_receivers_for('xyz')
-    assert list(sig.receivers_for('xyz')) == []
+    assert not sig.has_receivers_for("xyz")
+    assert list(sig.receivers_for("xyz")) == []
     assert list(sig.receivers_for(o)) == []
 
     sig.connect(lambda sender: None, weak=False)
-    assert sig.has_receivers_for('xyz')
+    assert sig.has_receivers_for("xyz")
     assert sig.has_receivers_for(o)
     assert sig.has_receivers_for(None)
     assert sig.has_receivers_for(blinker.ANY)
-    assert sig.has_receivers_for('xyz')
+    assert sig.has_receivers_for("xyz")
 
 
 def test_instance_doc():
-    sig = blinker.Signal(doc='x')
-    assert sig.__doc__ == 'x'
+    sig = blinker.Signal(doc="x")
+    assert sig.__doc__ == "x"
 
-    sig = blinker.Signal('x')
-    assert sig.__doc__ == 'x'
+    sig = blinker.Signal("x")
+    assert sig.__doc__ == "x"
 
 
 def test_named_blinker():
-    sig = blinker.NamedSignal('squiznart')
-    assert 'squiznart' in repr(sig)
+    sig = blinker.NamedSignal("squiznart")
+    assert "squiznart" in repr(sig)
+
+
+def test_mute_signal():
+    sentinel = []
+
+    def received(sender):
+        sentinel.append(sender)
+
+    sig = blinker.Signal()
+    sig.connect(received)
+
+    sig.send(123)
+    assert 123 in sentinel
+
+    with sig.muted():
+        sig.send(456)
+    assert 456 not in sentinel
 
 
 def values_are_empty_sets_(dictionary):
     for val in dictionary.values():
         assert val == set()
+
+
+def test_int_sender():
+    sentinel = []
+
+    def received(sender):
+        sentinel.append(sender)
+
+    sig = blinker.Signal()
+
+    sig.connect(received, sender=123456789)
+    sig.send(123456789)
+    assert len(sentinel) == 1
+    sig.send(123456789 + 0)  # Forces a new id with CPython
+    assert len(sentinel) == 2
diff --git a/tests/test_utilities.py b/tests/test_utilities.py
index 4f802f4..a07a53a 100644
--- a/tests/test_utilities.py
+++ b/tests/test_utilities.py
@@ -4,21 +4,21 @@ from blinker._utilities import symbol
 
 
 def test_symbols():
-    foo = symbol('foo')
-    assert foo.name == 'foo'
-    assert foo is symbol('foo')
+    foo = symbol("foo")
+    assert foo.name == "foo"
+    assert foo is symbol("foo")
 
-    bar = symbol('bar')
+    bar = symbol("bar")
     assert foo is not bar
     assert foo != bar
     assert not foo == bar
 
-    assert repr(foo) == 'foo'
+    assert repr(foo) == "foo"
 
 
 def test_pickled_symbols():
-    foo = symbol('foo')
+    foo = symbol("foo")
 
-    for protocol in 0, 1, 2:
+    for _ in 0, 1, 2:
         roundtrip = pickle.loads(pickle.dumps(foo))
         assert roundtrip is foo
diff --git a/tox.ini b/tox.ini
index 72fdd8e..75f9933 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,9 +1,27 @@
 [tox]
 envlist =
-    py3{11,10,9,8,7}
+    py3{12,11,10,9,8,7}
     pypy3{9,8,7}
+    docs
+    lint
+    typing
 skip_missing_interpreters = true
 
 [testenv]
-deps = -r tests/requirements.txt
+deps = -r requirements/tests.txt
 commands = pytest -v --tb=short --basetemp={envtmpdir} {posargs:tests}
+
+[testenv:docs]
+deps = -r requirements/docs.txt
+commands = sphinx-build -W -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html
+
+[testenv:lint]
+skip_install = true
+deps =
+    pre-commit>=2.20
+commands =
+    pre-commit run --all-files --show-diff-on-failure {tty:--color=always} {posargs}
+
+[testenv:typing]
+deps = -r requirements/typing.txt
+commands = mypy

More details

Full run details

Historical runs