This commit is contained in:
Adrian 2026-08-15 11:31:49 -05:00 committed by GitHub
commit aaa55f68e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 589 additions and 53 deletions

View File

@ -276,10 +276,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:

View File

@ -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",
@ -24,8 +25,6 @@ dependencies = [
"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"',
'brotli>=1.2.0; implementation_name != "pypy"',
'brotlicffi>=1.2.0.0; implementation_name == "pypy"',
]
@ -173,7 +172,6 @@ module = [
"brotli",
"brotlicffi",
"google.*",
"pydispatch.*",
"pyftpdlib.*",
"pytest_twisted",
"robotexclusionrulesparser",

View File

@ -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]]:
"""

View File

@ -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(),
}

View File

@ -0,0 +1,198 @@
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
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.
"""
parameters = list(inspect.signature(receiver).parameters.values())
return frozenset(
p.name
for p in parameters[:count]
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
)

View File

@ -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)

View File

@ -5,10 +5,10 @@ import pytest
from scrapy import signals
from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning
from scrapy.signalmanager import SignalManager
from scrapy.statscollectors import StatsCollector
from scrapy.utils.misc import build_from_crawler
from scrapy.utils.spider import DefaultSpider
from scrapy.utils.test import get_crawler
with warnings.catch_warnings():
warnings.filterwarnings(
@ -47,11 +47,8 @@ def test_from_crawler_without_recipients_raises_notconfigured():
build_from_crawler(statsmailer.StatsMailer, crawler)
def test_from_crawler_with_recipients_initializes_extension(dummy_stats, monkeypatch):
crawler = MagicMock()
crawler.settings.getlist.return_value = ["test@example.com"]
crawler.stats = dummy_stats
crawler.signals = SignalManager(crawler)
def test_from_crawler_with_recipients_initializes_extension(monkeypatch):
crawler = get_crawler(settings_dict={"STATSMAILER_RCPTS": ["test@example.com"]})
mailer = MagicMock(spec=MailSender)
monkeypatch.setattr(statsmailer.MailSender, "from_crawler", lambda _: mailer)
@ -63,21 +60,18 @@ def test_from_crawler_with_recipients_initializes_extension(dummy_stats, monkeyp
assert ext.mail is mailer
def test_from_crawler_connects_spider_closed_signal(dummy_stats, monkeypatch):
crawler = MagicMock()
crawler.settings.getlist.return_value = ["test@example.com"]
crawler.stats = dummy_stats
crawler.signals = SignalManager(crawler)
def test_from_crawler_connects_spider_closed_signal(monkeypatch):
crawler = get_crawler(settings_dict={"STATSMAILER_RCPTS": ["test@example.com"]})
mailer = MagicMock(spec=MailSender)
monkeypatch.setattr(statsmailer.MailSender, "from_crawler", lambda _: mailer)
build_from_crawler(statsmailer.StatsMailer, crawler)
ext = build_from_crawler(statsmailer.StatsMailer, crawler)
connected = crawler.signals.send_catch_log(
crawler.signals.send_catch_log(
signals.spider_closed, spider=DefaultSpider(name="dummy")
)
assert connected is not None
assert ext.mail.send.call_count == 1
def test_spider_closed_sends_email(dummy_stats):

View File

@ -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 (

View File

@ -0,0 +1,331 @@
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
from scrapy.utils.signal import send_catch_log
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_handler_that_cannot_be_introspected_gets_every_argument(self) -> None:
assert registry.apply(dict, spider="SPIDER") == {"spider": "SPIDER"}
def test_positional_arguments_are_not_repeated_as_keywords(self) -> None:
received: dict[str, Any] = {}
def handler(spider: Any, signal: Any = None) -> None:
received.update(spider=spider, signal=signal)
signal = object()
sender = object()
registry.connect(handler, signal, sender=sender)
send_catch_log(signal, sender, "SPIDER")
assert received == {"spider": "SPIDER", "signal": signal}
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_warning_falls_back_to_the_repr_of_the_signal(self) -> None:
signal = object()
assert registry._signal_name(signal) == repr(signal)
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 TestSignalStorage:
def test_signal_that_can_be_weakly_referenced_is_not_kept_alive(self) -> None:
class Signal:
pass
def handler(**kwargs: Any) -> None:
pass
signal = Signal()
ref = weakref.ref(signal)
sm = SignalManager(object())
sm.connect(handler, signal)
sm.send_catch_log(signal)
del signal
gc.collect()
assert ref() is None
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)

View File

@ -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

10
tox.ini
View File

@ -138,6 +138,7 @@ deps =
pytest==8.4.0
Protego==0.1.15
Twisted==21.7.0
blinker==1.7.0
brotli==1.2.0; implementation_name != "pypy"
brotlicffi==1.2.0.0; implementation_name == "pypy"
cryptography==37.0.0
@ -161,7 +162,6 @@ commands =
basepython = {[min]basepython}
deps =
{[min]deps}
PyDispatcher==2.0.5
setenv =
{[min]setenv}
commands = {[min]commands}
@ -213,14 +213,14 @@ deps =
# leaving the rest of the environment as the install above resolved it.
#
# Pillow and uvloop build from source, and need the libjpeg headers and
# autotools respectively. robotexclusionrulesparser has no public repository,
# and PyDispatcher has seen no commit since 2023-10-23, so they stay at their
# latest release.
# autotools respectively. robotexclusionrulesparser has no public repository, so
# it stays at its latest release.
commands_pre =
uv pip install --python {envpython} --no-deps --reinstall \
git+https://github.com/twisted/twisted \
git+https://github.com/python-pillow/Pillow \
git+https://github.com/MagicStack/uvloop \
git+https://github.com/pallets-eco/blinker \
git+https://github.com/pyca/cryptography \
git+https://github.com/scrapy/cssselect \
git+https://github.com/tiran/defusedxml \
@ -294,11 +294,11 @@ deps =
[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
brotlicffi==1.2.0.0
cryptography==44.0.2
cssselect==0.9.1