From 7289af962b8dfbbe2492ea04671dcc3c2a45af5e Mon Sep 17 00:00:00 2001 From: Adrian Chaves Date: Sat, 8 Aug 2026 15:31:42 +0200 Subject: [PATCH] Replace PyDispatcher with blinker --- docs/intro/install.rst | 4 - pyproject.toml | 5 +- scrapy/signalmanager.py | 8 +- scrapy/signals.py | 28 +++ scrapy/utils/_signal_registry.py | 203 +++++++++++++++++++ scrapy/utils/signal.py | 33 ++-- tests/test_utils_signal.py | 2 +- tests/test_utils_signal_registry.py | 293 ++++++++++++++++++++++++++++ tests/utils/engine.py | 2 +- tox.ini | 4 +- 10 files changed, 545 insertions(+), 37 deletions(-) create mode 100644 scrapy/utils/_signal_registry.py create mode 100644 tests/test_utils_signal_registry.py diff --git a/docs/intro/install.rst b/docs/intro/install.rst index cba8c15a1..443468f54 100644 --- a/docs/intro/install.rst +++ b/docs/intro/install.rst @@ -278,10 +278,6 @@ issues besides installing build dependencies. Installing Scrapy with PyPy on Windows is not tested. You can check that Scrapy is installed correctly by running ``scrapy bench``. -If this command gives errors such as -``TypeError: ... got 2 unexpected keyword arguments``, this means -that the ``PyPyDispatcher`` dependency wasn't installed. To fix this issue, run -``pip install 'PyPyDispatcher>=2.1.0'``. .. _intro-install-troubleshooting: diff --git a/pyproject.toml b/pyproject.toml index 0dcbade90..84513ee2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dynamic = ["version"] description = "A high-level Web Crawling and Web Scraping framework" dependencies = [ "Twisted>=21.7.0", + "blinker>=1.7.0", "cryptography>=37.0.0", "cssselect>=0.9.1", "defusedxml>=0.7.1", @@ -23,9 +24,6 @@ dependencies = [ "tldextract", "w3lib>=1.17.0", "zope.interface>=5.1.0", - # Platform-specific dependencies - 'PyDispatcher>=2.0.5; platform_python_implementation == "CPython"', - 'PyPyDispatcher>=2.1.0; platform_python_implementation == "PyPy"', ] classifiers = [ "Development Status :: 5 - Production/Stable", @@ -187,7 +185,6 @@ module = [ "brotli", "brotlicffi", "google.*", - "pydispatch.*", "pyftpdlib.*", "pytest_twisted", "robotexclusionrulesparser", diff --git a/scrapy/signalmanager.py b/scrapy/signalmanager.py index ca3b5b7dd..f02de31b3 100644 --- a/scrapy/signalmanager.py +++ b/scrapy/signalmanager.py @@ -3,16 +3,16 @@ from __future__ import annotations import warnings from typing import Any -from pydispatch import dispatcher from twisted.internet.defer import Deferred from scrapy.exceptions import ScrapyDeprecationWarning +from scrapy.utils import _signal_registry from scrapy.utils import signal as _signal from scrapy.utils.defer import maybe_deferred_to_future class SignalManager: - def __init__(self, sender: Any = dispatcher.Anonymous): + def __init__(self, sender: Any = _signal_registry.Anonymous): self.sender: Any = sender def connect(self, receiver: Any, signal: Any, **kwargs: Any) -> None: @@ -30,7 +30,7 @@ class SignalManager: :type signal: object """ kwargs.setdefault("sender", self.sender) - dispatcher.connect(receiver, signal, **kwargs) + _signal_registry.connect(receiver, signal, **kwargs) def disconnect(self, receiver: Any, signal: Any, **kwargs: Any) -> None: """ @@ -39,7 +39,7 @@ class SignalManager: are the same. """ kwargs.setdefault("sender", self.sender) - dispatcher.disconnect(receiver, signal, **kwargs) + _signal_registry.disconnect(receiver, signal, **kwargs) def send_catch_log(self, signal: Any, **kwargs: Any) -> list[tuple[Any, Any]]: """ diff --git a/scrapy/signals.py b/scrapy/signals.py index 3afeb6eab..36e5885a5 100644 --- a/scrapy/signals.py +++ b/scrapy/signals.py @@ -27,3 +27,31 @@ item_dropped = object() item_error = object() feed_slot_closed = object() feed_exporter_closed = object() + +#: Arguments that each signal sends, used to catch handlers that declare an +#: argument their signal never sends. ``signal`` and ``sender`` are omitted +#: because every signal sends them. +_signal_args: dict[object, frozenset[str]] = { + engine_started: frozenset(), + engine_stopped: frozenset(), + scheduler_empty: frozenset(), + spider_opened: frozenset({"spider"}), + spider_idle: frozenset({"spider"}), + spider_closed: frozenset({"spider", "reason"}), + spider_error: frozenset({"failure", "response", "spider"}), + memusage_warning_reached: frozenset(), + request_scheduled: frozenset({"request", "spider"}), + request_dropped: frozenset({"request", "spider"}), + request_reached_downloader: frozenset({"request", "spider"}), + request_left_downloader: frozenset({"request", "spider"}), + response_received: frozenset({"response", "request", "spider"}), + response_downloaded: frozenset({"response", "request", "spider"}), + headers_received: frozenset({"headers", "body_length", "request", "spider"}), + bytes_received: frozenset({"data", "request", "spider"}), + robots_parsed: frozenset({"robotparser", "request"}), + item_scraped: frozenset({"item", "response", "spider"}), + item_dropped: frozenset({"item", "response", "spider", "exception"}), + item_error: frozenset({"item", "response", "spider", "failure"}), + feed_slot_closed: frozenset({"slot"}), + feed_exporter_closed: frozenset(), +} diff --git a/scrapy/utils/_signal_registry.py b/scrapy/utils/_signal_registry.py new file mode 100644 index 000000000..19c91ffa5 --- /dev/null +++ b/scrapy/utils/_signal_registry.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import inspect +import warnings +from typing import TYPE_CHECKING +from typing import Any as TypingAny +from weakref import WeakKeyDictionary + +from blinker import ANY, Signal + +from scrapy import signals as _builtin_signals +from scrapy.utils.python import global_object_name + +if TYPE_CHECKING: + from collections.abc import Callable + +#: Sender that matches receivers connected to any sender. +Any = ANY + +#: Default sender of signals sent without one. +Anonymous = object() + +#: Arguments that every signal sends, on top of its own. +_COMMON_ARGS = frozenset({"signal", "sender"}) + + +class _OrderedSet(dict[TypingAny, TypingAny]): + """Insertion-ordered set covering the subset of the ``set`` API that + :attr:`blinker.Signal.set_class` requires. + + Receivers run in the order they were connected, which components rely on: + :class:`~scrapy.extensions.logcount.LogCount` and + :class:`~scrapy.extensions.logstats.LogStats` share a priority, so only + connection order puts the log counter in place before the first log message. + """ + + def add(self, item: TypingAny) -> None: + self[item] = None + + def discard(self, item: TypingAny) -> None: + self.pop(item, None) + + def copy(self) -> _OrderedSet: + return _OrderedSet(self) + + def __or__(self, other: TypingAny) -> _OrderedSet: + return _OrderedSet({**self, **other}) + + +class _Signal(Signal): + set_class = _OrderedSet # type: ignore[assignment] + + +# Signals are usually module-level constants, but a signal that goes out of scope +# should not keep its receivers alive. Signals that cannot be weakly referenced, +# such as strings, are keyed by value instead. +_signals: WeakKeyDictionary[TypingAny, _Signal] = WeakKeyDictionary() +_signals_by_value: dict[TypingAny, _Signal] = {} + + +def _signal_for(signal: TypingAny) -> _Signal: + try: + return _signals[signal] + except KeyError: + obj = _signals[signal] = _Signal() + return obj + except TypeError: + pass + try: + return _signals_by_value[signal] + except KeyError: + obj = _signals_by_value[signal] = _Signal() + return obj + + +# Keyed by the underlying function rather than by the receiver, so that a bound +# method does not keep its instance alive, and so that every instance of a +# component shares one entry. +_accepted_args: dict[TypingAny, frozenset[str] | None] = {} + + +def _cache_key(receiver: TypingAny) -> TypingAny: + func = getattr(receiver, "__func__", None) + if func is not None: + return func + if inspect.isroutine(receiver): + return receiver + return type(receiver) + + +def _accepted(receiver: TypingAny) -> frozenset[str] | None: + """Return the names of the arguments *receiver* accepts, or ``None`` if it + accepts arbitrary keyword arguments. + """ + key = _cache_key(receiver) + try: + return _accepted_args[key] + except KeyError: + pass + except TypeError: + return _introspect(receiver) + names = _introspect(receiver) + _accepted_args[key] = names + return names + + +def _introspect(receiver: TypingAny) -> frozenset[str] | None: + try: + parameters = inspect.signature(receiver).parameters.values() + except (TypeError, ValueError): + # Callables that cannot be introspected, e.g. some built-ins. + return None + if any(p.kind is p.VAR_KEYWORD for p in parameters): + return None + return frozenset( + p.name + for p in parameters + if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY) + ) + + +def _warn_unknown_args(receiver: TypingAny, signal: TypingAny) -> None: + sent = _builtin_signals._signal_args.get(signal) + if sent is None: + return + accepted = _accepted(receiver) + if accepted is None: + return + unknown = accepted - sent - _COMMON_ARGS + if not unknown: + return + names = ", ".join(sorted(unknown)) + warnings.warn( + f"Signal handler {global_object_name(receiver)} declares" + f" {names}, which {_signal_name(signal)} does not send. Handlers" + f" only receive the arguments of their signal, so those arguments always" + f" get their default value.", + stacklevel=4, + ) + + +def _signal_name(signal: TypingAny) -> str: + for name, value in vars(_builtin_signals).items(): + if value is signal: + return f"scrapy.signals.{name}" + return repr(signal) + + +def connect( + receiver: Callable[..., TypingAny], + signal: TypingAny, + sender: TypingAny = Any, + weak: bool = True, +) -> None: + if signal is Any: + raise ValueError( + "Connecting a receiver to every signal at once is not supported." + " Connect it to each signal that it handles instead." + ) + _warn_unknown_args(receiver, signal) + _signal_for(signal).connect(receiver, sender=sender, weak=weak) + + +def disconnect( + receiver: Callable[..., TypingAny], + signal: TypingAny, + sender: TypingAny = Any, +) -> None: + _signal_for(signal).disconnect(receiver, sender=sender) + + +def receivers(signal: TypingAny, sender: TypingAny) -> list[Callable[..., TypingAny]]: + """Return the live receivers of *signal* for *sender*, in connection order.""" + return list(_signal_for(signal).receivers_for(sender)) + + +def apply( + receiver: Callable[..., TypingAny], + *arguments: TypingAny, + **named: TypingAny, +) -> TypingAny: + """Call *receiver* with *arguments* and the entries of *named* that it accepts.""" + accepted = _accepted(receiver) + if accepted is not None: + if arguments: + accepted = accepted - _positional_names(receiver, len(arguments)) + named = {k: v for k, v in named.items() if k in accepted} + return receiver(*arguments, **named) + + +def _positional_names(receiver: TypingAny, count: int) -> frozenset[str]: + """Return the names of the first *count* parameters of *receiver*, which + positional arguments bind and keyword arguments therefore must not repeat. + """ + try: + parameters = list(inspect.signature(receiver).parameters.values()) + except (TypeError, ValueError): + return frozenset() + return frozenset( + p.name + for p in parameters[:count] + if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) + ) diff --git a/scrapy/utils/signal.py b/scrapy/utils/signal.py index eca95e225..ebae2c240 100644 --- a/scrapy/utils/signal.py +++ b/scrapy/utils/signal.py @@ -9,18 +9,11 @@ from collections.abc import Awaitable, Callable, Generator, Sequence from typing import Any as TypingAny from typing import cast -from pydispatch.dispatcher import ( - Anonymous, - Any, - disconnect, - getAllReceivers, - liveReceivers, -) -from pydispatch.robustapply import robustApply from twisted.internet.defer import Deferred, DeferredList, inlineCallbacks from twisted.python.failure import Failure from scrapy.exceptions import ScrapyDeprecationWarning, StopDownload +from scrapy.utils._signal_registry import Anonymous, Any, apply, disconnect, receivers from scrapy.utils.asyncio import is_asyncio_available from scrapy.utils.defer import ( _maybeDeferred_coro, @@ -39,18 +32,18 @@ def send_catch_log( *arguments: TypingAny, **named: TypingAny, ) -> list[tuple[TypingAny, TypingAny]]: - """Like ``pydispatch.robust.sendRobust()`` but it also logs errors and returns - Failures instead of exceptions. + """Send *signal*, logging any error raised by a handler and returning Failures + instead of raising. """ dont_log = named.pop("dont_log", ()) dont_log = tuple(dont_log) if isinstance(dont_log, Sequence) else (dont_log,) dont_log += (StopDownload,) spider = named.get("spider") responses: list[tuple[TypingAny, TypingAny]] = [] - for receiver in liveReceivers(getAllReceivers(sender, signal)): + for receiver in receivers(signal, sender): result: TypingAny try: - response = robustApply( + response = apply( receiver, *arguments, signal=signal, sender=sender, **named ) if isinstance(response, Deferred): @@ -114,9 +107,9 @@ def _send_catch_log_deferred( dont_log = named.pop("dont_log", None) spider = named.get("spider") dfds: list[Deferred[tuple[TypingAny, TypingAny]]] = [] - for receiver in liveReceivers(getAllReceivers(sender, signal)): + for receiver in receivers(signal, sender): d: Deferred[TypingAny] = _maybeDeferred_coro( - robustApply, + apply, True, receiver, *arguments, @@ -181,17 +174,15 @@ async def _send_catch_log_asyncio( dont_log = tuple(dont_log) if isinstance(dont_log, Sequence) else (dont_log,) spider = named.get("spider") handlers: list[Awaitable[TypingAny]] = [] - for receiver in liveReceivers(getAllReceivers(sender, signal)): + for receiver in receivers(signal, sender): async def handler( - receiver: Callable[..., Any], - ) -> tuple[Callable[..., Any], TypingAny]: + receiver: Callable[..., TypingAny], + ) -> tuple[Callable[..., TypingAny], TypingAny]: result: TypingAny try: result = await ensure_awaitable( - robustApply( - receiver, *arguments, signal=signal, sender=sender, **named - ), + apply(receiver, *arguments, signal=signal, sender=sender, **named), _warn=global_object_name(receiver), ) except dont_log as ex: # pylint: disable=catching-non-exception @@ -218,5 +209,5 @@ def disconnect_all(signal: TypingAny = Any, sender: TypingAny = Any) -> None: """Disconnect all signal handlers. Useful for cleaning up after running tests. """ - for receiver in liveReceivers(getAllReceivers(sender, signal)): + for receiver in receivers(signal, sender): disconnect(receiver, signal=signal, sender=sender) diff --git a/tests/test_utils_signal.py b/tests/test_utils_signal.py index 3109bc149..0968368e7 100644 --- a/tests/test_utils_signal.py +++ b/tests/test_utils_signal.py @@ -4,10 +4,10 @@ import asyncio from typing import TYPE_CHECKING, Any import pytest -from pydispatch import dispatcher from twisted.internet import defer from twisted.python.failure import Failure +from scrapy.utils import _signal_registry as dispatcher from scrapy.utils.asyncio import call_later from scrapy.utils.defer import deferred_from_coro, ensure_awaitable from scrapy.utils.signal import ( diff --git a/tests/test_utils_signal_registry.py b/tests/test_utils_signal_registry.py new file mode 100644 index 000000000..c86d11a9f --- /dev/null +++ b/tests/test_utils_signal_registry.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import gc +import weakref +from collections import Counter +from functools import partial +from typing import TYPE_CHECKING, Any + +import pytest + +from scrapy import signals +from scrapy.signalmanager import SignalManager +from scrapy.utils import _signal_registry as registry + +if TYPE_CHECKING: + from collections.abc import Callable + + +class TestArgumentDelivery: + def test_handler_gets_only_the_arguments_it_declares(self) -> None: + received: dict[str, Any] = {} + + def handler(spider: Any = None) -> None: + received.update(spider=spider) + + signal = object() + sm = SignalManager(object()) + sm.connect(handler, signal) + sm.send_catch_log(signal, spider="SPIDER") + assert received == {"spider": "SPIDER"} + + def test_handler_with_kwargs_gets_every_argument(self) -> None: + received: dict[str, Any] = {} + + def handler(**kwargs: Any) -> None: + received.update(kwargs) + + signal = object() + sm = SignalManager(object()) + sm.connect(handler, signal) + sm.send_catch_log(signal, spider="SPIDER") + assert received["spider"] == "SPIDER" + assert received["signal"] is signal + + def test_keyword_only_handler(self) -> None: + received: dict[str, Any] = {} + + def handler(*, spider: Any = None) -> None: + received.update(spider=spider) + + signal = object() + sm = SignalManager(object()) + sm.connect(handler, signal) + sm.send_catch_log(signal, spider="SPIDER") + assert received == {"spider": "SPIDER"} + + def test_partial_handler(self) -> None: + received: dict[str, Any] = {} + + def handler(prefix: str, spider: Any = None) -> None: + received.update(spider=f"{prefix}{spider}") + + bound = partial(handler, "p-") + signal = object() + sm = SignalManager(object()) + sm.connect(bound, signal) + sm.send_catch_log(signal, spider="SPIDER") + assert received == {"spider": "p-SPIDER"} + + def test_callable_object_handler(self) -> None: + class Handler: + def __init__(self) -> None: + self.spider: Any = None + + def __call__(self, spider: Any = None) -> None: + self.spider = spider + + handler = Handler() + signal = object() + sm = SignalManager(object()) + sm.connect(handler, signal) + sm.send_catch_log(signal, spider="SPIDER") + assert handler.spider == "SPIDER" + + +def _appending_handlers(calls: list[int], count: int) -> list[Callable[..., None]]: + def make(n: int) -> Callable[..., None]: + def handler(**kwargs: Any) -> None: + calls.append(n) + + return handler + + return [make(n) for n in range(count)] + + +class TestDispatchOrder: + def test_handlers_run_in_connection_order(self) -> None: + calls: list[int] = [] + signal = object() + sm = SignalManager(object()) + handlers = _appending_handlers(calls, 10) + for handler in handlers: + sm.connect(handler, signal) + sm.send_catch_log(signal) + assert calls == list(range(10)) + + def test_order_survives_disconnect(self) -> None: + calls: list[int] = [] + signal = object() + sm = SignalManager(object()) + handlers = _appending_handlers(calls, 5) + for handler in handlers: + sm.connect(handler, signal) + sm.disconnect(handlers[2], signal) + sm.send_catch_log(signal) + assert calls == [0, 1, 3, 4] + + +class TestDisconnect: + def test_disconnected_handler_stops_running(self) -> None: + calls: list[str] = [] + signal = object() + sm = SignalManager(object()) + + def kept(**kwargs: Any) -> None: + calls.append("kept") + + def dropped(**kwargs: Any) -> None: + calls.append("dropped") + + sm.connect(kept, signal) + sm.connect(dropped, signal) + sm.disconnect(dropped, signal) + sm.send_catch_log(signal) + assert calls == ["kept"] + + def test_every_handler_runs(self) -> None: + calls: set[str] = set() + signal = object() + sm = SignalManager(object()) + handlers = [] + for name in ("a", "b", "c"): + + def handler(_name: str = name, **kwargs: Any) -> None: + calls.add(_name) + + handlers.append(handler) + sm.connect(handler, signal) + sm.send_catch_log(signal) + assert calls == {"a", "b", "c"} + + +class TestSenderIsolation: + def test_handlers_only_run_for_their_sender(self) -> None: + calls: list[str] = [] + + def first_handler(**kwargs: Any) -> None: + calls.append("first") + + def second_handler(**kwargs: Any) -> None: + calls.append("second") + + signal = object() + first, second = SignalManager(object()), SignalManager(object()) + first.connect(first_handler, signal) + second.connect(second_handler, signal) + first.send_catch_log(signal) + assert calls == ["first"] + + +class TestUnknownArgumentWarning: + def test_warns_about_an_argument_the_signal_does_not_send(self) -> None: + def handler(reason: Any = None) -> None: + pass + + sm = SignalManager(object()) + with pytest.warns(UserWarning, match="declares reason"): + sm.connect(handler, signals.spider_opened) + + def test_warning_names_the_signal(self) -> None: + def handler(item: Any = None) -> None: + pass + + sm = SignalManager(object()) + with pytest.warns(UserWarning, match=r"scrapy\.signals\.spider_opened"): + sm.connect(handler, signals.spider_opened) + + def test_lists_every_unknown_argument(self) -> None: + def handler(cheese: Any = None, ham: Any = None) -> None: + pass + + sm = SignalManager(object()) + with pytest.warns(UserWarning, match="declares cheese, ham"): + sm.connect(handler, signals.spider_opened) + + @pytest.mark.filterwarnings("error") + def test_no_warning_for_a_matching_handler(self) -> None: + def handler(spider: Any = None, signal: Any = None, sender: Any = None) -> None: + pass + + SignalManager(object()).connect(handler, signals.spider_opened) + + @pytest.mark.filterwarnings("error") + def test_no_warning_for_a_kwargs_handler(self) -> None: + def handler(anything: Any = None, **kwargs: Any) -> None: + pass + + SignalManager(object()).connect(handler, signals.spider_opened) + + @pytest.mark.filterwarnings("error") + def test_no_warning_for_an_unknown_signal(self) -> None: + def handler(whatever: Any = None) -> None: + pass + + SignalManager(object()).connect(handler, object()) + + +class TestSignalArgs: + def test_every_signal_declares_its_arguments(self) -> None: + defined = { + value + for name, value in vars(signals).items() + if not name.startswith("_") and isinstance(value, object) + } + documented = set(signals._signal_args) + assert defined - documented == set() + + +class TestReceiverCache: + def test_bound_method_handler_is_not_kept_alive(self) -> None: + class Component: + def handler(self, spider: Any = None) -> None: + pass + + component = Component() + ref = weakref.ref(component) + signal = object() + sm = SignalManager(object()) + sm.connect(component.handler, signal) + del component + gc.collect() + assert ref() is None + assert registry.receivers(signal, sm.sender) == [] + + +class TestMutationDuringDispatch: + def test_handler_can_disconnect_itself(self) -> None: + calls: list[str] = [] + signal = object() + sm = SignalManager(object()) + + def handler(**kwargs: Any) -> None: + calls.append("handler") + sm.disconnect(handler, signal) + + def other(**kwargs: Any) -> None: + calls.append("other") + + sm.connect(handler, signal) + sm.connect(other, signal) + sm.send_catch_log(signal) + sm.send_catch_log(signal) + assert Counter(calls) == {"handler": 1, "other": 2} + + def test_handler_can_connect_another_handler(self) -> None: + calls: list[str] = [] + signal = object() + sm = SignalManager(object()) + added: list[Callable[..., None]] = [] + + def late(**kwargs: Any) -> None: + calls.append("late") + + def handler(**kwargs: Any) -> None: + calls.append("handler") + if not added: + added.append(late) + sm.connect(late, signal) + + sm.connect(handler, signal) + sm.send_catch_log(signal) + assert Counter(calls) == {"handler": 1} + sm.send_catch_log(signal) + assert Counter(calls) == {"handler": 2, "late": 1} + + +class TestWildcardSignal: + def test_connecting_to_every_signal_is_rejected(self) -> None: + def handler(**kwargs: Any) -> None: + pass + + with pytest.raises(ValueError, match="every signal"): + SignalManager(object()).connect(handler, registry.Any) diff --git a/tests/utils/engine.py b/tests/utils/engine.py index c193d39a9..7b06fbeb1 100644 --- a/tests/utils/engine.py +++ b/tests/utils/engine.py @@ -8,7 +8,6 @@ from urllib.parse import urlparse import attr from itemadapter import ItemAdapter -from pydispatch import dispatcher from twisted.internet import defer from scrapy import signals @@ -16,6 +15,7 @@ from scrapy.http import Headers, Request, Response from scrapy.item import Field, Item from scrapy.linkextractors import LinkExtractor from scrapy.spiders import Spider +from scrapy.utils import _signal_registry as dispatcher from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.signal import disconnect_all from scrapy.utils.test import get_crawler diff --git a/tox.ini b/tox.ini index c56ad011b..6bc3c36c5 100644 --- a/tox.ini +++ b/tox.ini @@ -136,6 +136,7 @@ deps = pytest==8.4.0 Protego==0.1.15 Twisted==21.7.0 + blinker==1.7.0 cryptography==37.0.0 cssselect==0.9.1 httpx2==2.0.0 @@ -157,7 +158,6 @@ commands = basepython = {[min]basepython} deps = {[min]deps} - PyDispatcher==2.0.5 setenv = {[min]setenv} commands = {[min]commands} @@ -250,11 +250,11 @@ commands = {[testenv:pypy3]commands} [testenv:min-pypy3] basepython = pypy3.11 deps = - PyPyDispatcher==2.1.0 {[test-requirements]deps} pytest==8.4.0 Protego==0.1.15 Twisted==21.7.0 + blinker==1.7.0 cryptography==44.0.2 cssselect==0.9.1 itemadapter==0.1.0